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 } }