1155 lines
60 KiB
Swift
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."
|
|
}
|
|
}
|
|
}
|