Implement macOS local MP3 discovery foundation
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user