import AVFoundation import CryptoKit import Foundation import VelodyDomain import VelodyPersistence final class AVFoundationMetadataReader: MetadataReader { private let artworkExtractor: MP3EmbeddedArtworkExtractor init(artworkExtractor: MP3EmbeddedArtworkExtractor = MP3EmbeddedArtworkExtractor()) { self.artworkExtractor = artworkExtractor } 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) let artwork = try? artworkExtractor.extractArtwork(from: fileURL) return LocalTrackMetadata( title: commonMetadata.firstStringValue(for: .commonIdentifierTitle), artist: commonMetadata.firstStringValue(for: .commonIdentifierArtist), album: commonMetadata.firstStringValue(for: .commonIdentifierAlbumName), durationSeconds: duration?.seconds.validDurationSeconds, artwork: artwork ) } } final class FileSystemLocalMusicScanner: LocalMusicScanner { private let metadataReader: any MetadataReader private let artworkStore: any LocalArtworkStore private let fileHasher: SHA256FileHasher private let fileManager: FileManager init( metadataReader: any MetadataReader, artworkStore: any LocalArtworkStore, fileHasher: SHA256FileHasher = SHA256FileHasher(), fileManager: FileManager = .default ) { self.metadataReader = metadataReader self.artworkStore = artworkStore self.fileHasher = fileHasher self.fileManager = fileManager } func scanFolder(at folderURL: URL) async throws -> [ScannedLocalTrack] { let hasScopedAccess = folderURL.startAccessingSecurityScopedResource() defer { if hasScopedAccess { folderURL.stopAccessingSecurityScopedResource() } } guard let enumerator = fileManager.enumerator( at: folderURL, includingPropertiesForKeys: [ .isRegularFileKey, .contentModificationDateKey, ], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) else { throw CocoaError(.fileReadUnknown) } var discoveredTracks: [ScannedLocalTrack] = [] for case let fileURL as URL in enumerator { guard shouldScan(fileURL) else { continue } let fallbackTitle = fileURL.deletingPathExtension().lastPathComponent let sha256 = try fileHasher.hashFile(at: fileURL) let fileModifiedAt = modificationDate(for: fileURL) do { let metadata = try await metadataReader.readMetadata(for: fileURL) let artwork = try await persistedArtwork(from: metadata.artwork) discoveredTracks.append( ScannedLocalTrack( title: metadata.title?.trimmedNonEmpty ?? fallbackTitle, artist: metadata.artist?.trimmedNonEmpty ?? "Unknown Artist", album: metadata.album?.trimmedNonEmpty, durationSeconds: metadata.durationSeconds, localFilePath: fileURL.path, sha256: sha256, artwork: artwork, fileModifiedAt: fileModifiedAt ) ) } catch { discoveredTracks.append( ScannedLocalTrack( title: fallbackTitle, artist: "Unknown Artist", album: nil, durationSeconds: nil, localFilePath: fileURL.path, sha256: sha256, artwork: nil, fileModifiedAt: fileModifiedAt ) ) } } 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 func modificationDate(for fileURL: URL) -> Date { if let resourceValues = try? fileURL.resourceValues(forKeys: [.contentModificationDateKey]), let contentModificationDate = resourceValues.contentModificationDate { return contentModificationDate } if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path), let modificationDate = attributes[.modificationDate] as? Date { return modificationDate } return .distantPast } private func persistedArtwork( from artwork: LocalTrackMetadata.EmbeddedArtworkPayload? ) async throws -> LocalTrackArtwork? { guard let artwork else { return nil } let localFilePath = try await artworkStore.saveArtwork( artwork.data, sha256: artwork.sha256, mimeType: artwork.mimeType ) return LocalTrackArtwork( localFilePath: localFilePath, sha256: artwork.sha256, mimeType: artwork.mimeType, width: artwork.width, height: artwork.height ) } } final class SHA256FileHasher { private let bufferSize = 1_048_576 func hashFile(at fileURL: URL) throws -> String { let fileHandle = try FileHandle(forReadingFrom: fileURL) defer { try? fileHandle.close() } var hasher = SHA256() while true { let data = try fileHandle.read(upToCount: bufferSize) ?? Data() if data.isEmpty { break } hasher.update(data: data) } return hasher.finalize().map { String(format: "%02x", $0) }.joined() } } 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 } }