Implement macOS local MP3 discovery foundation

This commit is contained in:
diyaa
2026-05-25 09:11:00 +02:00
parent 4b8701e55b
commit 6d426f2a54
6507 changed files with 325 additions and 12656 deletions
@@ -1,8 +1,9 @@
import AppKit
import Foundation
import VelodyPersistence
@MainActor
final class FolderAccessService {
final class FolderAccessService: VelodyPersistence.FolderAccessService {
private let bookmarkKey = "velody.selected-folder.bookmark"
func chooseFolder() -> URL? {
@@ -16,17 +17,8 @@ final class FolderAccessService {
return nil
}
do {
let bookmark = try url.bookmarkData(
options: [.withSecurityScope],
includingResourceValuesForKeys: nil,
relativeTo: nil
)
UserDefaults.standard.set(bookmark, forKey: bookmarkKey)
return url
} catch {
return url
}
saveBookmark(for: url)
return url
}
func storedFolderURL() -> URL? {
@@ -37,11 +29,31 @@ final class FolderAccessService {
}
var isStale = false
return try? URL(
guard let url = try? URL(
resolvingBookmarkData: bookmark,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
) else {
return nil
}
if isStale {
saveBookmark(for: url)
}
return url
}
private func saveBookmark(for url: URL) {
guard let bookmark = try? url.bookmarkData(
options: [.withSecurityScope],
includingResourceValuesForKeys: nil,
relativeTo: nil
) else {
return
}
UserDefaults.standard.set(bookmark, forKey: bookmarkKey)
}
}
@@ -0,0 +1,131 @@
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
}
}
@@ -1,3 +1,4 @@
import Foundation
import SwiftUI
import VelodyDomain
@@ -5,52 +6,91 @@ struct MacLibraryView: View {
@State private var viewModel = MacLibraryViewModel()
var body: some View {
NavigationSplitView {
VStack(alignment: .leading, spacing: 16) {
Text("Private Library Foundation")
.font(.largeTitle)
Text("Selected folder")
.font(.headline)
Text(viewModel.selectedFolderPath)
.textSelection(.enabled)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Button("Choose Music Folder") {
viewModel.chooseFolder()
}
Button("Scan MP3 Files") {
Task {
await viewModel.scanMP3Files()
}
}
.disabled(viewModel.selectedFolderPath == "No folder selected" || viewModel.isScanning)
}
HStack(spacing: 12) {
Text("Discovered tracks: \(viewModel.discoveredTrackCount)")
.font(.headline)
if viewModel.isScanning {
ProgressView()
.controlSize(.small)
}
}
Text(viewModel.scanStatus)
.foregroundStyle(.secondary)
List(viewModel.tracks) { track in
VStack(alignment: .leading, spacing: 4) {
Text(track.title)
.font(.headline)
Text(track.artist)
.foregroundStyle(.secondary)
if let album = track.album {
Text(album)
.font(.caption)
.foregroundStyle(.secondary)
}
if let durationSeconds = track.durationSeconds {
Text("Duration: \(format(durationSeconds: durationSeconds))")
.font(.caption2)
.foregroundStyle(.tertiary)
}
Text(track.localFilePath)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
.padding(.vertical, 4)
}
.navigationTitle("Velody")
} detail: {
VStack(alignment: .leading, spacing: 16) {
Text("Private Library Foundation")
.font(.largeTitle)
Text("Selected folder")
.font(.headline)
Text(viewModel.selectedFolderPath)
.textSelection(.enabled)
Text("Sync status")
.font(.headline)
Text(viewModel.syncStatus)
.foregroundStyle(.secondary)
HStack {
Button("Choose Watched Folder") {
viewModel.chooseFolder()
}
Button("Refresh Placeholder Sync") {
Task {
await viewModel.refreshSyncStatus()
}
}
.overlay {
if viewModel.tracks.isEmpty && !viewModel.isScanning {
ContentUnavailableView(
"No MP3 Files Discovered",
systemImage: "music.note.list",
description: Text("Choose a folder, then run a manual scan.")
)
}
Spacer()
}
.padding(24)
Spacer(minLength: 0)
}
.padding(24)
.task {
await viewModel.loadIfNeeded()
}
}
private func format(durationSeconds: Double) -> String {
let totalSeconds = Int(durationSeconds.rounded())
let minutes = totalSeconds / 60
let seconds = totalSeconds % 60
return String(format: "%d:%02d", minutes, seconds)
}
}
@@ -1,59 +1,76 @@
import Foundation
import Observation
import VelodyDomain
import VelodyNetworking
import VelodyPersistence
import VelodySync
import VelodyUtilities
@MainActor
@Observable
final class MacLibraryViewModel {
var tracks: [LibraryTrack] = []
var selectedFolderPath = "No folder selected"
var syncStatus = "Sync not started"
var scanStatus = "Choose a folder to begin local discovery."
var discoveredTrackCount = 0
var isScanning = false
private let folderAccessService = FolderAccessService()
private let keychainService = MemoryKeychainService()
private let folderAccessService: any VelodyPersistence.FolderAccessService
private let localMusicScanner: any LocalMusicScanner
private let store = InMemoryLocalLibraryStore()
private let syncCoordinator: PlaceholderSyncCoordinator
private var hasLoaded = false
init() {
let environment = ServerEnvironment(
baseURL: URL(string: "http://localhost:3000")!,
appVersion: "0.1.0"
)
let apiClient = StubVelodyAPIClient(environment: environment)
self.syncCoordinator = PlaceholderSyncCoordinator(
apiClient: apiClient,
store: store
let folderAccessService = FolderAccessService()
let localMusicScanner = FileSystemLocalMusicScanner(
metadataReader: AVFoundationMetadataReader()
)
self.folderAccessService = folderAccessService
self.localMusicScanner = localMusicScanner
if let url = folderAccessService.storedFolderURL() {
selectedFolderPath = url.path
scanStatus = "Folder restored. Run a manual scan to discover MP3 files."
}
}
func loadIfNeeded() async {
guard !hasLoaded else { return }
hasLoaded = true
await refreshSyncStatus()
await keychainService.save("placeholder-bootstrap-token", forKey: "bootstrap-token")
}
func refreshSyncStatus() async {
do {
let result = try await syncCoordinator.performInitialSync()
tracks = result.tracks
syncStatus = result.statusMessage
} catch {
syncStatus = "Sync placeholder failed: \(error.localizedDescription)"
}
tracks = await store.loadTracks()
discoveredTrackCount = tracks.count
}
func chooseFolder() {
if let url = folderAccessService.chooseFolder() {
selectedFolderPath = url.path
scanStatus = "Folder selected. Run a manual scan to discover MP3 files."
tracks = []
discoveredTrackCount = 0
Task {
await store.replaceTracks([])
}
}
}
func scanMP3Files() async {
guard let folderURL = folderAccessService.storedFolderURL() else {
scanStatus = "Choose a folder before scanning."
return
}
isScanning = true
scanStatus = "Scanning MP3 files..."
defer {
isScanning = false
}
do {
let discoveredTracks = try await localMusicScanner.scanFolder(at: folderURL)
await store.replaceTracks(discoveredTracks)
tracks = await store.loadTracks()
discoveredTrackCount = tracks.count
scanStatus = "Scan finished. Found \(discoveredTrackCount) MP3 file(s)."
} catch {
scanStatus = "Scan failed: \(error.localizedDescription)"
}
}
}