180 lines
5.8 KiB
Swift
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
|
|
}
|
|
}
|