132 lines
4.3 KiB
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
|
|
}
|
|
}
|