velody/apps/apple/VelodyMac/Sources/LocalMusicScanner.swift
2026-05-28 13:32:40 +02:00

180 lines
5.8 KiB
Swift

import AVFoundation
import CryptoKit
import Foundation
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 fileHasher: SHA256FileHasher
private let fileManager: FileManager
init(
metadataReader: any MetadataReader,
fileHasher: SHA256FileHasher = SHA256FileHasher(),
fileManager: FileManager = .default
) {
self.metadataReader = metadataReader
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)
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,
fileModifiedAt: fileModifiedAt
)
)
} catch {
discoveredTracks.append(
ScannedLocalTrack(
title: fallbackTitle,
artist: "Unknown Artist",
album: nil,
durationSeconds: nil,
localFilePath: fileURL.path,
sha256: sha256,
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
}
}
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
}
}