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() @Published var cleanPreview: CleanPreview? @Published private(set) var syncProgress = "" @Published private(set) var isSyncing = false private var scanTask: Task? private var volumeScanTask: Task? 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) 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 = [.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." } } }