feat/music-streaming
parent
e0e03daf3f
commit
49693e95b4
@ -0,0 +1,200 @@ |
|||||||
|
import Foundation |
||||||
|
import AVFoundation |
||||||
|
|
||||||
|
// With the project-wide `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` setting, |
||||||
|
// this class is implicitly @MainActor. That's intentional: @Observable properties |
||||||
|
// (isScanning, scanProgress) drive the UI and must be updated on the main actor. |
||||||
|
// Static utility methods are marked `nonisolated` so they can run off the main actor. |
||||||
|
@Observable |
||||||
|
final class ScannerService { |
||||||
|
var isScanning = false |
||||||
|
var scanProgress: (current: Int, total: Int) = (0, 0) |
||||||
|
|
||||||
|
let db: DatabaseService |
||||||
|
|
||||||
|
init(db: DatabaseService) { |
||||||
|
self.db = db |
||||||
|
} |
||||||
|
|
||||||
|
nonisolated static let audioExtensions: Set<String> = ["mp3", "m4a", "aac", "wav", "aiff", "alac", "flac"] |
||||||
|
|
||||||
|
nonisolated static func discoverAudioFiles(in folder: URL) -> [URL] { |
||||||
|
var results: [URL] = [] |
||||||
|
guard let enumerator = FileManager.default.enumerator( |
||||||
|
at: folder, |
||||||
|
includingPropertiesForKeys: [.isRegularFileKey], |
||||||
|
options: [.skipsHiddenFiles] |
||||||
|
) else { return results } |
||||||
|
|
||||||
|
while let url = enumerator.nextObject() as? URL { |
||||||
|
if audioExtensions.contains(url.pathExtension.lowercased()) { |
||||||
|
results.append(url) |
||||||
|
} |
||||||
|
} |
||||||
|
return results |
||||||
|
} |
||||||
|
|
||||||
|
func scanFolder(_ folder: URL) async { |
||||||
|
isScanning = true |
||||||
|
defer { isScanning = false } |
||||||
|
|
||||||
|
let audioFiles = Self.discoverAudioFiles(in: folder) |
||||||
|
scanProgress = (0, audioFiles.count) |
||||||
|
|
||||||
|
let existingURLs = (try? db.allFileURLs()) ?? [] |
||||||
|
let batchSize = 50 |
||||||
|
|
||||||
|
for batchStart in stride(from: 0, to: audioFiles.count, by: batchSize) { |
||||||
|
let batchEnd = min(batchStart + batchSize, audioFiles.count) |
||||||
|
let batch = Array(audioFiles[batchStart..<batchEnd]) |
||||||
|
|
||||||
|
var tracks: [Track] = [] |
||||||
|
for fileURL in batch { |
||||||
|
let urlString = fileURL.absoluteString |
||||||
|
if existingURLs.contains(urlString) { continue } |
||||||
|
if let track = await Self.extractMetadata(from: fileURL) { |
||||||
|
tracks.append(track) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if !tracks.isEmpty { |
||||||
|
try? db.insertBatch(tracks) |
||||||
|
} |
||||||
|
scanProgress = (batchEnd, audioFiles.count) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func rescan(_ folder: URL) async { |
||||||
|
let audioFiles = Self.discoverAudioFiles(in: folder) |
||||||
|
let currentFileURLs = Set(audioFiles.map { $0.absoluteString }) |
||||||
|
let storedURLs = (try? db.allFileURLs()) ?? [] |
||||||
|
|
||||||
|
let removedURLs = storedURLs.subtracting(currentFileURLs) |
||||||
|
if !removedURLs.isEmpty { |
||||||
|
try? db.deleteTracksWithURLs(removedURLs) |
||||||
|
} |
||||||
|
|
||||||
|
await scanFolder(folder) |
||||||
|
} |
||||||
|
|
||||||
|
nonisolated static func extractMetadata(from url: URL) async -> Track? { |
||||||
|
let asset = AVURLAsset(url: url) |
||||||
|
|
||||||
|
var title = url.deletingPathExtension().lastPathComponent |
||||||
|
var artist = "Unknown" |
||||||
|
var albumArtist = "Unknown" |
||||||
|
var album = "Unknown" |
||||||
|
var genre = "" |
||||||
|
var year: Int? |
||||||
|
var trackNumber: Int? |
||||||
|
var discNumber: Int? |
||||||
|
var bpm: Int? |
||||||
|
var composer = "" |
||||||
|
var artworkData: Data? |
||||||
|
|
||||||
|
do { |
||||||
|
let metadata = try await asset.load(.metadata) |
||||||
|
|
||||||
|
for item in metadata { |
||||||
|
guard let key = item.commonKey else { continue } |
||||||
|
switch key { |
||||||
|
case .commonKeyTitle: |
||||||
|
title = (try? await item.load(.stringValue)) ?? title |
||||||
|
case .commonKeyArtist: |
||||||
|
let val = (try? await item.load(.stringValue)) ?? "Unknown" |
||||||
|
artist = val |
||||||
|
if albumArtist == "Unknown" { albumArtist = val } |
||||||
|
case .commonKeyAlbumName: |
||||||
|
album = (try? await item.load(.stringValue)) ?? "Unknown" |
||||||
|
case .commonKeyArtwork: |
||||||
|
artworkData = try? await item.load(.dataValue) |
||||||
|
case .commonKeyCreator: |
||||||
|
composer = (try? await item.load(.stringValue)) ?? "" |
||||||
|
default: |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Format-specific metadata (ID3 / iTunes) |
||||||
|
for item in metadata { |
||||||
|
if let identifier = item.identifier { |
||||||
|
switch identifier { |
||||||
|
case .id3MetadataContentType, .iTunesMetadataUserGenre: |
||||||
|
genre = (try? await item.load(.stringValue)) ?? genre |
||||||
|
case .id3MetadataTrackNumber: |
||||||
|
if let val = try? await item.load(.stringValue), |
||||||
|
let num = Int(val.split(separator: "/").first ?? "") { |
||||||
|
trackNumber = num |
||||||
|
} |
||||||
|
case .id3MetadataPartOfASet: |
||||||
|
if let val = try? await item.load(.stringValue), |
||||||
|
let num = Int(val.split(separator: "/").first ?? "") { |
||||||
|
discNumber = num |
||||||
|
} |
||||||
|
case .id3MetadataBeatsPerMinute: |
||||||
|
if let val = try? await item.load(.stringValue) { |
||||||
|
bpm = Int(val) |
||||||
|
} |
||||||
|
case .id3MetadataBand: |
||||||
|
albumArtist = (try? await item.load(.stringValue)) ?? albumArtist |
||||||
|
case .id3MetadataYear: |
||||||
|
if let val = try? await item.load(.stringValue) { |
||||||
|
year = Int(val) |
||||||
|
} |
||||||
|
default: |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let duration = try await asset.load(.duration) |
||||||
|
let durationSeconds = CMTimeGetSeconds(duration) |
||||||
|
|
||||||
|
var bitrate: Int? |
||||||
|
var sampleRate: Int? |
||||||
|
if let audioTrack = try await asset.loadTracks(withMediaType: .audio).first { |
||||||
|
let estimatedRate = try await audioTrack.load(.estimatedDataRate) |
||||||
|
bitrate = Int(estimatedRate / 1000) |
||||||
|
let descriptions = try await audioTrack.load(.formatDescriptions) |
||||||
|
if let desc = descriptions.first { |
||||||
|
if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) { |
||||||
|
sampleRate = Int(asbd.pointee.mSampleRate) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let attrs = try FileManager.default.attributesOfItem(atPath: url.path) |
||||||
|
let fileSize = attrs[.size] as? Int64 ?? 0 |
||||||
|
let modDate = attrs[.modificationDate] as? Date ?? Date() |
||||||
|
|
||||||
|
return Track( |
||||||
|
fileURL: url.absoluteString, |
||||||
|
title: title, |
||||||
|
artist: artist, |
||||||
|
albumArtist: albumArtist, |
||||||
|
album: album, |
||||||
|
genre: genre, |
||||||
|
year: year, |
||||||
|
trackNumber: trackNumber, |
||||||
|
discNumber: discNumber, |
||||||
|
duration: durationSeconds.isNaN ? 0 : durationSeconds, |
||||||
|
bpm: bpm, |
||||||
|
composer: composer, |
||||||
|
fileFormat: url.pathExtension.lowercased(), |
||||||
|
bitrate: bitrate, |
||||||
|
sampleRate: sampleRate, |
||||||
|
fileSize: fileSize, |
||||||
|
artworkData: artworkData, |
||||||
|
playCount: 0, |
||||||
|
lastPlayedAt: nil, |
||||||
|
rating: 0, |
||||||
|
dateAdded: Date(), |
||||||
|
dateModified: modDate, |
||||||
|
fileHash: Track.computeHash(fileSize: fileSize, modificationDate: modDate) |
||||||
|
) |
||||||
|
} catch { |
||||||
|
print("Failed to extract metadata from \(url.lastPathComponent): \(error)") |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
import Testing |
||||||
|
import Foundation |
||||||
|
@testable import Music |
||||||
|
|
||||||
|
struct ScannerServiceTests { |
||||||
|
// Creates a temp directory with audio and non-audio files, verifies only audio files are discovered. |
||||||
|
// 1. Creates a unique temporary directory. |
||||||
|
// 2. Creates files with audio extensions (mp3, m4a, flac, wav, aiff) and non-audio extensions (txt, png, json). |
||||||
|
// 3. Creates a subdirectory with a nested mp3 file to test recursive discovery. |
||||||
|
// 4. Calls ScannerService.discoverAudioFiles and checks that exactly 6 audio files are found. |
||||||
|
// 5. Verifies all discovered files have audio-only extensions. |
||||||
|
// 6. Cleans up the temp directory. |
||||||
|
@Test func discoverAudioFiles() throws { |
||||||
|
let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
||||||
|
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) |
||||||
|
defer { try? FileManager.default.removeItem(at: tmpDir) } |
||||||
|
|
||||||
|
let audioFiles = ["song.mp3", "track.m4a", "audio.flac", "sound.wav", "music.aiff"] |
||||||
|
let nonAudioFiles = ["readme.txt", "image.png", "data.json"] |
||||||
|
|
||||||
|
for name in audioFiles + nonAudioFiles { |
||||||
|
FileManager.default.createFile(atPath: tmpDir.appendingPathComponent(name).path, contents: Data()) |
||||||
|
} |
||||||
|
|
||||||
|
let subDir = tmpDir.appendingPathComponent("subfolder") |
||||||
|
try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) |
||||||
|
FileManager.default.createFile(atPath: subDir.appendingPathComponent("nested.mp3").path, contents: Data()) |
||||||
|
|
||||||
|
let discovered = ScannerService.discoverAudioFiles(in: tmpDir) |
||||||
|
#expect(discovered.count == 6) |
||||||
|
|
||||||
|
let extensions = Set(discovered.map { $0.pathExtension.lowercased() }) |
||||||
|
#expect(extensions.isSubset(of: ["mp3", "m4a", "flac", "wav", "aiff", "aac", "alac"])) |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue