velody/apps/apple/VelodyMac/Sources/LocalMusicScanner.swift

132 lines
4.3 KiB
Swift

import AVFoundation
import Foundation
import VelodyDomain
import VelodyPersistence
final class AVFoundationMetadataReader: MetadataReader {
func readMetadata(for fileURL: URL) async throws -> LocalTrackMetadata {
let asset = AVURLAsset(url: fileURL)
let commonMetadata = try await asset.load(.commonMetadata)
let duration = try? await asset.load(.duration)
return LocalTrackMetadata(
title: commonMetadata.firstStringValue(for: .commonIdentifierTitle),
artist: commonMetadata.firstStringValue(for: .commonIdentifierArtist),
album: commonMetadata.firstStringValue(for: .commonIdentifierAlbumName),
durationSeconds: duration?.seconds.validDurationSeconds
)
}
}
final class FileSystemLocalMusicScanner: LocalMusicScanner {
private let metadataReader: any MetadataReader
private let fileManager: FileManager
init(
metadataReader: any MetadataReader,
fileManager: FileManager = .default
) {
self.metadataReader = metadataReader
self.fileManager = fileManager
}
func scanFolder(at folderURL: URL) async throws -> [LibraryTrack] {
let hasScopedAccess = folderURL.startAccessingSecurityScopedResource()
defer {
if hasScopedAccess {
folderURL.stopAccessingSecurityScopedResource()
}
}
guard let enumerator = fileManager.enumerator(
at: folderURL,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles, .skipsPackageDescendants]
) else {
throw CocoaError(.fileReadUnknown)
}
var discoveredTracks: [LibraryTrack] = []
for case let fileURL as URL in enumerator {
guard shouldScan(fileURL) else {
continue
}
let fallbackTitle = fileURL.deletingPathExtension().lastPathComponent
do {
let metadata = try await metadataReader.readMetadata(for: fileURL)
discoveredTracks.append(
LibraryTrack(
id: fileURL.path,
title: metadata.title?.trimmedNonEmpty ?? fallbackTitle,
artist: metadata.artist?.trimmedNonEmpty ?? "Unknown Artist",
album: metadata.album?.trimmedNonEmpty,
durationSeconds: metadata.durationSeconds,
localFilePath: fileURL.path,
sha256: nil
)
)
} catch {
discoveredTracks.append(
LibraryTrack(
id: fileURL.path,
title: fallbackTitle,
artist: "Unknown Artist",
album: nil,
durationSeconds: nil,
localFilePath: fileURL.path,
sha256: nil
)
)
}
}
return discoveredTracks.sorted { lhs, rhs in
let titleOrder = lhs.title.localizedCaseInsensitiveCompare(rhs.title)
if titleOrder == .orderedSame {
return lhs.localFilePath.localizedCaseInsensitiveCompare(rhs.localFilePath) == .orderedAscending
}
return titleOrder == .orderedAscending
}
}
private func shouldScan(_ fileURL: URL) -> Bool {
guard fileURL.pathExtension.lowercased() == "mp3" else {
return false
}
let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey])
return resourceValues?.isRegularFile == true
}
}
private extension Array where Element == AVMetadataItem {
func firstStringValue(for identifier: AVMetadataIdentifier) -> String? {
AVMetadataItem
.metadataItems(from: self, filteredByIdentifier: identifier)
.first?
.stringValue?
.trimmedNonEmpty
}
}
private extension Double {
var validDurationSeconds: Double? {
guard isFinite, self > 0 else {
return nil
}
return self
}
}
private extension String {
var trimmedNonEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}