Files

1155 lines
60 KiB
Swift

import AVFoundation
import AppKit
import Combine
import CryptoKit
import Foundation
enum TonuinoVolumeMatch: Int, Sendable {
case likely
case possible
case notRecognized
case unreadable
}
struct TonuinoMountedVolume: Identifiable, Sendable {
let url: URL
let name: String
let match: TonuinoVolumeMatch
let volumeUUID: String?
let isRemovable: Bool
var id: String { url.path }
}
struct LibraryProfile: Identifiable, Codable, Hashable, Sendable {
let id: UUID
var name: String
var boundVolumeUUID: String?
let createdAt: Date
var customStorageBasePath: String? = nil
var cardRootURL: URL {
guard let customStorageBasePath else { return LibraryProfileStore.contentsURL(for: id) }
return URL(fileURLWithPath: customStorageBasePath, isDirectory: true)
.appendingPathComponent("Tonuino Manager Profiles", isDirectory: true)
.appendingPathComponent(id.uuidString, isDirectory: true)
.appendingPathComponent("Card Contents", isDirectory: true)
}
var storageDirectoryURL: URL { cardRootURL.deletingLastPathComponent() }
}
enum LibrarySyncOperation: String, Sendable {
case added = "Neu"
case updated = "Geändert"
case removed = "Wird entfernt"
}
struct LibrarySyncChange: Identifiable, Sendable {
let path: String
let operation: LibrarySyncOperation
let isDirectory: Bool
let size: Int64
var id: String { "\(operation.rawValue):\(path)" }
}
struct LibrarySyncPreview: Sendable {
let profileID: UUID
let targetURL: URL
let targetName: String
let targetUUID: String?
let requiresRebinding: Bool
let requiresBinding: Bool
let changes: [LibrarySyncChange]
var addedCount: Int { changes.filter { $0.operation == .added }.count }
var updatedCount: Int { changes.filter { $0.operation == .updated }.count }
var removedCount: Int { changes.filter { $0.operation == .removed }.count }
var profileOnlyFileCount: Int { changes.filter { $0.operation == .added && !$0.isDirectory }.count }
var cardOnlyFileCount: Int { changes.filter { $0.operation == .removed && !$0.isDirectory }.count }
var conflictingFileCount: Int { changes.filter { $0.operation == .updated && !$0.isDirectory }.count }
var copyCount: Int { changes.filter { $0.operation != .removed && !$0.isDirectory }.count }
var copiedBytes: Int64 { changes.filter { $0.operation != .removed && !$0.isDirectory }.reduce(0) { $0 + $1.size } }
}
@MainActor
final class LibraryModel: ObservableObject {
@Published private(set) var rootURL: URL?
@Published private(set) var profiles: [LibraryProfile] = []
@Published private(set) var activeProfileID: UUID?
@Published private(set) var profileStorageError: String?
@Published private(set) var folders: [MusicFolder] = []
@Published var selectedFolder: Int?
@Published private(set) var systemFolders: [SystemMusicFolder] = []
@Published var selectedSystemFolderName: String?
@Published private(set) var isLoading = false
@Published private(set) var scanError: String?
@Published private(set) var scanProgress = ""
@Published private(set) var mountedVolumes: [TonuinoMountedVolume] = []
@Published private(set) var isDiscoveringVolumes = false
@Published var selectedTrackIDs = Set<String>()
@Published var cleanPreview: CleanPreview?
@Published private(set) var syncProgress = ""
@Published private(set) var isSyncing = false
private var scanTask: Task<Void, Never>?
private var volumeScanTask: Task<Void, Never>?
private var workspaceObservers: [NSObjectProtocol] = []
init() {
do { profiles = try LibraryProfileStore.load() }
catch { profileStorageError = error.localizedDescription }
refreshMountedVolumes()
let center = NSWorkspace.shared.notificationCenter
let refresh: @Sendable (Notification) -> Void = { [weak self] _ in
Task { @MainActor [weak self] in
self?.refreshMountedVolumes()
if let self, self.profileStorageError != nil {
self.refreshProfileStorage()
} else if let self,
self.scanError != nil,
let profile = self.activeProfile,
FileManager.default.fileExists(atPath: profile.cardRootURL.path) {
self.reload()
}
}
}
workspaceObservers = [
center.addObserver(forName: NSWorkspace.didMountNotification, object: nil, queue: .main, using: refresh),
center.addObserver(forName: NSWorkspace.didUnmountNotification, object: nil, queue: .main, using: refresh)
]
if profileStorageError == nil,
let storedID = UserDefaults.standard.string(forKey: LibraryProfileStore.activeProfileKey),
let id = UUID(uuidString: storedID), profiles.contains(where: { $0.id == id }) {
openProfile(id)
}
}
var profileStorageURL: URL { LibraryProfileStore.profilesDirectoryURL }
var isProfileStorageCustom: Bool { LibraryProfileStore.customStorageBaseURL != nil }
func refreshProfileStorage() {
do {
profiles = try LibraryProfileStore.load()
profileStorageError = nil
if let storedID = UserDefaults.standard.string(forKey: LibraryProfileStore.activeProfileKey),
let id = UUID(uuidString: storedID), profiles.contains(where: { $0.id == id }) {
openProfile(id)
}
} catch {
profiles = []
profileStorageError = error.localizedDescription
scanTask?.cancel()
rootURL = nil
folders = []
systemFolders = []
}
}
func changeProfileStorageBase(to baseURL: URL?) async throws {
guard !isSyncing else { throw LibraryError.profileStorageBusy }
let oldDirectory = LibraryProfileStore.profilesDirectoryURL
let destination = LibraryProfileStore.profilesDirectory(forBase: baseURL)
guard oldDirectory.standardizedFileURL != destination.standardizedFileURL else { return }
guard FileManager.default.fileExists(atPath: oldDirectory.path) else {
if profileStorageError != nil { throw LibraryError.profileStorageUnavailable }
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
LibraryProfileStore.setCustomStorageBase(baseURL)
refreshProfileStorage()
return
}
guard !destination.path.hasPrefix(oldDirectory.path + "/"), !oldDirectory.path.hasPrefix(destination.path + "/") else {
throw LibraryError.invalidProfileStorageDestination
}
if FileManager.default.fileExists(atPath: destination.path) {
let existing = try FileManager.default.contentsOfDirectory(at: destination, includingPropertiesForKeys: nil, options: [])
guard existing.isEmpty else { throw LibraryError.profileStorageDestinationNotEmpty }
try FileManager.default.removeItem(at: destination)
}
isSyncing = true
syncProgress = "Kopiere lokale Profile an den neuen Speicherort…"
defer { isSyncing = false; syncProgress = "" }
let staging = destination.deletingLastPathComponent()
.appendingPathComponent(".tonuino-profiles-migration-\(UUID().uuidString)", isDirectory: true)
let reporter = LibrarySyncProgressReporter(self)
let worker = Task.detached(priority: .utility) {
try LibraryProfileStore.copyContents(from: oldDirectory, to: staging) { reporter.report($0) }
try LibraryProfileStore.validateCopiedProfiles(at: staging)
}
do {
try await withTaskCancellationHandler { try await worker.value } onCancel: { worker.cancel() }
try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager.default.moveItem(at: staging, to: destination)
LibraryProfileStore.setCustomStorageBase(baseURL)
refreshProfileStorage()
if profileStorageError != nil { throw LibraryError.profileStorageMigrationFailed }
if FileManager.default.fileExists(atPath: oldDirectory.path) {
try? FileManager.default.trashItem(at: oldDirectory, resultingItemURL: nil)
}
if let activeProfileID, profiles.contains(where: { $0.id == activeProfileID }) { openProfile(activeProfileID) }
} catch {
try? FileManager.default.removeItem(at: staging)
throw error
}
}
func refreshMountedVolumes() {
volumeScanTask?.cancel()
isDiscoveringVolumes = true
volumeScanTask = Task { [weak self] in
let volumes = await Task.detached(priority: .utility) {
Self.scanMountedVolumes()
}.value
guard !Task.isCancelled, let self else { return }
self.mountedVolumes = volumes
self.isDiscoveringVolumes = false
}
}
func open(_ url: URL) {
activeProfileID = nil
UserDefaults.standard.removeObject(forKey: LibraryProfileStore.activeProfileKey)
rootURL = url
reload()
}
var activeProfile: LibraryProfile? { profiles.first { $0.id == activeProfileID } }
fileprivate func updateSyncProgress(_ message: String) { syncProgress = message }
func openProfile(_ id: UUID) {
guard let profile = profiles.first(where: { $0.id == id }) else { return }
activeProfileID = id
UserDefaults.standard.set(id.uuidString, forKey: LibraryProfileStore.activeProfileKey)
rootURL = profile.cardRootURL
reload()
}
func createProfile(name: String, copyingFrom sourceURL: URL?, storageBaseURL: URL? = nil) async throws {
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedName.isEmpty else { throw LibraryError.invalidProfileName }
let id = UUID()
let sourceUUID = sourceURL.flatMap { (try? $0.resourceValues(forKeys: [.volumeUUIDStringKey]))?.volumeUUIDString }
let profile = LibraryProfile(
id: id,
name: normalizedName,
boundVolumeUUID: sourceUUID,
createdAt: Date(),
customStorageBasePath: storageBaseURL?.standardizedFileURL.path
)
let destination = profile.cardRootURL
isSyncing = sourceURL != nil
syncProgress = sourceURL == nil ? "Erstelle leere Bibliothek…" : "Kopiere SD-Karteninhalt…"
defer { isSyncing = false; syncProgress = "" }
do {
if let sourceURL {
let progressReporter = LibrarySyncProgressReporter(self)
let worker = Task.detached(priority: .userInitiated) {
try LibraryProfileStore.copyContents(from: sourceURL, to: destination) { message in
progressReporter.report(message)
}
}
try await withTaskCancellationHandler { try await worker.value } onCancel: { worker.cancel() }
} else {
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: destination.appendingPathComponent("mp3", isDirectory: true), withIntermediateDirectories: false)
try FileManager.default.createDirectory(at: destination.appendingPathComponent("advert", isDirectory: true), withIntermediateDirectories: false)
}
profiles.append(profile)
profiles.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
try LibraryProfileStore.save(profiles)
openProfile(id)
} catch {
try? FileManager.default.removeItem(at: profile.storageDirectoryURL)
throw error
}
}
func renameProfile(_ id: UUID, to name: String) throws {
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedName.isEmpty else { throw LibraryError.invalidProfileName }
guard let index = profiles.firstIndex(where: { $0.id == id }) else { throw LibraryError.profileNotFound }
profiles[index].name = normalizedName
profiles.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
try LibraryProfileStore.save(profiles)
}
func deleteProfile(_ id: UUID) throws {
guard profiles.contains(where: { $0.id == id }) else { throw LibraryError.profileNotFound }
guard let profile = profiles.first(where: { $0.id == id }) else { throw LibraryError.profileNotFound }
let directory = profile.storageDirectoryURL
if FileManager.default.fileExists(atPath: directory.path) {
try FileManager.default.trashItem(at: directory, resultingItemURL: nil)
}
profiles.removeAll { $0.id == id }
try LibraryProfileStore.save(profiles)
if activeProfileID == id {
scanTask?.cancel()
activeProfileID = nil
UserDefaults.standard.removeObject(forKey: LibraryProfileStore.activeProfileKey)
rootURL = nil
folders = []
systemFolders = []
selectedFolder = nil
selectedSystemFolderName = nil
}
}
func previewSync(profileID: UUID, to volume: TonuinoMountedVolume) async throws -> LibrarySyncPreview {
guard let profile = profiles.first(where: { $0.id == profileID }) else { throw LibraryError.profileNotFound }
guard !LibraryProfileStore.pathsOverlap(profile.cardRootURL, volume.url) else { throw LibraryError.invalidSyncTarget }
isSyncing = true
syncProgress = "Vergleiche lokale Bibliothek und SD-Karte…"
defer { isSyncing = false; syncProgress = "" }
let progressReporter = LibrarySyncProgressReporter(self)
let worker = Task.detached(priority: .utility) {
try LibraryProfileStore.makeSyncPreview(profile: profile, volume: volume) { message in
progressReporter.report(message)
}
}
return try await withTaskCancellationHandler { try await worker.value } onCancel: { worker.cancel() }
}
func synchronize(_ preview: LibrarySyncPreview) async throws {
guard let index = profiles.firstIndex(where: { $0.id == preview.profileID }) else { throw LibraryError.profileNotFound }
let profile = profiles[index]
guard FileManager.default.fileExists(atPath: preview.targetURL.path) else { throw LibraryError.invalidSyncTarget }
guard !LibraryProfileStore.pathsOverlap(profile.cardRootURL, preview.targetURL) else { throw LibraryError.invalidSyncTarget }
let currentUUID = (try? preview.targetURL.resourceValues(forKeys: [.volumeUUIDStringKey]).volumeUUIDString) ?? nil
if preview.targetUUID != currentUUID { throw LibraryError.syncTargetChanged }
if let bound = profile.boundVolumeUUID, let currentUUID, bound != currentUUID, !preview.requiresRebinding {
throw LibraryError.profileBoundToDifferentVolume
}
if profile.boundVolumeUUID != currentUUID {
if currentUUID != nil || preview.requiresRebinding || profile.boundVolumeUUID == nil {
profiles[index].boundVolumeUUID = currentUUID
try LibraryProfileStore.save(profiles)
}
}
isSyncing = true
syncProgress = "Synchronisiere…"
defer { isSyncing = false; syncProgress = "" }
let progressReporter = LibrarySyncProgressReporter(self)
let worker = Task.detached(priority: .userInitiated) {
try LibraryProfileStore.apply(preview, from: profile.cardRootURL) { message in
progressReporter.report(message)
}
}
try await withTaskCancellationHandler { try await worker.value } onCancel: { worker.cancel() }
if activeProfileID == preview.profileID { reload() }
}
func restoreMissingFiles(from preview: LibrarySyncPreview) async throws -> Int {
guard !isSyncing else { throw LibraryError.profileStorageBusy }
guard let profile = profiles.first(where: { $0.id == preview.profileID }) else { throw LibraryError.profileNotFound }
guard FileManager.default.fileExists(atPath: preview.targetURL.path) else { throw LibraryError.invalidSyncTarget }
let currentUUID = (try? preview.targetURL.resourceValues(forKeys: [.volumeUUIDStringKey]).volumeUUIDString) ?? nil
guard preview.targetUUID == currentUUID else { throw LibraryError.syncTargetChanged }
isSyncing = true
syncProgress = "Ergänze fehlende lokale Dateien…"
defer { isSyncing = false; syncProgress = "" }
let progressReporter = LibrarySyncProgressReporter(self)
let worker = Task.detached(priority: .utility) {
try LibraryProfileStore.restoreMissingFiles(from: preview, into: profile.cardRootURL) { message in
progressReporter.report(message)
}
}
let copiedCount = try await withTaskCancellationHandler { try await worker.value } onCancel: { worker.cancel() }
if activeProfileID == preview.profileID { reload() }
return copiedCount
}
func reload() {
guard let rootURL else { return }
scanTask?.cancel()
isLoading = true
scanError = nil
scanProgress = "Lese SD-Kartenstruktur…"
scanTask = Task { [weak self] in
do {
let reportProgress: @Sendable (String) -> Void = { [weak self] message in
Task { @MainActor [weak self] in self?.scanProgress = message }
}
let snapshot = try await Task.detached(priority: .userInitiated) {
try Self.scan(rootURL, reportProgress: reportProgress)
}.value
guard !Task.isCancelled, let self else { return }
self.folders = snapshot.folders
self.systemFolders = snapshot.systemFolders
if self.selectedFolder == nil || !self.folders.contains(where: { $0.number == self.selectedFolder }) {
self.selectedFolder = self.folders.first?.number
}
if self.selectedSystemFolderName != nil && !self.systemFolders.contains(where: { $0.name == self.selectedSystemFolderName }) {
self.selectedSystemFolderName = nil
}
self.isLoading = false
self.scanProgress = "Einlesen abgeschlossen"
} catch {
guard !Task.isCancelled, let self else { return }
self.scanError = error.localizedDescription
self.isLoading = false
}
}
}
var currentFolder: MusicFolder? { folders.first { $0.number == selectedFolder } }
var currentSystemFolder: SystemMusicFolder? { systemFolders.first { $0.name == selectedSystemFolderName } }
var currentTracks: [MusicTrack] { currentFolder?.tracks ?? currentSystemFolder?.tracks ?? [] }
func createFolder() throws {
guard let rootURL else { throw LibraryError.noCard }
if let emptyFolder = folders.first(where: { $0.tracks.isEmpty }) {
selectedFolder = emptyFolder.number
selectedSystemFolderName = nil
return
}
let used = Set(folders.map(\.number))
guard let free = (1...99).first(where: { !used.contains($0) }) else { throw LibraryError.noFolderSlots }
let url = rootURL.appendingPathComponent(String(format: "%02d", free), isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false)
selectedFolder = free
selectedSystemFolderName = nil
reload()
}
func renameFolder(_ number: Int, to name: String) throws {
guard let rootURL, let index = folders.firstIndex(where: { $0.number == number }) else { throw LibraryError.noFolder }
let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines)
try CardFolderNames.set(normalized.isEmpty ? nil : normalized, for: number, on: rootURL)
var updated = folders
updated[index].customName = normalized.isEmpty ? nil : normalized
folders = updated
}
func deleteFolder(_ number: Int) throws {
guard let folder = folders.first(where: { $0.number == number }) else { return }
try FileManager.default.trashItem(at: folder.url, resultingItemURL: nil)
if let rootURL { try CardFolderNames.set(nil, for: number, on: rootURL) }
reload()
}
func importFiles(_ urls: [URL], to folderNumber: Int) async throws {
guard let folder = folders.first(where: { $0.number == folderNumber }) else { throw LibraryError.noFolder }
let folderURL = folder.url
try await Task.detached(priority: .userInitiated) {
try Self.importFilesSync(urls, folderURL: folderURL)
}.value
reload()
}
func importYouTube(_ video: YouTubeVideoInfo, splitByChapters: Bool, to folderNumber: Int) async throws {
guard let folder = folders.first(where: { $0.number == folderNumber }) else { throw LibraryError.noFolder }
let incomingCount = splitByChapters ? video.usableChapters.count : 1
guard folder.tracks.count + incomingCount <= 255 else { throw LibraryError.trackLimit }
let folderURL = folder.url
try await Task.detached(priority: .userInitiated) {
let prepared = try YouTubeImportService.prepare(video, splitByChapters: splitByChapters)
defer { try? FileManager.default.removeItem(at: prepared.directory) }
try Self.importFilesSync(prepared.files, folderURL: folderURL)
}.value
reload()
}
nonisolated private static func importFilesSync(_ urls: [URL], folderURL: URL) throws {
let existing = try renumberFiles(in: folderURL)
var mp3s: [URL] = []
do {
for url in urls { mp3s.append(try AudioImportService.convertIfNeeded(url)) }
} catch {
for (source, converted) in zip(urls, mp3s) where source.pathExtension.lowercased() != "mp3" { try? FileManager.default.removeItem(at: converted) }
throw error
}
defer { for (source, converted) in zip(urls, mp3s) where source.pathExtension.lowercased() != "mp3" { try? FileManager.default.removeItem(at: converted) } }
guard existing + mp3s.count <= 255 else { throw LibraryError.trackLimit }
let staging = folderURL.appendingPathComponent(".tonuino-import-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: false)
do {
for (index, url) in mp3s.enumerated() {
let target = staging.appendingPathComponent(String(format: "%03d.mp3", index + 1))
try FileManager.default.copyItem(at: url, to: target)
}
for (index, file) in try FileManager.default.contentsOfDirectory(at: staging, includingPropertiesForKeys: nil).sorted(by: { $0.lastPathComponent < $1.lastPathComponent }).enumerated() {
try FileManager.default.moveItem(at: file, to: folderURL.appendingPathComponent(String(format: "%03d.mp3", existing + index + 1)))
}
try FileManager.default.removeItem(at: staging)
} catch {
try? FileManager.default.removeItem(at: staging)
throw error
}
}
nonisolated private static func renumberFiles(in folder: URL) throws -> Int {
let tracks = try FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])
.filter { $0.pathExtension.lowercased() == "mp3" && $0.lastPathComponent.range(of: #"^\d{3}\.mp3$"#, options: [.regularExpression, .caseInsensitive]) != nil }
.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }
let staging = folder.appendingPathComponent(".tonuino-renumber-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: false)
do {
for (index, url) in tracks.enumerated() { try FileManager.default.moveItem(at: url, to: staging.appendingPathComponent(String(format: "%03d.mp3", index + 1))) }
for file in try FileManager.default.contentsOfDirectory(at: staging, includingPropertiesForKeys: nil) { try FileManager.default.moveItem(at: file, to: folder.appendingPathComponent(file.lastPathComponent)) }
try FileManager.default.removeItem(at: staging)
} catch { try? FileManager.default.removeItem(at: staging); throw error }
return tracks.count
}
func deleteTracks(_ ids: Set<String>) throws {
guard let folder = currentFolder else { throw LibraryError.noFolder }
for track in folder.tracks where ids.contains(track.id) { try FileManager.default.removeItem(at: track.url) }
try renumber(folder.url)
selectedTrackIDs.removeAll()
reload()
}
func moveTrack(from source: Int, to destination: Int) throws {
guard let folder = currentFolder, folder.tracks.indices.contains(source), folder.tracks.indices.contains(destination) else { return }
var ordered = folder.tracks.map(\.url)
let item = ordered.remove(at: source); ordered.insert(item, at: destination)
try writeOrder(ordered, in: folder.url)
reload()
}
func moveSelectedTracks(by offset: Int) throws {
guard let folder = currentFolder, !selectedTrackIDs.isEmpty else { return }
var values = folder.tracks.map(\.url)
let indices = values.indices.filter { selectedTrackIDs.contains(values[$0].path) }
guard let first = indices.first, let last = indices.last else { return }
if offset < 0, first > 0 {
let moved = indices.map { values[$0] }
for index in indices.reversed() { values.remove(at: index) }
values.insert(contentsOf: moved, at: first - 1)
} else if offset > 0, last + 1 < values.count {
let moved = indices.map { values[$0] }
for index in indices.reversed() { values.remove(at: index) }
values.insert(contentsOf: moved, at: min(values.count, first + 1))
}
try writeOrder(values, in: folder.url)
selectedTrackIDs.removeAll()
reload()
}
func cleanPreviewForCard() throws -> CleanPreview {
guard let rootURL else { throw LibraryError.noCard }
let allowed = Set((1...99).map { String(format: "%02d", $0) } + ["mp3", "advert", CardFolderNames.fileName])
let items = try FileManager.default.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: [.isDirectoryKey], options: [])
let unknown = items.filter { !allowed.contains($0.lastPathComponent) }
let nested = try folders.flatMap { folder in
try FileManager.default.contentsOfDirectory(at: folder.url, includingPropertiesForKeys: nil, options: [])
.filter { $0.lastPathComponent.hasPrefix(".") || $0.pathExtension.lowercased() != "mp3" || $0.lastPathComponent.range(of: #"^\d{3}\.mp3$"#, options: [.regularExpression, .caseInsensitive]) == nil }
}
let preview = CleanPreview(rootItems: unknown.map(\.lastPathComponent), malformedFiles: nested.map(\.lastPathComponent), removeURLs: unknown + nested)
cleanPreview = preview
return preview
}
func cleanCard() throws {
guard rootURL != nil else { throw LibraryError.noCard }
let preview = try cleanPreviewForCard()
for url in preview.removeURLs { try? FileManager.default.removeItem(at: url) }
for folder in folders { try renumber(folder.url) }
reload()
cleanPreview = nil
}
nonisolated private static func scan(_ rootURL: URL, reportProgress: @Sendable (String) -> Void) throws -> LibrarySnapshot {
let customNames = CardFolderNames.read(from: rootURL)
let children = try FileManager.default.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
let folders = try children.compactMap { url -> MusicFolder? in
guard (try url.resourceValues(forKeys: [.isDirectoryKey])).isDirectory == true,
let number = Int(url.lastPathComponent), (1...99).contains(number), url.lastPathComponent == String(format: "%02d", number) else { return nil }
return MusicFolder(number: number, url: url, tracks: try scanTracks(in: url, reportProgress: reportProgress), customName: customNames[number])
}.sorted { $0.number < $1.number }
let systemFolders = try ["mp3", "advert"].compactMap { name -> SystemMusicFolder? in
let url = rootURL.appendingPathComponent(name, isDirectory: true)
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
return SystemMusicFolder(name: name, url: url, tracks: try scanSystemTracks(in: url, reportProgress: reportProgress))
}
return LibrarySnapshot(folders: folders, systemFolders: systemFolders)
}
nonisolated private static func scanMountedVolumes() -> [TonuinoMountedVolume] {
let keys: Set<URLResourceKey> = [.volumeNameKey, .volumeIsLocalKey, .volumeUUIDStringKey, .volumeIsRemovableKey, .volumeIsEjectableKey]
let urls = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: Array(keys), options: [.skipHiddenVolumes]) ?? []
return urls.compactMap { url -> TonuinoMountedVolume? in
guard !url.path.hasPrefix("/System/Volumes/") else { return nil }
let values = try? url.resourceValues(forKeys: keys)
guard values?.volumeIsLocal != false else { return nil }
let volumeName = values?.volumeName?.trimmingCharacters(in: .whitespacesAndNewlines)
return TonuinoMountedVolume(
url: url,
name: volumeName.map { $0.isEmpty ? url.lastPathComponent : $0 } ?? url.lastPathComponent,
match: recognizeTonuinoVolume(at: url),
volumeUUID: values?.volumeUUIDString,
isRemovable: (values?.volumeIsRemovable == true) || (values?.volumeIsEjectable == true)
)
}.sorted { lhs, rhs in
if lhs.match.rawValue != rhs.match.rawValue { return lhs.match.rawValue < rhs.match.rawValue }
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
}
}
nonisolated private static func recognizeTonuinoVolume(at rootURL: URL) -> TonuinoVolumeMatch {
do {
let rootItems = try FileManager.default.contentsOfDirectory(
at: rootURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
)
let directories = rootItems.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true }
let directoriesByName = Dictionary(directories.map { ($0.lastPathComponent.lowercased(), $0) }, uniquingKeysWith: { first, _ in first })
let hasMP3Directory = directoriesByName["mp3"] != nil
let hasAdvertDirectory = directoriesByName["advert"] != nil
let hasAudioInMP3 = try directoriesByName["mp3"].map(hasMP3Files(in:)) ?? false
let hasAudioInAdvert = try directoriesByName["advert"].map(hasMP3Files(in:)) ?? false
if hasMP3Directory, hasAdvertDirectory, hasAudioInMP3, hasAudioInAdvert { return .likely }
if hasAudioInMP3 || hasAudioInAdvert { return .possible }
let numericFolderPattern = #"^(0[1-9]|[1-9][0-9])$"#
for folder in directories where folder.lastPathComponent.range(of: numericFolderPattern, options: .regularExpression) != nil {
if try hasNumberedTracks(in: folder) { return .possible }
}
return .notRecognized
} catch {
return .unreadable
}
}
nonisolated private static func hasMP3Files(in directory: URL) throws -> Bool {
try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])
.contains { $0.pathExtension.lowercased() == "mp3" }
}
nonisolated private static func hasNumberedTracks(in directory: URL) throws -> Bool {
try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])
.contains { $0.lastPathComponent.range(of: #"^\d{3}\.mp3$"#, options: [.regularExpression, .caseInsensitive]) != nil }
}
nonisolated private static func scanTracks(in folder: URL, reportProgress: @Sendable (String) -> Void) throws -> [MusicTrack] {
let files = try FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles])
let tracks = files.filter { $0.pathExtension.lowercased() == "mp3" && $0.lastPathComponent.range(of: #"^\d{3}\.mp3$"#, options: [.regularExpression, .caseInsensitive]) != nil }
.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }
return tracks.enumerated().map { index, url in
if index == 0 || (index + 1) % 25 == 0 || index + 1 == tracks.count {
reportProgress("Ordner \(folder.lastPathComponent): Metadaten \(index + 1) von \(tracks.count) Tracks")
}
let tags = ID3Metadata.read(url)
return MusicTrack(url: url, title: tags.title, artist: tags.artist, album: tags.album, albumArtist: tags.albumArtist, trackNumber: tags.trackNumber, discNumber: tags.discNumber, year: tags.year, genre: tags.genre, acoustID: tags.acoustID, musicBrainzRecordingID: tags.musicBrainzRecordingID, musicBrainzReleaseID: tags.musicBrainzReleaseID)
}
}
nonisolated private static func scanSystemTracks(in folder: URL, reportProgress: @Sendable (String) -> Void) throws -> [MusicTrack] {
let files = try FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles])
.filter { $0.pathExtension.lowercased() == "mp3" && !$0.lastPathComponent.hasPrefix("._") }
.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }
return files.enumerated().map { index, url in
if index == 0 || (index + 1) % 25 == 0 || index + 1 == files.count {
reportProgress("Systemordner \(folder.lastPathComponent): \(index + 1) von \(files.count) Tracks")
}
let tags = ID3Metadata.read(url)
return MusicTrack(url: url, title: tags.title, artist: tags.artist, album: tags.album, albumArtist: tags.albumArtist, trackNumber: tags.trackNumber, discNumber: tags.discNumber, year: tags.year, genre: tags.genre, acoustID: tags.acoustID, musicBrainzRecordingID: tags.musicBrainzRecordingID, musicBrainzReleaseID: tags.musicBrainzReleaseID)
}
}
private func renumber(_ folder: URL) throws {
let files = try FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])
.filter { $0.pathExtension.lowercased() == "mp3" && $0.lastPathComponent.range(of: #"^\d{3}\.mp3$"#, options: [.regularExpression, .caseInsensitive]) != nil }
.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }
try writeOrder(files, in: folder)
}
private func writeOrder(_ urls: [URL], in folder: URL) throws {
let staging = folder.appendingPathComponent(".tonuino-renumber-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: false)
do {
for (index, url) in urls.enumerated() { try FileManager.default.moveItem(at: url, to: staging.appendingPathComponent(String(format: "%03d.mp3", index + 1))) }
for file in try FileManager.default.contentsOfDirectory(at: staging, includingPropertiesForKeys: nil) { try FileManager.default.moveItem(at: file, to: folder.appendingPathComponent(file.lastPathComponent)) }
try FileManager.default.removeItem(at: staging)
} catch { try? FileManager.default.removeItem(at: staging); throw error }
}
}
private final class LibrarySyncProgressReporter: @unchecked Sendable {
weak var model: LibraryModel?
private let lock = NSLock()
private var lastReport = Date(timeIntervalSince1970: 0)
init(_ model: LibraryModel) { self.model = model }
func report(_ message: String) {
lock.lock()
let now = Date()
guard now.timeIntervalSince(lastReport) >= 0.5 else {
lock.unlock()
return
}
lastReport = now
lock.unlock()
Task { @MainActor [weak self] in self?.model?.updateSyncProgress(message) }
}
}
enum LibraryProfileStore {
static let activeProfileKey = "library.activeProfileID"
private static let storageBaseKey = "library.profileStorageBasePath"
static var customStorageBaseURL: URL? {
guard let path = UserDefaults.standard.string(forKey: storageBaseKey), !path.isEmpty else { return nil }
return URL(fileURLWithPath: path, isDirectory: true)
}
static var profilesDirectoryURL: URL { profilesDirectory(forBase: customStorageBaseURL) }
static func profilesDirectory(forBase baseURL: URL?) -> URL {
if let baseURL {
return baseURL.appendingPathComponent("Tonuino Manager Profiles", isDirectory: true)
}
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support", isDirectory: true)
return support.appendingPathComponent("Tonuino Manager/Profiles", isDirectory: true)
}
static func setCustomStorageBase(_ url: URL?) {
if let url {
UserDefaults.standard.set(url.standardizedFileURL.path, forKey: storageBaseKey)
} else {
UserDefaults.standard.removeObject(forKey: storageBaseKey)
}
}
static func profileDirectory(for id: UUID) -> URL {
profilesDirectoryURL.appendingPathComponent(id.uuidString, isDirectory: true)
}
static func contentsURL(for id: UUID) -> URL {
profileDirectory(for: id).appendingPathComponent("Card Contents", isDirectory: true)
}
static func load() throws -> [LibraryProfile] {
let directory = profilesDirectoryURL
guard FileManager.default.fileExists(atPath: directory.path) else {
if customStorageBaseURL != nil { throw LibraryError.profileStorageUnavailable }
return []
}
let indexURL = directory.appendingPathComponent("profiles.json")
guard FileManager.default.fileExists(atPath: indexURL.path) else { return [] }
let data = try Data(contentsOf: indexURL)
let profiles = try JSONDecoder().decode([LibraryProfile].self, from: data)
return profiles.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
static func save(_ profiles: [LibraryProfile]) throws {
let directory = profilesDirectoryURL
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(profiles).write(to: directory.appendingPathComponent("profiles.json"), options: .atomic)
}
static func validateCopiedProfiles(at directory: URL) throws {
let indexURL = directory.appendingPathComponent("profiles.json")
let data = try Data(contentsOf: indexURL)
let profiles = try JSONDecoder().decode([LibraryProfile].self, from: data)
for profile in profiles where profile.customStorageBasePath == nil {
let cardRoot = directory.appendingPathComponent(profile.id.uuidString, isDirectory: true)
.appendingPathComponent("Card Contents", isDirectory: true)
guard FileManager.default.fileExists(atPath: cardRoot.path) else { throw LibraryError.profileStorageMigrationFailed }
}
}
static func copyContents(from sourceURL: URL, to destinationURL: URL, progress: @escaping @Sendable (String) -> Void) throws {
guard FileManager.default.fileExists(atPath: sourceURL.path) else { throw LibraryError.invalidSyncTarget }
try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true)
var copiedItems = 0
func copyDirectory(_ source: URL, to destination: URL) throws {
let children = try FileManager.default.contentsOfDirectory(
at: source,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: []
)
for child in children {
try Task.checkCancellation()
let name = child.lastPathComponent
guard !isMacOSMetadata(name) else {
progress("Überspringe macOS-Metadaten \(name)…")
continue
}
let values = try child.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
guard values.isSymbolicLink != true else { throw LibraryError.symbolicLink(name) }
let target = destination.appendingPathComponent(name, isDirectory: values.isDirectory == true)
if values.isDirectory == true {
try FileManager.default.createDirectory(at: target, withIntermediateDirectories: false)
progress("Kopiere Ordner \(name)…")
try copyDirectory(child, to: target)
} else {
try FileManager.default.copyItem(at: child, to: target)
copiedItems += 1
progress("Kopiere \(name) (\(copiedItems) Dateien)…")
}
}
}
try copyDirectory(sourceURL, to: destinationURL)
}
private static func isMacOSMetadata(_ name: String) -> Bool {
if name.hasPrefix("._") { return true }
return [
".Spotlight-V100", ".Trashes", ".fseventsd", ".TemporaryItems",
".DocumentRevisions-V100", ".DS_Store", ".VolumeIcon.icns",
"System Volume Information", "Thumbs.db", "desktop.ini"
].contains(name)
}
static func makeSyncPreview(
profile: LibraryProfile,
volume: TonuinoMountedVolume,
progress: @escaping @Sendable (String) -> Void
) throws -> LibrarySyncPreview {
guard FileManager.default.fileExists(atPath: volume.url.path) else { throw LibraryError.invalidSyncTarget }
let source = FileManager.default.fileExists(atPath: profile.cardRootURL.path)
? try fileTree(at: profile.cardRootURL, label: "Lokales Profil", progress: progress)
: [:]
let target = try fileTree(at: volume.url, label: "SD-Karte", progress: progress)
var changes: [LibrarySyncChange] = []
let contentCheckCount = source.reduce(into: 0) { count, item in
guard let targetEntry = target[item.key],
!item.value.isDirectory,
!targetEntry.isDirectory,
item.value.size == targetEntry.size,
item.value.modifiedTimeBucket != targetEntry.modifiedTimeBucket else { return }
count += 1
}
var contentChecksCompleted = 0
for path in source.keys.sorted() {
guard let sourceEntry = source[path] else { continue }
guard let targetEntry = target[path] else {
changes.append(LibrarySyncChange(path: path, operation: .added, isDirectory: sourceEntry.isDirectory, size: sourceEntry.size))
continue
}
var hasChanged = sourceEntry.isDirectory != targetEntry.isDirectory
if !hasChanged && !sourceEntry.isDirectory {
if sourceEntry.size != targetEntry.size {
hasChanged = true
} else if sourceEntry.modifiedTimeBucket != targetEntry.modifiedTimeBucket {
contentChecksCompleted += 1
progress("Prüfe Dateiinhalte (\(contentChecksCompleted) von \(contentCheckCount)): \(path)")
let sourceFile = try safeURL(path, under: profile.cardRootURL)
let targetFile = try safeURL(path, under: volume.url)
hasChanged = try !filesHaveSameContents(sourceFile, targetFile)
}
}
if hasChanged {
changes.append(LibrarySyncChange(path: path, operation: .updated, isDirectory: sourceEntry.isDirectory, size: sourceEntry.size))
}
}
for path in target.keys.sorted() where source[path] == nil {
guard let entry = target[path] else { continue }
changes.append(LibrarySyncChange(path: path, operation: .removed, isDirectory: entry.isDirectory, size: entry.size))
}
let requiresRebinding = profile.boundVolumeUUID != nil && volume.volumeUUID != nil && profile.boundVolumeUUID != volume.volumeUUID
let requiresBinding = profile.boundVolumeUUID == nil || volume.volumeUUID == nil
return LibrarySyncPreview(
profileID: profile.id,
targetURL: volume.url,
targetName: volume.name,
targetUUID: volume.volumeUUID,
requiresRebinding: requiresRebinding,
requiresBinding: requiresBinding,
changes: changes.sorted {
if $0.operation != $1.operation { return $0.operation.rawValue < $1.operation.rawValue }
return $0.path.localizedStandardCompare($1.path) == .orderedAscending
}
)
}
static func apply(_ preview: LibrarySyncPreview, from sourceURL: URL, progress: @escaping @Sendable (String) -> Void) throws {
guard FileManager.default.fileExists(atPath: sourceURL.path),
FileManager.default.fileExists(atPath: preview.targetURL.path),
!pathsOverlap(sourceURL, preview.targetURL) else { throw LibraryError.invalidSyncTarget }
let targetRoot = preview.targetURL
let removals = preview.changes.filter { $0.operation == .removed }
.sorted { $0.path.count > $1.path.count }
var completed = 0
let total = preview.changes.count
for change in removals {
try Task.checkCancellation()
let destination = try safeURL(change.path, under: targetRoot)
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
completed += 1
progress("Entferne \(change.path) (\(completed) von \(total))…")
}
let directories = preview.changes.filter { $0.operation != .removed && $0.isDirectory }
.sorted { $0.path.count < $1.path.count }
for change in directories {
try Task.checkCancellation()
let destination = try safeURL(change.path, under: targetRoot)
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: destination.path, isDirectory: &isDirectory), !isDirectory.boolValue {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
completed += 1
progress("Erstelle Ordner \(change.path) (\(completed) von \(total))…")
}
let files = preview.changes.filter { $0.operation != .removed && !$0.isDirectory }
for change in files {
try Task.checkCancellation()
let source = try safeURL(change.path, under: sourceURL)
let destination = try safeURL(change.path, under: targetRoot)
let parent = destination.deletingLastPathComponent()
try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
let staging = parent.appendingPathComponent(".tonuino-sync-\(UUID().uuidString).tmp")
do {
try FileManager.default.copyItem(at: source, to: staging)
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.moveItem(at: staging, to: destination)
} catch {
try? FileManager.default.removeItem(at: staging)
throw error
}
completed += 1
progress("Kopiere \(change.path) (\(completed) von \(total))…")
}
}
static func restoreMissingFiles(
from preview: LibrarySyncPreview,
into destinationRoot: URL,
progress: @escaping @Sendable (String) -> Void
) throws -> Int {
let sourceRoot = preview.targetURL
guard FileManager.default.fileExists(atPath: sourceRoot.path),
!pathsOverlap(sourceRoot, destinationRoot) else { throw LibraryError.invalidSyncTarget }
try FileManager.default.createDirectory(at: destinationRoot, withIntermediateDirectories: true)
let conflictingLocalFiles = preview.changes
.filter { $0.operation == .updated && !$0.isDirectory }
.map(\.path)
func isBelowConflictingFile(_ path: String) -> Bool {
conflictingLocalFiles.contains { path.hasPrefix($0 + "/") }
}
let directories = preview.changes
.filter { $0.operation == .removed && $0.isDirectory }
.sorted { $0.path.count < $1.path.count }
for change in directories {
try Task.checkCancellation()
guard !isBelowConflictingFile(change.path) else { continue }
let destination = try safeURL(change.path, under: destinationRoot)
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: destination.path, isDirectory: &isDirectory) {
guard isDirectory.boolValue else { continue }
} else {
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
}
}
let files = preview.changes.filter { $0.operation == .removed && !$0.isDirectory }
var copiedCount = 0
for change in files {
try Task.checkCancellation()
guard !isBelowConflictingFile(change.path) else { continue }
let source = try safeURL(change.path, under: sourceRoot)
let destination = try safeURL(change.path, under: destinationRoot)
guard !FileManager.default.fileExists(atPath: destination.path) else { continue }
try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
let staging = destination.deletingLastPathComponent()
.appendingPathComponent(".tonuino-recovery-\(UUID().uuidString).partial")
do {
try FileManager.default.copyItem(at: source, to: staging)
try Task.checkCancellation()
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: staging)
continue
}
try FileManager.default.moveItem(at: staging, to: destination)
copiedCount += 1
progress("Ergänze \(change.path) (\(copiedCount) von \(files.count))…")
} catch {
try? FileManager.default.removeItem(at: staging)
throw error
}
}
return copiedCount
}
private static func safeURL(_ relativePath: String, under root: URL) throws -> URL {
let components = relativePath.split(separator: "/")
guard !components.isEmpty, components.allSatisfy({ $0 != "." && $0 != ".." }) else { throw LibraryError.invalidSyncPath }
return components.reduce(root) { $0.appendingPathComponent(String($1)) }
}
private static func filesHaveSameContents(_ firstURL: URL, _ secondURL: URL) throws -> Bool {
try contentDigest(at: firstURL) == contentDigest(at: secondURL)
}
private static func contentDigest(at url: URL) throws -> SHA256.Digest {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var hasher = SHA256()
while let data = try handle.read(upToCount: 1_048_576), !data.isEmpty {
try Task.checkCancellation()
hasher.update(data: data)
}
return hasher.finalize()
}
static func pathsOverlap(_ first: URL, _ second: URL) -> Bool {
let firstPath = first.standardizedFileURL.path
let secondPath = second.standardizedFileURL.path
return firstPath == secondPath
|| firstPath.hasPrefix(secondPath.hasSuffix("/") ? secondPath : secondPath + "/")
|| secondPath.hasPrefix(firstPath.hasSuffix("/") ? firstPath : firstPath + "/")
}
private static func fileTree(
at root: URL,
label: String,
progress: @escaping @Sendable (String) -> Void
) throws -> [String: SyncFileEntry] {
var entries: [String: SyncFileEntry] = [:]
var fileCount = 0
func visit(_ directory: URL) throws {
try Task.checkCancellation()
let children = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey, .fileSizeKey], options: [])
for child in children {
try Task.checkCancellation()
guard !isMacOSMetadata(child.lastPathComponent) else { continue }
let values = try child.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey, .fileSizeKey])
let path = String(child.path.dropFirst(root.path.count + 1))
if path == CardFolderNames.fileName, values.isDirectory != true { continue }
guard values.isSymbolicLink != true else { throw LibraryError.symbolicLink(child.lastPathComponent) }
if values.isDirectory == true {
entries[path] = SyncFileEntry(isDirectory: true, size: 0, modifiedTimeBucket: nil)
try visit(child)
} else {
let size = Int64(values.fileSize ?? 0)
let modifiedTimeBucket = values.contentModificationDate.map {
Int64(floor($0.timeIntervalSince1970 / 2))
}
entries[path] = SyncFileEntry(isDirectory: false, size: size, modifiedTimeBucket: modifiedTimeBucket)
fileCount += 1
if fileCount == 1 || fileCount.isMultiple(of: 128) {
progress("\(label): Prüfe Dateiinformationen (\(fileCount) Dateien)")
}
}
}
}
try visit(root)
return entries
}
}
private struct SyncFileEntry: Sendable {
let isDirectory: Bool
let size: Int64
let modifiedTimeBucket: Int64?
}
private struct LibrarySnapshot: Sendable {
let folders: [MusicFolder]
let systemFolders: [SystemMusicFolder]
}
enum CardFolderNames {
static let fileName = ".tonuino-manager.json"
static func read(from rootURL: URL) -> [Int: String] {
let url = rootURL.appendingPathComponent(fileName)
guard let data = try? Data(contentsOf: url), let values = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] }
return Dictionary(values.compactMap { key, value in Int(key).map { ($0, value) } }, uniquingKeysWith: { _, newest in newest })
}
static func set(_ name: String?, for number: Int, on rootURL: URL) throws {
var names = read(from: rootURL)
if let name, !name.isEmpty { names[number] = name }
else { names.removeValue(forKey: number) }
let url = rootURL.appendingPathComponent(fileName)
if names.isEmpty {
if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) }
} else {
let values = Dictionary(uniqueKeysWithValues: names.map { (String(format: "%02d", $0.key), $0.value) })
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(values).write(to: url, options: .atomic)
}
}
}
struct CleanPreview {
let rootItems: [String]
let malformedFiles: [String]
let removeURLs: [URL]
}
enum LibraryError: LocalizedError {
case noCard, noFolder, noFolderSlots, trackLimit
case invalidProfileName, profileNotFound, invalidSyncTarget, profileBoundToDifferentVolume, syncTargetChanged, invalidSyncPath, symbolicLink(String)
case profileStorageBusy, profileStorageUnavailable, invalidProfileStorageDestination, profileStorageDestinationNotEmpty, profileStorageMigrationFailed
var errorDescription: String? {
switch self {
case .noCard: "Bitte zuerst eine TonUINO-SD-Karte auswählen."
case .noFolder: "Bitte einen Ordner auswählen."
case .noFolderSlots: "Alle 99 TonUINO-Ordner sind belegt."
case .trackLimit: "Pro Ordner sind maximal 255 Tracks möglich."
case .invalidProfileName: "Bitte einen Profilnamen eingeben."
case .profileNotFound: "Das lokale Bibliotheksprofil wurde nicht gefunden."
case .invalidSyncTarget: "Die SD-Karte ist nicht erreichbar oder kann nicht mit diesem Profil synchronisiert werden."
case .profileBoundToDifferentVolume: "Dieses Profil ist einer anderen SD-Karte zugeordnet. Bitte erstelle eine neue Synchronisierungsvorschau."
case .syncTargetChanged: "Die ausgewählte SD-Karte hat sich seit der Vorschau geändert. Bitte erstelle eine neue Vorschau."
case .invalidSyncPath: "Der Synchronisierungspfad ist ungültig."
case .symbolicLink(let name): "Der symbolische Link „\(name)“ kann nicht sicher synchronisiert werden."
case .profileStorageBusy: "Der Speicherort kann während einer laufenden Synchronisierung nicht geändert werden."
case .profileStorageUnavailable: "Der aktuelle Profilspeicher ist nicht erreichbar. Verbinde das Laufwerk erneut und versuche es noch einmal."
case .invalidProfileStorageDestination: "Der neue Speicherort darf nicht innerhalb des bisherigen Profilspeichers liegen und diesen auch nicht enthalten."
case .profileStorageDestinationNotEmpty: "Am gewählten Speicherort liegen bereits Dateien. Bitte wähle einen leeren Ordner."
case .profileStorageMigrationFailed: "Die lokalen Profile konnten am neuen Speicherort nicht vollständig geprüft werden. Der bisherige Speicherort bleibt erhalten."
}
}
}
enum AudioImportService {
static let supported = Set(["mp3", "wav", "flac", "ogg", "aac", "wma", "m4a", "opus"])
static func convertIfNeeded(_ source: URL) throws -> URL {
guard supported.contains(source.pathExtension.lowercased()) else { throw AudioImportError.unsupported(source.pathExtension) }
guard source.pathExtension.lowercased() != "mp3" else { return source }
guard let ffmpeg = MediaToolLocator.executable(named: "ffmpeg") else { throw AudioImportError.converterMissing }
let output = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).mp3")
let arguments = ["-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", source.path, "-map", "0:a:0", "-vn", "-codec:a", "libmp3lame", "-q:a", "2", "-id3v2_version", "3", output.path]
do { _ = try MediaProcess.run(ffmpeg, arguments: arguments) }
catch { try? FileManager.default.removeItem(at: output); throw error }
guard FileManager.default.fileExists(atPath: output.path) else { throw AudioImportError.conversionFailed }
return output
}
}
enum AudioImportError: LocalizedError {
case unsupported(String), converterMissing, conversionFailed
var errorDescription: String? {
switch self {
case .unsupported(let ext): "Das Format .\(ext) wird nicht unterstützt."
case .converterMissing: "FFmpeg fehlt im App-Paket. Installiere eine App-Version mit gebündeltem FFmpeg."
case .conversionFailed: "Die Audiodatei konnte nicht nach MP3 konvertiert werden."
}
}
}