commit 1cac427ff21aa6b870bf317a5b07f82560021943 Author: Maddin Date: Sat Oct 31 16:40:48 2026 +0100 Add Tonuino Manager website and Sparkle updates diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebe6b43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.build/ +DerivedData/ +*.xcuserstate +xcuserdata/ +*.xcodeproj/ +.DS_Store +/*.bin +/Icon.png +/Icon_Neu.png +/33/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b517c98 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Tonuino Manager for macOS + +Native SwiftUI app for managing TonUINO music cards and programming TonUINO NFC cards. + +## Build + +Requirements: macOS 13+, Xcode 15+, and [XcodeGen](https://github.com/yonaskolb/XcodeGen). + +```sh +xcodegen generate +open TonuinoManager.xcodeproj +``` + +The first launch can be built from Xcode. Select a mounted SD-card volume in the app. TonUINO firmware needs `SerialInputAsCommand` enabled for serial programming. The alternative reader path uses CryptoTokenKit and a PC/SC compatible ACR122U. + +For local builds, bundle standalone media tools into the built app before launching it: + +```sh +./scripts/bundle-local-media-tools.sh \ + ".build/DerivedData/Build/Products/Debug/Tonuino Manager.app" \ + "/path/to/ffmpeg" "/path/to/yt-dlp" "/path/to/deno" "/path/to/fpcalc" +``` + +Audio conversion, YouTube import and AcoustID fingerprinting use only tools embedded in the app bundle. The app never searches `PATH`, Homebrew, or other system package locations. YouTube import bundles yt-dlp and Deno alongside FFmpeg; AcoustID uses bundled Chromaprint `fpcalc`. Videos can be imported as one MP3 or as one MP3 per chapter. + +AcoustID identification fingerprints selected audio files with bundled Chromaprint `fpcalc`, looks up recordings through AcoustID, and uses MusicBrainz release data for tags. Register an application at AcoustID and save its client key in Settings; the key is stored in the macOS Keychain. The lookup service is intended for non-commercial use and requests are rate limited. + +## Universal release + +The release script builds arm64 and x86_64, embeds universal FFmpeg, yt-dlp, Deno and Chromaprint `fpcalc` executables, signs and notarizes the app, and creates a stapled DMG plus a Sparkle ZIP. The Sparkle feed is hosted in this Gitea repository at `appcast.xml`; its public key is embedded in the app. The corresponding private key is stored in the macOS Keychain and must never be committed. + +```sh +DEVELOPMENT_TEAM=TEAMID \ +DEVELOPER_ID_APPLICATION='Developer ID Application: Example (TEAMID)' \ +NOTARY_PROFILE=tonuino-notary \ +FFMPEG_BINARY='/path/to/universal/ffmpeg' \ +YTDLP_BINARY='/path/to/universal/yt-dlp_macos' \ +DENO_BINARY='/path/to/universal/deno' \ +FPCALC_BINARY='/path/to/universal/fpcalc' \ +./scripts/build-release.sh +``` + +Publish the resulting release from a clean `main` checkout after setting `APP_VERSION`, `APP_BUILD`, and a Gitea API token in `GITEA_TOKEN`: + +```sh +APP_VERSION=1.0.1 APP_BUILD=2 GITEA_TOKEN='…' ./scripts/publish-release.sh +``` + +The publisher creates a Gitea release, signs the Sparkle archive with the private key in Keychain, updates and pushes `appcast.xml`, and deploys the DMG and download page to `https://tonuino.maddin.app`. The website itself is static and has no JavaScript runtime dependency. + +FFmpeg must include `libmp3lame`. Do not use Homebrew wrappers or binaries linked to Homebrew libraries; dependencies must run from the app bundle without separately installed packages. Include the licenses and corresponding source offers for all bundled tools with releases. + +## TonUINO card format + +Card data matches TonUINO-TNG: cookie `13 37 B3 47`, version `02`, folder, playback mode, and two special parameters. ACR122U access supports MIFARE Mini, Classic 1K/4K and Ultralight/NTAG cards. Serial programming sends the TNG `WRITECARD` command at 115200 baud. diff --git a/Sources/TonuinoManager/App/AppModel.swift b/Sources/TonuinoManager/App/AppModel.swift new file mode 100644 index 0000000..e131228 --- /dev/null +++ b/Sources/TonuinoManager/App/AppModel.swift @@ -0,0 +1,40 @@ +import AppKit +import Combine +import SwiftUI + +@MainActor +final class AppModel: ObservableObject { + enum Section: String, CaseIterable, Identifiable, Hashable { + case library = "SD-Karte" + case cards = "NFC-Karten" + case tonuino = "TonUINO" + case settings = "Einstellungen" + var id: String { rawValue } + var icon: String { + switch self { + case .library: "sdcard" + case .cards: "wave.3.right" + case .tonuino: "slider.horizontal.3" + case .settings: "gearshape" + } + } + } + + @Published var section: Section = .library + @Published var library = LibraryModel() + @Published var cards = CardModel() + @Published var status = "Bereit" + @Published var errorMessage: String? + @Published var showError = false + private var childSubscriptions = Set() + + init() { + library.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }.store(in: &childSubscriptions) + cards.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }.store(in: &childSubscriptions) + } + + func report(_ error: Error) { + errorMessage = error.localizedDescription + showError = true + } +} diff --git a/Sources/TonuinoManager/App/ContentView.swift b/Sources/TonuinoManager/App/ContentView.swift new file mode 100644 index 0000000..6617372 --- /dev/null +++ b/Sources/TonuinoManager/App/ContentView.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject private var app: AppModel + + var body: some View { + VStack(spacing: 0) { + NavigationSplitView { + List(AppModel.Section.allCases, selection: $app.section) { item in + Label(item.rawValue, systemImage: item.icon).tag(item) + } + .listStyle(.sidebar) + .navigationTitle("Tonuino Manager") + .safeAreaInset(edge: .bottom) { + HStack(spacing: 8) { + Circle().fill(app.library.rootURL == nil ? Color.secondary : Color.green).frame(width: 8, height: 8) + Text(app.library.activeProfile.map { "Lokal · \($0.name)" } ?? (app.library.rootURL?.lastPathComponent ?? "Keine Bibliothek ausgewählt")) + .lineLimit(1).foregroundStyle(.secondary) + } + .font(.caption).padding() + } + } detail: { + switch app.section { + case .library: LibraryView() + case .cards: CardsView() + case .tonuino: TonuinoSettingsView() + case .settings: SettingsView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + HStack { + Spacer(minLength: 0) + Text(app.status) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 520, alignment: .trailing) + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + .background(.regularMaterial) + .overlay(alignment: .top) { + Rectangle().fill(.primary.opacity(0.08)).frame(height: 1) + } + } + .alert("Fehler", isPresented: $app.showError) { + Button("OK", role: .cancel) { } + } message: { Text(app.errorMessage ?? "Unbekannter Fehler") } + } +} diff --git a/Sources/TonuinoManager/App/TonuinoManagerApp.swift b/Sources/TonuinoManager/App/TonuinoManagerApp.swift new file mode 100644 index 0000000..0cdf071 --- /dev/null +++ b/Sources/TonuinoManager/App/TonuinoManagerApp.swift @@ -0,0 +1,35 @@ +import SwiftUI +import Sparkle +import AppKit + +final class AppDelegate: NSObject, NSApplicationDelegate { + static var updater: SPUStandardUpdaterController? + func applicationDidFinishLaunching(_ notification: Notification) { + guard let feed = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String, + URL(string: feed)?.scheme != nil, + let key = Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String, + !key.isEmpty else { return } + Self.updater = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + } +} + +@main +struct TonuinoManagerApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var model = AppModel() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(model) + .frame(minWidth: 980, minHeight: 640) + } + .commands { + CommandGroup(replacing: .newItem) { } + CommandGroup(after: .appInfo) { + Button("Auf Updates prüfen…") { AppDelegate.updater?.checkForUpdates(nil) } + .disabled(AppDelegate.updater?.updater.canCheckForUpdates != true) + } + } + } +} diff --git a/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..78cda91 Binary files /dev/null and b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png new file mode 100644 index 0000000..2cdde45 Binary files /dev/null and b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png differ diff --git a/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png new file mode 100644 index 0000000..28b114c Binary files /dev/null and b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png differ diff --git a/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-256-1x.png b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-256-1x.png new file mode 100644 index 0000000..f0e6b73 Binary files /dev/null and b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-256-1x.png differ diff --git a/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png new file mode 100644 index 0000000..f0e6b73 Binary files /dev/null and b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png differ diff --git a/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-32-1x.png b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-32-1x.png new file mode 100644 index 0000000..31796b7 Binary files /dev/null and b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-32-1x.png differ diff --git a/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png new file mode 100644 index 0000000..31796b7 Binary files /dev/null and b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png differ diff --git a/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-512-1x.png b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-512-1x.png new file mode 100644 index 0000000..47a3bd4 Binary files /dev/null and b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-512-1x.png differ diff --git a/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png new file mode 100644 index 0000000..47a3bd4 Binary files /dev/null and b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png differ diff --git a/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-64.png b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-64.png new file mode 100644 index 0000000..4c558fb Binary files /dev/null and b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/AppIcon-64.png differ diff --git a/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..038cea6 --- /dev/null +++ b/Sources/TonuinoManager/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images": [ + { + "filename": "AppIcon-16.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" + }, + { + "filename": "AppIcon-32.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" + }, + { + "filename": "AppIcon-32-1x.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" + }, + { + "filename": "AppIcon-64.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" + }, + { + "filename": "AppIcon-128.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "filename": "AppIcon-256.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "filename": "AppIcon-256-1x.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "filename": "AppIcon-512.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "filename": "AppIcon-512-1x.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "AppIcon-1024.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Sources/TonuinoManager/Models/LibraryModels.swift b/Sources/TonuinoManager/Models/LibraryModels.swift new file mode 100644 index 0000000..6b25823 --- /dev/null +++ b/Sources/TonuinoManager/Models/LibraryModels.swift @@ -0,0 +1,34 @@ +import Foundation + +struct MusicFolder: Identifiable, Hashable, Sendable { + let number: Int + let url: URL + var tracks: [MusicTrack] + var customName: String? = nil + var id: Int { number } + var title: String { customName ?? tracks.first(where: { !$0.album.isEmpty })?.album ?? "Ordner \(String(format: "%02d", number))" } +} + +struct MusicTrack: Identifiable, Hashable, Sendable { + let url: URL + var title: String + var artist: String + var album: String + var albumArtist: String = "" + var trackNumber: String = "" + var discNumber: String = "" + var year: String = "" + var genre: String = "" + var acoustID: String = "" + var musicBrainzRecordingID: String = "" + var musicBrainzReleaseID: String = "" + var id: String { url.path } + var displayTitle: String { title.isEmpty ? url.deletingPathExtension().lastPathComponent : title } +} + +struct SystemMusicFolder: Identifiable, Hashable, Sendable { + let name: String + let url: URL + var tracks: [MusicTrack] + var id: String { name } +} diff --git a/Sources/TonuinoManager/Models/TonuinoCard.swift b/Sources/TonuinoManager/Models/TonuinoCard.swift new file mode 100644 index 0000000..50f41ea --- /dev/null +++ b/Sources/TonuinoManager/Models/TonuinoCard.swift @@ -0,0 +1,116 @@ +import Foundation + +enum PlaybackMode: Int, CaseIterable, Identifiable, Hashable { + case hoerspiel = 1, album, party, single, audiobook, admin = 255 + case hoerspielRange = 7, albumRange, partyRange, audiobookSingle, repeatLast, quiz, memory, bluetooth, teapot, audiobookRange + + var id: Int { rawValue } + var title: String { + switch self { + case .hoerspiel: "Hörspiel"; case .album: "Album"; case .party: "Party"; case .single: "Einzelner Track" + case .audiobook: "Hörbuch"; case .admin: "Admin-Karte"; case .hoerspielRange: "Hörspiel von-bis" + case .albumRange: "Album von-bis"; case .partyRange: "Party von-bis"; case .audiobookSingle: "Hörbuch einzeln" + case .repeatLast: "Letzte Karte wiederholen"; case .quiz: "Quiz-Spiel"; case .memory: "Memory-Spiel" + case .bluetooth: "Bluetooth umschalten"; case .teapot: "Teekesselchen-Spiel"; case .audiobookRange: "Hörbuch von-bis" + } + } + var needsFolder: Bool { ![.repeatLast, .bluetooth].contains(self) } + var needsSpecial: Bool { [.single, .audiobookSingle, .hoerspielRange, .albumRange, .partyRange, .quiz, .audiobookRange].contains(self) } + var needsSpecial2: Bool { [.hoerspielRange, .albumRange, .partyRange, .quiz, .audiobookRange].contains(self) } +} + +enum TonuinoModifier: Int, CaseIterable, Identifiable, Hashable { + case sleepTimer = 1 + case freezeDance + case fireWaterAir + case toddlerMode + case kindergartenMode + case repeatSingle + case bluetoothToggle + case jukebox + case pauseAfterTrack + case standbyTimerToggle + case endlessToggle + + var id: Int { rawValue } + var title: String { + switch self { + case .sleepTimer: "Einschlaf-Timer" + case .freezeDance: "Stopptanz" + case .fireWaterAir: "Feuer, Wasser, Luft" + case .toddlerMode: "Kleinkindmodus" + case .kindergartenMode: "Kindergartenmodus" + case .repeatSingle: "Track wiederholen" + case .bluetoothToggle: "Bluetooth umschalten" + case .jukebox: "Jukebox" + case .pauseAfterTrack: "Nach jedem Track pausieren" + case .standbyTimerToggle: "Standby-Timer umschalten" + case .endlessToggle: "Endlosmodus umschalten" + } + } + + var firmwareNote: String? { + switch self { + case .bluetoothToggle: "Benötigt eine TonUINO-Firmware mit BT_MODULE." + case .jukebox: "Benötigt eine TonUINO-Firmware mit MODIFICATION_CARD_JUKEBOX." + case .pauseAfterTrack: "Benötigt eine TonUINO-Firmware mit MODIFICATION_CARD_PAUSE_AFTER_TRACK." + case .standbyTimerToggle: "Benötigt TonUINO-TNG 3.3.2 oder neuer." + case .endlessToggle: "Benötigt TonUINO-TNG 3.3.3 oder neuer." + default: nil + } + } +} + +struct TonuinoCard: Equatable { + var folder: UInt8 + var mode: PlaybackMode + var special: UInt8 = 0 + var special2: UInt8 = 0 + var modifier: TonuinoModifier? = nil + + var displayTitle: String { modifier?.title ?? mode.title } + + func payload() -> Data { + Data([0x13, 0x37, 0xB3, 0x47, 0x02, folder, UInt8(modifier?.rawValue ?? mode.rawValue), special, special2] + Array(repeating: 0, count: 7)) + } + + static func decode(_ bytes: Data) -> TonuinoCard? { + guard bytes.count >= 9, Array(bytes.prefix(4)) == [0x13, 0x37, 0xB3, 0x47], bytes[4] > 0, bytes[4] <= 2, + let mode = PlaybackMode(rawValue: Int(bytes[6])) else { return nil } + let folder = bytes[5] + let modifier = folder == 0 ? TonuinoModifier(rawValue: Int(bytes[6])) : nil + return TonuinoCard(folder: folder, mode: mode, special: bytes[7], special2: bytes[8], modifier: modifier) + } + + static func makeModifier(_ modifier: TonuinoModifier, special: UInt8 = 0) -> TonuinoCard { + let fallbackMode = PlaybackMode(rawValue: modifier.rawValue) ?? .admin + return TonuinoCard(folder: 0, mode: fallbackMode, special: special, modifier: modifier) + } +} + +enum MifareClassicDump { + static func make1K(for card: TonuinoCard) -> Data { + var dump = Data(repeating: 0, count: 1024) + + let uid = (0..<4).map { _ in UInt8.random(in: 0...255) } + let bcc = uid.reduce(UInt8(0), ^) + let manufacturerBlock = uid + [bcc, 0x08, 0x04, 0x00] + Array(repeating: UInt8(0), count: 8) + dump.replaceSubrange(0..<16, with: manufacturerBlock) + + // TonUINO stores its 16-byte card payload in MIFARE Classic block 4. + dump.replaceSubrange(64..<80, with: card.payload()) + + // Use the standard transport configuration and default FF keys in each sector trailer. + let sectorTrailer: [UInt8] = [ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0x07, 0x80, 0x69, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF + ] + for block in stride(from: 3, through: 63, by: 4) { + let start = block * 16 + dump.replaceSubrange(start..<(start + 16), with: sectorTrailer) + } + + return dump + } +} diff --git a/Sources/TonuinoManager/Services/AcoustIDService.swift b/Sources/TonuinoManager/Services/AcoustIDService.swift new file mode 100644 index 0000000..2ed045f --- /dev/null +++ b/Sources/TonuinoManager/Services/AcoustIDService.swift @@ -0,0 +1,248 @@ +import Foundation +import Security + +struct AcoustIDRelease: Decodable, Identifiable, Hashable, Sendable { + let id: String + let title: String + let date: String? + let artists: [AcoustIDArtist]? + + var artist: String { artists?.map(\.name).filter { !$0.isEmpty }.joined(separator: ", ") ?? "" } + var year: String { String((date ?? "").prefix(4)) } + + private enum CodingKeys: String, CodingKey { case id, title, date, artists } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + title = try values.decode(String.self, forKey: .title) + artists = try values.decodeIfPresent([AcoustIDArtist].self, forKey: .artists) + + if let stringDate = try? values.decode(String.self, forKey: .date) { + date = stringDate + } else if let parts = try? values.decode(DateParts.self, forKey: .date) { + let year = parts.year?.value + let month = parts.month?.value + let day = parts.day?.value + if let year { + date = [year, month.map { String(format: "%02d", Int($0) ?? 0) }, day.map { String(format: "%02d", Int($0) ?? 0) }] + .compactMap { $0 } + .joined(separator: "-") + } else { + date = nil + } + } else { + date = nil + } + } + + private struct DateParts: Decodable { + let year: DatePart? + let month: DatePart? + let day: DatePart? + } + + private struct DatePart: Decodable { + let value: String + + init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer() + if let string = try? value.decode(String.self) { + self.value = string + } else if let integer = try? value.decode(Int.self) { + self.value = String(integer) + } else { + throw DecodingError.typeMismatch(String.self, .init(codingPath: decoder.codingPath, debugDescription: "Expected a date component as a string or integer.")) + } + } + } +} + +struct AcoustIDArtist: Decodable, Hashable, Sendable { + let id: String? + let name: String +} + +struct AcoustIDMatch: Identifiable, Hashable, Sendable { + let acoustID: String + let score: Double + let recordingID: String + let title: String + let artist: String + let releases: [AcoustIDRelease] + + var id: String { "\(acoustID):\(recordingID)" } +} + +enum AcoustIDCredentials { + private static let service = Bundle.main.bundleIdentifier ?? "app.maddin.tonuino" + private static let account = "acoustid-client-key" + + static func loadClientKey() -> String? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } + return value + } + + static func saveClientKey(_ value: String) throws { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { + let status = SecItemDelete(baseQuery as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { throw AcoustIDError.keychain(status) } + return + } + + let data = Data(normalized.utf8) + let update = [kSecValueData as String: data] + let updateStatus = SecItemUpdate(baseQuery as CFDictionary, update as CFDictionary) + if updateStatus == errSecItemNotFound { + var query = baseQuery + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked + let addStatus = SecItemAdd(query as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw AcoustIDError.keychain(addStatus) } + } else if updateStatus != errSecSuccess { + throw AcoustIDError.keychain(updateStatus) + } + } + + private static var baseQuery: [String: Any] { + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account] + } +} + +enum AcoustIDService { + private static let endpoint = URL(string: "https://api.acoustid.org/v2/lookup")! + private static let session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 30 + return URLSession(configuration: configuration) + }() + + static func identify(_ file: URL, clientKey: String) async throws -> [AcoustIDMatch] { + guard !clientKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw AcoustIDError.missingClientKey } + guard let fpcalc = MediaToolLocator.executable(named: "fpcalc") else { throw MediaToolError.missing("Chromaprint fpcalc") } + + let fingerprintData = try await Task.detached(priority: .userInitiated) { + try MediaProcess.run(fpcalc, arguments: ["-json", "-length", "120", file.path]) + }.value + let fingerprint: FingerprintOutput + do { fingerprint = try JSONDecoder().decode(FingerprintOutput.self, from: fingerprintData) } + catch { throw AcoustIDError.invalidFingerprint(file.lastPathComponent) } + guard fingerprint.duration > 0, !fingerprint.fingerprint.isEmpty else { throw AcoustIDError.invalidFingerprint(file.lastPathComponent) } + + await LookupRateGate.shared.wait() + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = formBody([ + "client": clientKey, + "duration": String(Int(fingerprint.duration.rounded())), + "fingerprint": fingerprint.fingerprint, + "format": "json", + "meta": "recordings+releases" + ]) + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw AcoustIDError.http((response as? HTTPURLResponse)?.statusCode ?? -1) + } + let payload: LookupResponse + do { payload = try JSONDecoder().decode(LookupResponse.self, from: data) } + catch { throw AcoustIDError.invalidResponse(Self.responseDecodingFailure(error, data: data, response: response)) } + guard payload.status == "ok" else { throw AcoustIDError.service(payload.error?.message ?? "Unbekannter AcoustID-Fehler") } + + return (payload.results ?? []).flatMap { result in + (result.recordings ?? []).map { recording in + let artist = recording.artists?.map(\.name).filter { !$0.isEmpty }.joined(separator: ", ") ?? "" + return AcoustIDMatch( + acoustID: result.id, + score: result.score, + recordingID: recording.id, + title: recording.title, + artist: artist, + releases: recording.releases ?? [] + ) + } + }.sorted { $0.score > $1.score } + } + + private static func formBody(_ values: [String: String]) -> Data { + var components = URLComponents() + components.queryItems = values.keys.sorted().map { URLQueryItem(name: $0, value: values[$0]) } + return Data((components.percentEncodedQuery ?? "").utf8) + } + + private static func responseDecodingFailure(_ error: Error, data: Data, response: URLResponse) -> String { + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + let contentType = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Type") ?? "unbekannt" + let body = String(data: data.prefix(500), encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "(keine lesbare Antwort)" + let detail: String + if let decodingError = error as? DecodingError { + switch decodingError { + case .keyNotFound(let key, let context): + detail = "Feld '\(key.stringValue)' fehlt (\(context.codingPath.map(\.stringValue).joined(separator: ".")))." + case .typeMismatch(let type, let context): + detail = "Unerwarteter Datentyp für \(context.codingPath.map(\.stringValue).joined(separator: ".")) (\(type))." + case .valueNotFound(let type, let context): + detail = "Leerer Wert für \(context.codingPath.map(\.stringValue).joined(separator: ".")) (\(type))." + case .dataCorrupted(let context): + detail = context.debugDescription + @unknown default: + detail = error.localizedDescription + } + } else { + detail = error.localizedDescription + } + return "HTTP \(status), Content-Type \(contentType): \(detail) Antwort: \(body)" + } + + private struct FingerprintOutput: Decodable { let duration: Double; let fingerprint: String } + private struct LookupResponse: Decodable { + let status: String + let error: ServiceError? + let results: [LookupResult]? + struct ServiceError: Decodable { let message: String? } + } + private struct LookupResult: Decodable { let id: String; let score: Double; let recordings: [Recording]? + struct Recording: Decodable { + let id: String + let title: String + let artists: [AcoustIDArtist]? + let releases: [AcoustIDRelease]? + } + } + + private actor LookupRateGate { + static let shared = LookupRateGate() + private var lastRequest = Date.distantPast + func wait() async { + let delay = max(0, 0.36 - Date().timeIntervalSince(lastRequest)) + lastRequest = Date().addingTimeInterval(delay) + if delay > 0 { try? await Task.sleep(for: .seconds(delay)) } + } + } +} + +enum AcoustIDError: LocalizedError { + case missingClientKey, invalidFingerprint(String), invalidResponse(String), http(Int), service(String), keychain(OSStatus) + + var errorDescription: String? { + switch self { + case .missingClientKey: "Trage zuerst den AcoustID-Anwendungsschlüssel in den Einstellungen ein." + case .invalidFingerprint(let file): "Für \(file) konnte kein Audiofingerabdruck erstellt werden." + case .invalidResponse(let detail): "Die AcoustID-Antwort konnte nicht gelesen werden: \(detail)" + case .http(let status): "AcoustID ist mit HTTP-Status \(status) fehlgeschlagen." + case .service(let message): "AcoustID-Fehler: \(message)" + case .keychain(let status): "Der AcoustID-Schlüssel konnte nicht im Schlüsselbund gespeichert werden (\(status))." + } + } +} diff --git a/Sources/TonuinoManager/Services/AudioPlayerModel.swift b/Sources/TonuinoManager/Services/AudioPlayerModel.swift new file mode 100644 index 0000000..f593a14 --- /dev/null +++ b/Sources/TonuinoManager/Services/AudioPlayerModel.swift @@ -0,0 +1,153 @@ +import AVFoundation +import Combine +import Foundation +import MediaPlayer + +@MainActor +final class AudioPlayerModel: ObservableObject { + @Published private(set) var title = "" + @Published private(set) var artist = "" + @Published private(set) var duration: Double = 0 + @Published private(set) var position: Double = 0 + @Published private(set) var isPlaying = false + @Published private(set) var isLoading = false + @Published private(set) var statusMessage = "Bereit" + @Published private(set) var currentURL: URL? + @Published var volume: Double = 0.8 { didSet { player.volume = Float(volume) } } + + private let player = AVPlayer() + private let commandCenter = MPRemoteCommandCenter.shared() + private var observer: Any? + private var itemObservation: NSKeyValueObservation? + private var endObserver: NSObjectProtocol? + private var remoteCommandTargets: [(MPRemoteCommand, Any)] = [] + private var queue: [MusicTrack] = [] + private var currentIndex = 0 + + init() { + player.automaticallyWaitsToMinimizeStalling = true + installRemoteCommands() + observer = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.25, preferredTimescale: 600), queue: .main) { [weak self] time in + Task { @MainActor [weak self] in + guard let self else { return } + self.position = time.seconds.isFinite ? time.seconds : 0 + let seconds = self.player.currentItem?.duration.seconds ?? 0 + if seconds.isFinite { self.duration = seconds } + self.isPlaying = self.player.rate > 0 + self.updateNowPlayingInfo() + } + } + } + + deinit { + if let observer { player.removeTimeObserver(observer) } + if let endObserver { NotificationCenter.default.removeObserver(endObserver) } + for (command, target) in remoteCommandTargets { command.removeTarget(target) } + } + + func play(_ tracks: [MusicTrack], startingAt index: Int) { + guard tracks.indices.contains(index) else { return } + queue = tracks; currentIndex = index + loadCurrent(autoplay: true) + } + + func isCurrentTrack(in tracks: [MusicTrack]) -> Bool { + guard let currentURL else { return false } + return tracks.contains { $0.url.standardizedFileURL == currentURL.standardizedFileURL } + } + + func toggle() { + guard currentURL != nil else { statusMessage = "Wähle zuerst einen Track aus."; return } + if isPlaying { player.pause(); isPlaying = false; statusMessage = "Pausiert" } + else if player.currentItem?.status == .failed { loadCurrent(autoplay: true) } + else { player.play(); isPlaying = true; statusMessage = "Wiedergabe wird gestartet…" } + updateNowPlayingInfo() + } + + func seek(_ value: Double) { player.seek(to: CMTime(seconds: value, preferredTimescale: 600)) } + func next() { guard currentIndex + 1 < queue.count else { return }; currentIndex += 1; loadCurrent(autoplay: true) } + func previous() { guard currentIndex > 0 else { return }; currentIndex -= 1; loadCurrent(autoplay: true) } + + private func loadCurrent(autoplay: Bool) { + let track = queue[currentIndex] + title = track.displayTitle; artist = track.artist; currentURL = track.url + duration = 0; position = 0; isPlaying = false; isLoading = true + statusMessage = "Lade \(track.url.lastPathComponent)…" + updateNowPlayingInfo() + + if let endObserver { NotificationCenter.default.removeObserver(endObserver); self.endObserver = nil } + itemObservation = nil + let item = AVPlayerItem(url: track.url) + itemObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in + Task { @MainActor in + guard let self, self.currentURL == track.url else { return } + switch item.status { + case .readyToPlay: + self.isLoading = false + let seconds = item.duration.seconds + if seconds.isFinite { self.duration = seconds } + self.statusMessage = autoplay ? "Wiedergabe" : "Bereit zum Abspielen" + if autoplay { self.player.play(); self.isPlaying = true } + self.updateNowPlayingInfo() + case .failed: + self.isLoading = false; self.isPlaying = false + self.statusMessage = "Wiedergabe fehlgeschlagen: \(item.error?.localizedDescription ?? "Unbekannter Fehler")" + self.updateNowPlayingInfo() + case .unknown: break + @unknown default: break + } + } + } + player.replaceCurrentItem(with: item) + endObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main) { [weak self] _ in + Task { @MainActor in self?.next() } + } + } + + private func installRemoteCommands() { + let toggleTarget = commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in + Task { @MainActor [weak self] in self?.toggle() } + return .success + } + let playTarget = commandCenter.playCommand.addTarget { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, self.currentURL != nil, !self.isPlaying else { return } + self.toggle() + } + return .success + } + let pauseTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, self.isPlaying else { return } + self.toggle() + } + return .success + } + let nextTarget = commandCenter.nextTrackCommand.addTarget { [weak self] _ in + Task { @MainActor [weak self] in self?.next() } + return .success + } + let previousTarget = commandCenter.previousTrackCommand.addTarget { [weak self] _ in + Task { @MainActor [weak self] in self?.previous() } + return .success + } + remoteCommandTargets = [ + (commandCenter.togglePlayPauseCommand, toggleTarget), + (commandCenter.playCommand, playTarget), + (commandCenter.pauseCommand, pauseTarget), + (commandCenter.nextTrackCommand, nextTarget), + (commandCenter.previousTrackCommand, previousTarget) + ] + } + + private func updateNowPlayingInfo() { + var info: [String: Any] = [ + MPMediaItemPropertyTitle: title, + MPMediaItemPropertyArtist: artist, + MPNowPlayingInfoPropertyElapsedPlaybackTime: position, + MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0 + ] + if duration > 0 { info[MPMediaItemPropertyPlaybackDuration] = duration } + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } +} diff --git a/Sources/TonuinoManager/Services/ID3Metadata.swift b/Sources/TonuinoManager/Services/ID3Metadata.swift new file mode 100644 index 0000000..bb94f93 --- /dev/null +++ b/Sources/TonuinoManager/Services/ID3Metadata.swift @@ -0,0 +1,252 @@ +import Foundation + +struct TrackMetadata: Sendable { + var title = "" + var artist = "" + var album = "" + var albumArtist = "" + var trackNumber = "" + var discNumber = "" + var year = "" + var genre = "" + var acoustID = "" + var musicBrainzRecordingID = "" + var musicBrainzReleaseID = "" +} + +enum ID3Metadata { + private static let standardFrames: [String: WritableKeyPath] = [ + "TIT2": \.title, "TPE1": \.artist, "TALB": \.album, "TPE2": \.albumArtist, + "TRCK": \.trackNumber, "TPOS": \.discNumber, "TDRC": \.year, "TYER": \.year, "TCON": \.genre + ] + private static let managedFrames = Set(standardFrames.keys) + private static let managedUserTexts: [String: WritableKeyPath] = [ + "Acoustid Id": \.acoustID, + "MusicBrainz Recording Id": \.musicBrainzRecordingID, + "MusicBrainz Album Id": \.musicBrainzReleaseID + ] + private static let v22FrameIDs: [String: String] = [ + "BUF": "RBUF", "CNT": "PCNT", "COM": "COMM", "CRA": "AENC", "ETC": "ETCO", "GEO": "GEOB", + "IPL": "TIPL", "MCI": "MCDI", "MLL": "MLLT", "PIC": "APIC", "POP": "POPM", "REV": "RVRB", + "RVA": "RVAD", "SLT": "SYLT", "STC": "SYTC", "TAL": "TALB", "TBP": "TBPM", "TCM": "TCOM", + "TCO": "TCON", "TCR": "TCOP", "TDA": "TDAT", "TDY": "TDLY", "TEN": "TENC", "TFT": "TFLT", + "TIM": "TIME", "TKE": "TKEY", "TLA": "TLAN", "TLE": "TLEN", "TMT": "TMED", "TOA": "TOPE", + "TOF": "TOFN", "TOL": "TOLY", "TOR": "TORY", "TOT": "TOAL", "TP1": "TPE1", "TP2": "TPE2", + "TP3": "TPE3", "TP4": "TPE4", "TPA": "TPOS", "TPB": "TPUB", "TRC": "TSRC", "TRD": "TRDA", + "TRK": "TRCK", "TSI": "TSIZ", "TSS": "TSSE", "TT1": "TIT1", "TT2": "TIT2", "TT3": "TIT3", + "TXT": "TEXT", "TXX": "TXXX", "TYE": "TYER", "UFI": "UFID", "ULT": "USLT", "WAF": "WOAF", + "WAM": "WOAM", "WAS": "WOAS", "WCM": "WCOM", "WCP": "WCOP", "WPB": "WPUB", "WXX": "WXXX" + ] + + static func read(_ url: URL) -> TrackMetadata { + guard let handle = try? FileHandle(forReadingFrom: url) else { return TrackMetadata() } + defer { try? handle.close() } + guard let header = try? handle.read(upToCount: 10), header.count == 10, + String(data: header.prefix(3), encoding: .ascii) == "ID3" else { return TrackMetadata() } + let tagSize = syncSafe(header[6..<10]) + guard tagSize > 0, let tagBody = try? handle.read(upToCount: tagSize) else { return TrackMetadata() } + var data = header + data.append(tagBody) + let version = header[3] + let limit = min(data.count, 10 + syncSafe(data[6..<10])) + var position = 10 + var result = TrackMetadata() + if version == 2 { + guard header[5] & 0x40 == 0 else { return result } + while position + 6 <= limit { + guard let oldID = String(data: data[position..= 0x20 && $0 <= 0x5A }), oldID != "\0\0\0" else { break } + let size = int24(data[position + 3.. 0, position + size <= limit else { break } + let id = v22FrameIDs[oldID] ?? oldID + let frame = data[position..= 0x20 && $0 <= 0x5A }), id != "\0\0\0\0" else { break } + let size = version == 4 ? syncSafe(data[position + 4.. 0, position + size <= limit else { break } + let frame = data[position..= 10, String(data: original.prefix(3), encoding: .ascii) == "ID3" { + let version = original[3] + let end = min(original.count, 10 + syncSafe(original[6..<10])) + audio = original.subdata(in: end..= 0x20 && $0 <= 0x5A }), oldID != "\0\0\0" else { break } + let size = int24(original[index + 3.. 0, index + size <= end else { break } + let oldFrame = original[index.. 0, index + 10 + size <= end else { break } + let frame = original[index + 10..(_ data: S) -> String? { + guard let encoding = data.first else { return nil } + let value = Data(data.dropFirst()) + let decoded: String? + switch encoding { + case 0: decoded = String(data: value, encoding: .isoLatin1) + case 1: decoded = String(data: value, encoding: .utf16) + case 2: decoded = String(data: value, encoding: .utf16BigEndian) + default: decoded = String(data: value, encoding: .utf8) + } + return decoded?.trimmingCharacters(in: .controlCharacters) + } + + private static func decodeUserText(_ data: S) -> (String, String)? { + guard let encoding = data.first else { return nil } + let value = Data(data.dropFirst()) + let separatorStart: Int + let separatorLength: Int + if encoding == 1 || encoding == 2 { + let start = value.starts(with: [0xFF, 0xFE]) || value.starts(with: [0xFE, 0xFF]) ? 2 : 0 + var cursor = start + var found: Int? + while cursor + 1 < value.count { + if value[cursor] == 0 && value[cursor + 1] == 0 { found = cursor; break } + cursor += 2 + } + guard let found else { return nil } + separatorStart = found; separatorLength = 2 + } else { + guard let found = value.firstIndex(of: 0) else { return nil } + separatorStart = found; separatorLength = 1 + } + let description = decodeEncoded(value[..(_ data: S, encoding: UInt8) -> String { + let value = Data(data) + switch encoding { + case 0: return String(data: value, encoding: .isoLatin1) ?? "" + case 1: return String(data: value, encoding: .utf16) ?? "" + case 2: return String(data: value, encoding: .utf16BigEndian) ?? "" + default: return String(data: value, encoding: .utf8) ?? "" + } + } + + private static func encodeV23Text(_ value: String) -> Data { Data([1, 0xFF, 0xFE]) + utf16LE(value) } + + private static func normalizeGenre(_ value: String) -> String { + let genres = [ + "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz", "Metal", + "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock", "Techno", "Industrial", + "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", + "Fusion", "Trance", "Classical", "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise", + "AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", + "Darkwave", "Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta", + "Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American", "Cabaret", "New Wave", "Psychedelic", "Rave", "Showtunes", + "Trailer", "Lo-Fi", "Tribal", "Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock" + ] + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let pattern = #"^\((\d{1,3})\)\s*"# + if let range = trimmed.range(of: pattern, options: .regularExpression), + let numberRange = trimmed.range(of: #"\d{1,3}"#, options: .regularExpression), + let index = Int(trimmed[numberRange]), genres.indices.contains(index) { + let suffix = String(trimmed[range.upperBound...]).trimmingCharacters(in: .whitespaces) + return suffix.isEmpty ? genres[index] : suffix + } + if let index = Int(trimmed), genres.indices.contains(index) { return genres[index] } + return value + } + + private static func convertV22Picture(_ frame: S) -> Data? { + let bytes = Data(frame) + guard bytes.count > 5, let format = String(data: bytes[1..<4], encoding: .ascii) else { return nil } + let normalized = format.uppercased() + let mime = normalized == "JPG" || normalized == "JPEG" ? "image/jpeg" : "image/\(normalized.lowercased())" + return Data([bytes[0]]) + Data(mime.utf8) + Data([0]) + bytes[4...] + } + private static func utf16LE(_ value: String) -> Data { + var data = Data(capacity: value.utf16.count * 2) + for unit in value.utf16 { data.append(UInt8(unit & 0xFF)); data.append(UInt8(unit >> 8)) } + return data + } + + private static func appendFrame(id: String, content: Data, to frames: inout Data) { + frames.append(Data(id.utf8)); frames.append(contentsOf: [UInt8((content.count >> 24) & 255), UInt8((content.count >> 16) & 255), UInt8((content.count >> 8) & 255), UInt8(content.count & 255), 0, 0]); frames.append(content) + } + private static func int32(_ bytes: S) -> Int where S.Element == UInt8 { bytes.reduce(0) { ($0 << 8) | Int($1) } } + private static func int24(_ bytes: S) -> Int where S.Element == UInt8 { bytes.reduce(0) { ($0 << 8) | Int($1) } } + private static func syncSafe(_ bytes: S) -> Int where S.Element == UInt8 { bytes.reduce(0) { ($0 << 7) | Int($1 & 0x7f) } } + private static func syncSafeBytes(_ number: Int) -> [UInt8] { [UInt8((number >> 21) & 0x7f), UInt8((number >> 14) & 0x7f), UInt8((number >> 7) & 0x7f), UInt8(number & 0x7f)] } +} + +enum ID3MetadataError: LocalizedError { + case verificationFailed(String) + case unsupportedCompressedV22 + var errorDescription: String? { + switch self { + case .verificationFailed(let file): "Die geschriebenen Tags in \(file) konnten nicht überprüft werden." + case .unsupportedCompressedV22: "Diese komprimierten ID3v2.2-Tags können nicht sicher bearbeitet werden. Die Datei wurde nicht geändert." + } + } +} diff --git a/Sources/TonuinoManager/Services/LibraryService.swift b/Sources/TonuinoManager/Services/LibraryService.swift new file mode 100644 index 0000000..480ec54 --- /dev/null +++ b/Sources/TonuinoManager/Services/LibraryService.swift @@ -0,0 +1,1154 @@ +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." + } + } +} diff --git a/Sources/TonuinoManager/Services/MediaToolLocator.swift b/Sources/TonuinoManager/Services/MediaToolLocator.swift new file mode 100644 index 0000000..4316fd2 --- /dev/null +++ b/Sources/TonuinoManager/Services/MediaToolLocator.swift @@ -0,0 +1,63 @@ +import Foundation + +enum MediaToolLocator { + static func executable(named name: String) -> URL? { + guard let bundled = Bundle.main.resourceURL?.appendingPathComponent(name), + FileManager.default.isExecutableFile(atPath: bundled.path) else { + return nil + } + return bundled + } + + static func javascriptRuntimeArguments() -> [String] { + guard let runtime = executable(named: "deno") else { return [] } + return ["--js-runtimes", "deno:\(runtime.path)"] + } +} + +enum MediaToolError: LocalizedError { + case missing(String) + case failed(String, String) + + var errorDescription: String? { + switch self { + case .missing(let tool): "\(tool) fehlt im App-Paket. Installiere eine App-Version mit gebündelten Werkzeugen." + case .failed(let tool, let detail): "\(tool) ist fehlgeschlagen: \(detail)" + } + } +} + +enum MediaProcess { + static func run(_ executable: URL, arguments: [String]) throws -> Data { + let id = UUID().uuidString + let outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("tonuino-tool-\(id).out") + let errorURL = FileManager.default.temporaryDirectory.appendingPathComponent("tonuino-tool-\(id).err") + FileManager.default.createFile(atPath: outputURL.path, contents: nil) + FileManager.default.createFile(atPath: errorURL.path, contents: nil) + defer { + try? FileManager.default.removeItem(at: outputURL) + try? FileManager.default.removeItem(at: errorURL) + } + + let outputHandle = try FileHandle(forWritingTo: outputURL) + let errorHandle = try FileHandle(forWritingTo: errorURL) + defer { try? outputHandle.close(); try? errorHandle.close() } + + let process = Process() + process.executableURL = executable + process.arguments = arguments + process.standardOutput = outputHandle + process.standardError = errorHandle + do { try process.run() } + catch { throw MediaToolError.failed(executable.lastPathComponent, error.localizedDescription) } + process.waitUntilExit() + + let output = try Data(contentsOf: outputURL) + let errorOutput = String(data: try Data(contentsOf: errorURL), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard process.terminationStatus == 0 else { + let detail = errorOutput.isEmpty ? "Prozesscode \(process.terminationStatus)" : String(errorOutput.suffix(1800)) + throw MediaToolError.failed(executable.lastPathComponent, detail) + } + return output + } +} diff --git a/Sources/TonuinoManager/Services/MusicBrainzService.swift b/Sources/TonuinoManager/Services/MusicBrainzService.swift new file mode 100644 index 0000000..30c38e8 --- /dev/null +++ b/Sources/TonuinoManager/Services/MusicBrainzService.swift @@ -0,0 +1,92 @@ +import Foundation + +struct MusicBrainzRelease: Decodable, Identifiable, Hashable { + let id: String + let title: String + let date: String? + let artistCredit: [ArtistCredit]? + let media: [Medium]? + let genres: [Genre]? + + var artist: String { artistCredit?.map { ($0.name ?? $0.artist?.name ?? "") + ($0.joinphrase ?? "") }.joined() ?? "" } + var year: String { String((date ?? "").prefix(4)) } + var genre: String { genres?.map(\.name).joined(separator: ", ") ?? "" } + var tracks: [MusicBrainzRecording] { media?.flatMap { medium in (medium.tracks ?? []).map { $0.withDisc(medium.position.map(String.init) ?? medium.number ?? "1") } } ?? [] } + + enum CodingKeys: String, CodingKey { case id, title, date, media, genres; case artistCredit = "artist-credit" } +} + +struct MusicBrainzRecording: Decodable, Identifiable, Hashable { + let recording: RecordingID? + let title: String + let number: String? + let position: Int? + let length: Int? + let artistCredit: [ArtistCredit]? + var id: String { recording?.id ?? "\(discNumber)-\(displayNumber)-\(title)" } + private(set) var discNumber = "1" + enum CodingKeys: String, CodingKey { case recording, title, number, position, length; case artistCredit = "artist-credit" } + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + recording = try values.decodeIfPresent(RecordingID.self, forKey: .recording) + title = try values.decode(String.self, forKey: .title) + number = try values.decodeIfPresent(String.self, forKey: .number) + position = try values.decodeIfPresent(Int.self, forKey: .position) + length = try values.decodeIfPresent(Int.self, forKey: .length) + artistCredit = try values.decodeIfPresent([ArtistCredit].self, forKey: .artistCredit) + } + var artist: String { artistCredit?.map { ($0.name ?? $0.artist?.name ?? "") + ($0.joinphrase ?? "") }.joined() ?? "" } + var displayNumber: String { number ?? position.map(String.init) ?? "—" } + func withDisc(_ value: String) -> MusicBrainzRecording { var copy = self; copy.discNumber = value; return copy } +} + +struct RecordingID: Decodable, Hashable { let id: String? } + +struct ArtistCredit: Decodable, Hashable { + let name: String? + let artist: CreditArtist? + let joinphrase: String? +} +struct CreditArtist: Decodable, Hashable { let name: String? } +struct Medium: Decodable, Hashable { let position: Int?; let number: String?; let tracks: [MusicBrainzRecording]? } +struct Genre: Decodable, Hashable { let name: String } + +enum MusicBrainzService { + private static let base = URL(string: "https://musicbrainz.org/ws/2")! + private static let session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 25 + configuration.httpAdditionalHeaders = ["User-Agent": "TonuinoManager/1.0 (macOS music library manager)"] + return URLSession(configuration: configuration) + }() + + static func searchReleases(artist: String, album: String) async throws -> [MusicBrainzRelease] { + await RateGate.shared.wait() + var components = URLComponents(url: base.appendingPathComponent("release"), resolvingAgainstBaseURL: false)! + let query = [album.isEmpty ? nil : "release:\"\(escape(album))\"", artist.isEmpty ? nil : "artist:\"\(escape(artist))\""].compactMap { $0 }.joined(separator: " AND ") + components.queryItems = [URLQueryItem(name: "query", value: query.isEmpty ? "*" : query), URLQueryItem(name: "limit", value: "15"), URLQueryItem(name: "fmt", value: "json")] + let (data, _) = try await session.data(from: components.url!) + return try JSONDecoder().decode(ReleaseSearch.self, from: data).releases + } + + static func loadRelease(id: String) async throws -> MusicBrainzRelease { + await RateGate.shared.wait() + var components = URLComponents(url: base.appendingPathComponent("release/\(id)"), resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "inc", value: "recordings+artist-credits+genres"), URLQueryItem(name: "fmt", value: "json")] + let (data, _) = try await session.data(from: components.url!) + return try JSONDecoder().decode(MusicBrainzRelease.self, from: data) + } + + private static func escape(_ value: String) -> String { value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") } + private struct ReleaseSearch: Decodable { let releases: [MusicBrainzRelease] } + + private actor RateGate { + static let shared = RateGate() + private var lastRequest = Date.distantPast + func wait() async { + let delay = max(0, 1.1 - Date().timeIntervalSince(lastRequest)) + lastRequest = Date().addingTimeInterval(delay) + if delay > 0 { try? await Task.sleep(for: .seconds(delay)) } + } + } +} diff --git a/Sources/TonuinoManager/Services/PCSCReader.swift b/Sources/TonuinoManager/Services/PCSCReader.swift new file mode 100644 index 0000000..9875dc8 --- /dev/null +++ b/Sources/TonuinoManager/Services/PCSCReader.swift @@ -0,0 +1,161 @@ +import CryptoTokenKit +import Combine +import Foundation + +@MainActor +final class CardModel: ObservableObject { + @Published private(set) var readerNames: [String] = [] + @Published var selectedReader = "" + @Published private(set) var cardStatus = "Kein Reader verbunden" + @Published private(set) var currentUID = "" + @Published private(set) var currentCard: TonuinoCard? + @Published private(set) var serialPorts = TonuinoSerialService.ports + @Published var selectedSerialPort = "" + @Published private(set) var serialConnected = false + @Published private(set) var firmwareVersion: String? + @Published private(set) var tonuinoSettings: TonuinoDeviceSettings? + @Published var writing = false + @Published var configuringSettings = false + @Published private(set) var serialAdminMenuActive = false + private let serial = TonuinoSerialService() + private let reader = PCSCReader() + + func refreshReaders() { readerNames = reader.slotNames; if !readerNames.contains(selectedReader) { selectedReader = readerNames.first ?? "" } } + func connectSerial() async throws { + guard !selectedSerialPort.isEmpty else { throw SerialError.disconnected } + let port = selectedSerialPort + let service = serial + let connection = try await Task.detached(priority: .userInitiated) { try service.connect(port) }.value + firmwareVersion = connection.firmwareVersion + tonuinoSettings = connection.settings + serialConnected = true + } + func disconnectSerial() { serial.disconnect(); serialConnected = false; firmwareVersion = nil; tonuinoSettings = nil; serialAdminMenuActive = false } + func refreshSerialPorts() { serialPorts = TonuinoSerialService.ports; if !serialPorts.contains(selectedSerialPort) { selectedSerialPort = serialPorts.first ?? "" } } + func writeViaTonuino(_ card: TonuinoCard) async throws { + guard serialConnected else { throw SerialError.disconnected }; writing = true + defer { writing = false } + let service = serial + try await Task.detached(priority: .userInitiated) { try await service.writeCard(card) }.value + serialAdminMenuActive = true + } + func cancelWrite() throws { try serial.cancelWrite(); serialAdminMenuActive = false } + func exitTonuinoAdminMenu() throws { try serial.exitAdminMenu(); serialAdminMenuActive = false } + func applyTonuinoSetting(menuItem: Int, option: Int, currentPIN: String = "", newPIN: String = "") async throws { + guard serialConnected else { throw SerialError.disconnected } + configuringSettings = true + defer { configuringSettings = false } + let service = serial + try await Task.detached(priority: .userInitiated) { + try await service.applyAdminSetting(menuItem: menuItem, option: option, currentPIN: currentPIN, newPIN: newPIN) + }.value + serialAdminMenuActive = true + } + func applyTonuinoVolume(minimum: Int, maximum: Int, initial: Int, currentPIN: String = "") async throws { + guard serialConnected else { throw SerialError.disconnected } + configuringSettings = true + defer { configuringSettings = false } + let service = serial + try await Task.detached(priority: .userInitiated) { + try await service.applyVolumeSettings(minimum: minimum, maximum: maximum, initial: initial, currentPIN: currentPIN) + }.value + serialAdminMenuActive = true + } + func resetTonuinoSettings(currentPIN: String = "") async throws { + guard serialConnected else { throw SerialError.disconnected } + configuringSettings = true + defer { configuringSettings = false } + let service = serial + try await Task.detached(priority: .userInitiated) { + try await service.resetAdminSettings(currentPIN: currentPIN) + }.value + serialAdminMenuActive = false + } + func readViaReader() async throws -> (String, TonuinoCard?) { try await reader.readCard(slotName: selectedReader) } + func writeViaReader(_ card: TonuinoCard) async throws { try await reader.writeCard(card, slotName: selectedReader) } + func eraseViaReader() async throws { try await reader.writeCard(nil, slotName: selectedReader) } +} + +final class PCSCReader { + private var manager: TKSmartCardSlotManager? { TKSmartCardSlotManager.default } + var slotNames: [String] { manager?.slotNames ?? [] } + + func readCard(slotName: String) async throws -> (String, TonuinoCard?) { + let (card, _) = try await connectedCard(slotName) + defer { card.endSession() } + let uidData = try await transmit(card, [0xFF, 0xCA, 0x00, 0x00, 0x00]) + let uid = uidData.map { String(format: "%02X", $0) }.joined() + let bytes = try await readTonuinoData(card) + return (uid, TonuinoCard.decode(bytes)) + } + + func writeCard(_ value: TonuinoCard?, slotName: String) async throws { + let (card, atr) = try await connectedCard(slotName) + defer { card.endSession() } + let type = CardType(atr: atr) + guard type != .unknown else { throw PCSCError.unsupportedCard } + let payload = value?.payload() ?? Data(repeating: 0, count: 16) + if type == .ultralight { + for page in 0..<4 { + let chunk = payload.subdata(in: page * 4..<(page + 1) * 4) + _ = try await transmit(card, [0xFF, 0xD6, 0x00, UInt8(8 + page), 0x04] + chunk) + } + } else { + try await authenticate(card, block: 4) + _ = try await transmit(card, [0xFF, 0xD6, 0x00, 0x04, 0x10] + payload) + } + } + + private func readTonuinoData(_ card: TKSmartCard) async throws -> Data { + let atr = Array(card.slot.atr?.bytes ?? Data()) + if CardType(atr: atr) == .ultralight { + return try await transmit(card, [0xFF, 0xB0, 0x00, 0x08, 0x10]) + } + try await authenticate(card, block: 4) + return try await transmit(card, [0xFF, 0xB0, 0x00, 0x04, 0x10]) + } + + private func authenticate(_ card: TKSmartCard, block: UInt8) async throws { + _ = try await transmit(card, [0xFF, 0x82, 0x00, 0x00, 0x06, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) + _ = try await transmit(card, [0xFF, 0x86, 0x00, 0x00, 0x05, 0x01, 0x00, block, 0x60, 0x00]) + } + + private func connectedCard(_ name: String) async throws -> (TKSmartCard, [UInt8]) { + guard let slot = manager?.slotNamed(name), let card = slot.makeSmartCard() else { throw PCSCError.noCard } + try await card.beginSession() + return (card, Array(slot.atr?.bytes ?? Data())) + } + + private func transmit(_ card: TKSmartCard, _ command: [UInt8]) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + card.transmit(Data(command)) { response, error in + if let error { continuation.resume(throwing: error); return } + guard let response, response.count >= 2 else { continuation.resume(throwing: PCSCError.communication); return } + let status = response.suffix(2) + guard status.elementsEqual([0x90, 0x00]) else { continuation.resume(throwing: PCSCError.status(UInt8(status.first ?? 0), UInt8(status.last ?? 0))); return } + continuation.resume(returning: response.dropLast(2)) + } + } + } +} + +enum CardType: Equatable { + case classic, ultralight, unknown + init(atr: [UInt8]) { + let marker: [UInt8] = [0xA0, 0x00, 0x00, 0x03, 0x06, 0x03, 0x00] + guard let i = atr.indices.dropLast(marker.count).first(where: { Array(atr[$0..<$0 + marker.count]) == marker }), atr.count > i + marker.count else { self = .unknown; return } + switch atr[i + marker.count] { case 0x01, 0x02, 0x26: self = .classic; case 0x03: self = .ultralight; default: self = .unknown } + } +} + +enum PCSCError: LocalizedError { + case noCard, unsupportedCard, communication, status(UInt8, UInt8) + var errorDescription: String? { + switch self { + case .noCard: "Lege eine Karte auf den ausgewählten Reader." + case .unsupportedCard: "Dieser Kartentyp wird nicht unterstützt." + case .communication: "Keine gültige Antwort vom Kartenleser." + case .status(let a, let b): "Kartenleser meldet Fehlerstatus %02X%02X.".replacingOccurrences(of: "%02X%02X", with: String(format: "%02X%02X", a, b)) + } + } +} diff --git a/Sources/TonuinoManager/Services/TonuinoSerialService.swift b/Sources/TonuinoManager/Services/TonuinoSerialService.swift new file mode 100644 index 0000000..63753d9 --- /dev/null +++ b/Sources/TonuinoManager/Services/TonuinoSerialService.swift @@ -0,0 +1,303 @@ +import Darwin +import Foundation + +final class TonuinoSerialService: @unchecked Sendable { + private let lock = NSLock() + private var descriptor: Int32 = -1 + private var buffer = Data() + private var outputMonitor: Task? + private(set) var firmwareVersion: String? + static var ports: [String] { + ((try? FileManager.default.contentsOfDirectory(atPath: "/dev")) ?? []) + .filter { $0.hasPrefix("cu.") }.map { "/dev/\($0)" }.sorted() + } + var isConnected: Bool { lock.lock(); defer { lock.unlock() }; return descriptor >= 0 } + + func connect(_ path: String) throws -> (firmwareVersion: String?, settings: TonuinoDeviceSettings?) { + disconnect() + let fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK) + guard fd >= 0 else { throw SerialError.open(path, String(cString: strerror(errno))) } + var termiosConfig = termios() + guard tcgetattr(fd, &termiosConfig) == 0 else { close(fd); throw SerialError.configure } + cfmakeraw(&termiosConfig); cfsetispeed(&termiosConfig, speed_t(B115200)); cfsetospeed(&termiosConfig, speed_t(B115200)) + termiosConfig.c_cflag |= tcflag_t(CLOCAL | CREAD) + termiosConfig.c_cflag &= ~tcflag_t(CSTOPB | PARENB | CSIZE) + termiosConfig.c_cflag |= tcflag_t(CS8) + guard tcsetattr(fd, TCSANOW, &termiosConfig) == 0 else { close(fd); throw SerialError.configure } + tcflush(fd, TCIOFLUSH) + lock.lock(); descriptor = fd; buffer.removeAll(); lock.unlock() + firmwareVersion = nil + var parsedSettings = TonuinoDeviceSettings() + let startupDeadline = Date().addingTimeInterval(3) // many TonUINO boards reset when serial is opened + while Date() < startupDeadline { + let remaining = startupDeadline.timeIntervalSinceNow + guard let line = try readLine(timeout: min(0.15, max(remaining, 0))) else { continue } + if let range = line.range(of: #"V\d+\.\d+\.\d+[^\r\n]*"#, options: .regularExpression) { + firmwareVersion = String(line[range]) + } + parsedSettings.readLogLine(line) + } + let capturedSettings = parsedSettings.hasValues ? parsedSettings : nil + outputMonitor = Task.detached(priority: .utility) { [weak self] in + while !Task.isCancelled { + guard let self else { return } + do { _ = try self.readLine(timeout: 0.3) } + catch { return } + } + } + return (firmwareVersion, capturedSettings) + } + + func disconnect() { + outputMonitor?.cancel() + outputMonitor = nil + lock.lock(); let old = descriptor; descriptor = -1; buffer.removeAll(); lock.unlock() + if old >= 0 { close(old) } + } + + func writeCard(_ card: TonuinoCard) async throws { + try card.validate() + guard card.modifier == nil else { + throw SerialError.invalid("Sonderkarten können mit diesem seriellen Schreibassistenten nicht programmiert werden. Verwende einen PC/SC-Reader oder exportiere einen Dump.") + } + // TonUINO-TNG has no WRITECARD serial command. With SerialInputAsCommand + // enabled, its documented numeric commands can drive the built-in + // Admin > New card voice menu instead. + try write("-4\n") // allLong: open the admin menu while TonUINO is idle + try await Task.sleep(for: .milliseconds(500)) + try await selectMenuItem(1) // Admin: create new card + let mode = card.mode == .admin ? 6 : card.mode.rawValue + try await selectMenuItem(mode) + + if card.mode.needsFolder, ![.admin, .repeatLast, .bluetooth].contains(card.mode) { + try await selectMenuItem(Int(card.folder)) + } + + switch card.mode { + case .single: + try await selectMenuItem(Int(card.special)) + case .hoerspielRange, .albumRange, .partyRange, .audiobookRange: + try await selectMenuItem(Int(card.special)) + try await selectMenuItem(Int(card.special2)) + case .audiobook: + try await selectMenuItem(Int(card.folder) + Int(card.special2)) + case .audiobookSingle: + try await selectMenuItem(Int(card.folder) + Int(card.special2)) + try await selectMenuItem(Int(card.special) + 1) + case .quiz: + let option: Int + switch (card.special, card.special2) { + case (2, 0): option = 1 + case (4, 0): option = 2 + case (2, 1): option = 3 + case (4, 1): option = 4 + case (0, 1): option = 5 + default: throw SerialError.invalid("Ungültige Quiz-Optionen.") + } + try await selectMenuItem(option) + default: + break + } + } + + func cancelWrite() throws { try exitAdminMenu() } + func exitAdminMenu() throws { try write("-6\n") } + + func applyAdminSetting(menuItem: Int, option: Int, currentPIN: String = "", newPIN: String = "") async throws { + guard (2...14).contains(menuItem), (1...99).contains(option) else { + throw SerialError.invalid("Ungültige TonUINO-Menüauswahl.") + } + if menuItem == 12, option == 3 { try validatePIN(newPIN) } + try await enterAdmin(currentPIN: currentPIN) + try await selectMenuItem(menuItem) + try await selectMenuItem(option) + if menuItem == 12, option == 3 { + try await sendPIN(newPIN) + } + } + + func applyVolumeSettings(minimum: Int, maximum: Int, initial: Int, currentPIN: String = "") async throws { + guard (1...29).contains(minimum), (2...30).contains(maximum), minimum < maximum, + (minimum...maximum).contains(initial) else { + throw SerialError.invalid("Lautstärke: Minimum muss unter dem Maximum liegen; Startlautstärke muss dazwischen liegen.") + } + + try await enterAdmin(currentPIN: currentPIN) + // Set a known intermediate range so subsequent voice-menu indices are deterministic. + try await selectMenuItem(3) // Minimum volume + try await selectMenuItem(1) + try await selectMenuItem(2) // Maximum volume + try await selectMenuItem(29) // 30, now that minimum is 1 + try await selectMenuItem(3) + try await selectMenuItem(minimum) + try await selectMenuItem(2) + try await selectMenuItem(maximum - minimum) + try await selectMenuItem(4) + try await selectMenuItem(initial - minimum + 1) + } + + func resetAdminSettings(currentPIN: String = "") async throws { + try await enterAdmin(currentPIN: currentPIN) + try await selectMenuItem(11) + // The reset state consumes one command to return to Admin_Entry. + try await Task.sleep(for: .milliseconds(250)) + try write("-6\n") + try await Task.sleep(for: .milliseconds(250)) + try write("-6\n") + try await Task.sleep(for: .milliseconds(250)) + } + + private func enterAdmin(currentPIN: String) async throws { + if !currentPIN.isEmpty { try validatePIN(currentPIN) } + try write("-4\n") // allLong: enter Admin while idle + try await Task.sleep(for: .milliseconds(500)) + if !currentPIN.isEmpty { try await sendPIN(currentPIN) } + } + + private func sendPIN(_ pin: String) async throws { + let commands: [Character: Int] = ["1": -5, "2": -8, "3": -2] + for digit in pin { + try Task.checkCancellation() + try write("\(commands[digit]!)\n") + try await Task.sleep(for: .milliseconds(220)) + } + try await Task.sleep(for: .milliseconds(250)) + } + + private func validatePIN(_ pin: String) throws { + guard pin.count == 4, pin.allSatisfy({ ["1", "2", "3"].contains(String($0)) }) else { + throw SerialError.invalid("Die TonUINO-PIN muss aus vier Ziffern 1, 2 oder 3 bestehen.") + } + } + + private func selectMenuItem(_ value: Int) async throws { + try Task.checkCancellation() + try write("\(value)\n") + try await Task.sleep(for: .milliseconds(180)) + try write("-5\n") // pause: select the current voice-menu item + try await Task.sleep(for: .milliseconds(250)) + } + + private func write(_ text: String) throws { + lock.lock(); defer { lock.unlock() } + guard descriptor >= 0 else { throw SerialError.disconnected } + let bytes = Array(text.utf8) + let result = Darwin.write(descriptor, bytes, bytes.count) + guard result == bytes.count else { throw SerialError.io(String(cString: strerror(errno))) } + } + + private func readLine(timeout: TimeInterval) throws -> String? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + lock.lock(); let fd = descriptor; lock.unlock() + guard fd >= 0 else { throw SerialError.disconnected } + var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + if Darwin.poll(&pollDescriptor, 1, 100) > 0 { + var bytes = [UInt8](repeating: 0, count: 256) + let count = Darwin.read(fd, &bytes, bytes.count) + if count > 0 { + lock.lock(); buffer.append(contentsOf: bytes.prefix(count)) + if let newline = buffer.firstIndex(of: 10) { + let line = Data(buffer[.. [Int]? { + guard let range = line.range(of: NSRegularExpression.escapedPattern(for: marker), options: [.regularExpression, .caseInsensitive]) else { return nil } + let remainder = line[range.upperBound...] + let text = String(remainder) + let pattern = #"-?\d+"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let nsRange = NSRange(text.startIndex..., in: text) + return regex.matches(in: text, range: nsRange).compactMap { match in + guard let matchRange = Range(match.range, in: text) else { return nil } + return Int(text[matchRange]) + } + } +} + +extension TonuinoCard { + func validate() throws { + if let modifier { + switch modifier { + case .sleepTimer: + guard special > 0 else { throw SerialError.invalid("Der Einschlaf-Timer muss mindestens eine Minute dauern.") } + case .freezeDance, .fireWaterAir: + guard special <= 2 else { throw SerialError.invalid("Die Pausenstufe muss zwischen 0 und 2 liegen.") } + default: + break + } + return + } + if mode.needsFolder, mode != .admin, !(1...99).contains(Int(folder)) { throw SerialError.invalid("Ordner muss zwischen 01 und 99 liegen.") } + if mode == .single && special == 0 { throw SerialError.invalid("Bitte einen Track größer 0 auswählen.") } + if mode == .audiobookSingle && special >= 30 { throw SerialError.invalid("Hörbuch einzeln akzeptiert maximal 29 Zusatztracks.") } + if [.hoerspielRange, .albumRange, .partyRange, .audiobookRange].contains(mode), special == 0 || special > special2 { + throw SerialError.invalid("Der Trackbereich muss von 1 bis zum Endtrack reichen.") + } + if mode == .quiz && !([0, 2, 4].contains(Int(special)) && [0, 1].contains(Int(special2))) { throw SerialError.invalid("Ungültige Quiz-Optionen.") } + } +} + +enum SerialError: LocalizedError { + case open(String, String), configure, disconnected, io(String), timeout, firmware(String), invalid(String) + var errorDescription: String? { + switch self { + case .open(let p, let e): "Serieller Port \(p) kann nicht geöffnet werden: \(e)" + case .configure: "Der serielle Port konnte nicht auf 115200 Baud konfiguriert werden." + case .disconnected: "Keine Verbindung zum TonUINO." + case .io(let e): "Serielle Kommunikation fehlgeschlagen: \(e)" + case .timeout: "Zeitüberschreitung. Prüfe, ob keine Karte auf dem TonUINO-Leser liegt und die Firmware SerialInputAsCommand aktiviert hat." + case .firmware(let text): text + case .invalid(let text): text + } + } +} diff --git a/Sources/TonuinoManager/Services/YouTubeImportService.swift b/Sources/TonuinoManager/Services/YouTubeImportService.swift new file mode 100644 index 0000000..5a0d72a --- /dev/null +++ b/Sources/TonuinoManager/Services/YouTubeImportService.swift @@ -0,0 +1,133 @@ +import Foundation + +struct YouTubeChapter: Decodable, Identifiable, Sendable { + let start: Double + let end: Double? + let title: String? + var id: Int { Int(start * 1000) } + + enum CodingKeys: String, CodingKey { case title; case start = "start_time"; case end = "end_time" } +} + +struct YouTubeVideoInfo: Decodable, Sendable { + let id: String + let webpageURL: String? + let title: String + let uploader: String? + let channel: String? + let duration: Double? + let chapters: [YouTubeChapter]? + + enum CodingKeys: String, CodingKey { + case id, title, uploader, channel, duration, chapters + case webpageURL = "webpage_url" + } + + var artist: String { channel?.isEmpty == false ? channel! : (uploader ?? "") } + var usableChapters: [YouTubeChapter] { + guard let chapters else { return [] } + return chapters.filter { $0.start.isFinite && $0.start >= 0 && ($0.end ?? duration ?? 0) > $0.start } + } +} + +struct PreparedYouTubeImport: Sendable { + let directory: URL + let files: [URL] +} + +enum YouTubeImportService { + static func inspect(_ rawURL: String) throws -> YouTubeVideoInfo { + guard let url = validatedURL(rawURL) else { throw YouTubeImportError.invalidURL } + guard let ytDLP = MediaToolLocator.executable(named: "yt-dlp") else { + throw MediaToolError.missing("yt-dlp") + } + guard MediaToolLocator.executable(named: "deno") != nil else { throw MediaToolError.missing("Deno") } + let arguments = ["--dump-single-json", "--skip-download", "--no-playlist", "--no-warnings", "--quiet", "--no-cache-dir"] + + MediaToolLocator.javascriptRuntimeArguments() + [url.absoluteString] + let data = try MediaProcess.run(ytDLP, arguments: arguments) + do { return try JSONDecoder().decode(YouTubeVideoInfo.self, from: data) } + catch { throw YouTubeImportError.invalidVideoInfo(error.localizedDescription) } + } + + static func prepare(_ video: YouTubeVideoInfo, splitByChapters: Bool) throws -> PreparedYouTubeImport { + guard let sourceURL = validatedURL(video.webpageURL ?? "https://www.youtube.com/watch?v=\(video.id)") else { throw YouTubeImportError.invalidURL } + guard let ytDLP = MediaToolLocator.executable(named: "yt-dlp") else { throw MediaToolError.missing("yt-dlp") } + guard let ffmpeg = MediaToolLocator.executable(named: "ffmpeg") else { throw MediaToolError.missing("FFmpeg") } + guard MediaToolLocator.executable(named: "deno") != nil else { throw MediaToolError.missing("Deno") } + let chapters = video.usableChapters + guard !splitByChapters || !chapters.isEmpty else { throw YouTubeImportError.noChapters } + + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("tonuino-youtube-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + do { + let outputTemplate = directory.appendingPathComponent("source.%(ext)s").path + let downloadArguments = ["--format", "bestaudio/best", "--output", outputTemplate, "--no-playlist", "--no-warnings", "--no-progress", "--quiet", "--no-cache-dir"] + + MediaToolLocator.javascriptRuntimeArguments() + [sourceURL.absoluteString] + _ = try MediaProcess.run(ytDLP, arguments: downloadArguments) + + let sourceFiles = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) + .filter { $0.lastPathComponent.hasPrefix("source.") && !$0.pathExtension.lowercased().hasSuffix("part") } + guard let audioURL = sourceFiles.first else { throw YouTubeImportError.downloadOutputMissing } + + let outputs: [URL] + if splitByChapters { + outputs = try chapters.enumerated().map { index, chapter in + let end = chapter.end ?? video.duration ?? 0 + let length = end - chapter.start + guard length > 0 else { throw YouTubeImportError.invalidChapter } + let output = directory.appendingPathComponent(String(format: "chapter-%03d.mp3", index + 1)) + let displayTitle = chapter.title?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "Kapitel \(index + 1)" + let args = [ + "-nostdin", "-hide_banner", "-loglevel", "error", "-y", + "-ss", String(format: "%.3f", chapter.start), "-i", audioURL.path, "-t", String(format: "%.3f", length), + "-map", "0:a:0", "-vn", "-codec:a", "libmp3lame", "-q:a", "2", "-id3v2_version", "3", + "-metadata", "title=\(displayTitle)", "-metadata", "artist=\(video.artist)", "-metadata", "album=\(video.title)", + "-metadata", "track=\(index + 1)/\(chapters.count)", output.path + ] + _ = try MediaProcess.run(ffmpeg, arguments: args) + return output + } + } else { + let output = directory.appendingPathComponent("audio.mp3") + let args = [ + "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", audioURL.path, + "-map", "0:a:0", "-vn", "-codec:a", "libmp3lame", "-q:a", "2", "-id3v2_version", "3", + "-metadata", "title=\(video.title)", "-metadata", "artist=\(video.artist)", "-metadata", "album=\(video.title)", output.path + ] + _ = try MediaProcess.run(ffmpeg, arguments: args) + outputs = [output] + } + return PreparedYouTubeImport(directory: directory, files: outputs) + } catch { + try? FileManager.default.removeItem(at: directory) + throw error + } + } + + private static func validatedURL(_ raw: String) -> URL? { + guard let components = URLComponents(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)), + components.scheme?.lowercased() == "https", + let host = components.host?.lowercased(), + ["youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be", "www.youtube-nocookie.com"].contains(host), + let url = components.url else { return nil } + return url + } +} + +private extension String { + var nonEmpty: String? { isEmpty ? nil : self } +} + +enum YouTubeImportError: LocalizedError { + case invalidURL, invalidVideoInfo(String), noChapters, invalidChapter, downloadOutputMissing + + var errorDescription: String? { + switch self { + case .invalidURL: "Bitte eine HTTPS-URL zu einem einzelnen YouTube-Video eingeben. Playlists werden nicht importiert." + case .invalidVideoInfo(let detail): "Videoinformationen konnten nicht gelesen werden: \(detail)" + case .noChapters: "Dieses Video enthält keine verwendbaren Kapitel. Importiere es als eine Datei." + case .invalidChapter: "Mindestens ein Kapitel hat eine ungültige Zeitspanne." + case .downloadOutputMissing: "yt-dlp hat keine Audiodatei erzeugt." + } + } +} diff --git a/Sources/TonuinoManager/TonuinoManager.entitlements b/Sources/TonuinoManager/TonuinoManager.entitlements new file mode 100644 index 0000000..1fdef2f --- /dev/null +++ b/Sources/TonuinoManager/TonuinoManager.entitlements @@ -0,0 +1,5 @@ + + + + com.apple.security.smartcard + diff --git a/Sources/TonuinoManager/UI/AcoustIDTaggingView.swift b/Sources/TonuinoManager/UI/AcoustIDTaggingView.swift new file mode 100644 index 0000000..66fc824 --- /dev/null +++ b/Sources/TonuinoManager/UI/AcoustIDTaggingView.swift @@ -0,0 +1,224 @@ +import SwiftUI + +private struct AcoustIDTrackResult: Identifiable { + let track: MusicTrack + var matches: [AcoustIDMatch] = [] + var selectedMatchID = "" + var selectedReleaseID = "" + var status = "Bereit" + var id: String { track.id } + var selectedMatch: AcoustIDMatch? { matches.first { $0.id == selectedMatchID } } +} + +struct AcoustIDTaggingView: View { + let tracks: [MusicTrack] + let onComplete: () -> Void + @Environment(\.dismiss) private var dismiss + @State private var rows: [AcoustIDTrackResult] + @State private var isIdentifying = false + @State private var isWriting = false + @State private var progressText = "" + @State private var message: String? + + init(tracks: [MusicTrack], onComplete: @escaping () -> Void) { + self.tracks = tracks + self.onComplete = onComplete + _rows = State(initialValue: tracks.map { AcoustIDTrackResult(track: $0) }) + } + + private var selectedCount: Int { + rows.filter { $0.selectedMatch != nil }.count + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Musik mit AcoustID identifizieren").font(.title2.bold()) + Text("\(tracks.count) Datei\(tracks.count == 1 ? "" : "en") · Treffer werden vor dem Schreiben geprüft") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary).font(.title2) }.buttonStyle(.plain) + } + + if isIdentifying || isWriting { + ProgressView(progressText).frame(maxWidth: .infinity, alignment: .leading) + } + if let message { Text(message).font(.callout).foregroundStyle(.secondary) } + + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(rows.indices, id: \.self) { index in + trackResult(at: index) + } + } + } + .frame(minHeight: 240) + + HStack { + Text("Fingerabdrücke werden mit Chromaprint berechnet. AcoustID liefert mögliche MusicBrainz-Aufnahmen.") + .font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading) + Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Fingerabdrücke suchen", systemImage: "waveform") { identifyTracks() } + .disabled(isIdentifying || isWriting || tracks.isEmpty || AcoustIDCredentials.loadClientKey() == nil) + Button("Ausgewählte Treffer schreiben", systemImage: "tag") { writeTags() } + .buttonStyle(.borderedProminent) + .disabled(isIdentifying || isWriting || selectedCount == 0) + } + } + .padding(22) + .frame(width: 920, height: 680) + .onAppear { + if AcoustIDCredentials.loadClientKey() == nil { + message = "Trage einen AcoustID-Anwendungsschlüssel in den Einstellungen ein, bevor du die Suche startest." + } + } + } + + @ViewBuilder + private func trackResult(at index: Int) -> some View { + let row = rows[index] + VStack(alignment: .leading, spacing: 7) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(row.track.url.lastPathComponent).font(.subheadline.weight(.medium)).lineLimit(1) + Text(row.status).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if !row.matches.isEmpty { + Picker("Aufnahme", selection: matchBinding(at: index)) { + Text("Nicht taggen").tag("") + ForEach(row.matches) { match in + Text("\(Int((match.score * 100).rounded()))% · \(match.artist.isEmpty ? "Unbekannter Interpret" : match.artist) — \(match.title)") + .tag(match.id) + } + } + .frame(width: 470) + } + } + if let match = row.selectedMatch { + HStack { + Text(String(format: "Übereinstimmung %.0f%%", match.score * 100)) + .font(.caption.monospacedDigit()).foregroundStyle(.secondary) + if !match.releases.isEmpty { + Picker("Veröffentlichung", selection: releaseBinding(at: index)) { + ForEach(match.releases) { release in + Text("\(release.title)\(release.year.isEmpty ? "" : " · \(release.year)")") + .tag(release.id) + } + } + .frame(maxWidth: .infinity) + } else { + Text("Kein verknüpftes Album").font(.caption).foregroundStyle(.secondary) + } + } + } + } + .padding(11) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 8)) + } + + private func matchBinding(at index: Int) -> Binding { + Binding( + get: { rows[index].selectedMatchID }, + set: { value in + rows[index].selectedMatchID = value + rows[index].selectedReleaseID = rows[index].selectedMatch?.releases.first?.id ?? "" + } + ) + } + + private func releaseBinding(at index: Int) -> Binding { + Binding(get: { rows[index].selectedReleaseID }, set: { rows[index].selectedReleaseID = $0 }) + } + + private func identifyTracks() { + guard let clientKey = AcoustIDCredentials.loadClientKey() else { + message = "Trage einen AcoustID-Anwendungsschlüssel in den Einstellungen ein." + return + } + isIdentifying = true + message = nil + Task { + for index in rows.indices { + progressText = "Analysiere Datei \(index + 1) von \(rows.count)…" + rows[index].status = "Fingerabdruck und Datenbanksuche laufen…" + do { + let matches = try await AcoustIDService.identify(rows[index].track.url, clientKey: clientKey) + rows[index].matches = matches + rows[index].selectedMatchID = matches.first?.id ?? "" + rows[index].selectedReleaseID = matches.first?.releases.first?.id ?? "" + rows[index].status = matches.isEmpty ? "Kein AcoustID-Treffer" : "\(matches.count) Treffer · besten Treffer vorausgewählt" + } catch { + rows[index].matches = [] + rows[index].status = "Fehler: \(error.localizedDescription)" + } + } + isIdentifying = false + progressText = "" + let found = rows.filter { !$0.matches.isEmpty }.count + message = "Suche abgeschlossen: Treffer für \(found) von \(rows.count) Dateien. Prüfe die Zuordnung und wähle, was geschrieben werden soll." + } + } + + private func writeTags() { + isWriting = true + message = nil + Task { + var written = 0 + var failures: [String] = [] + for index in rows.indices { + guard let match = rows[index].selectedMatch else { continue } + let track = rows[index].track + progressText = "Schreibe Datei \(index + 1) von \(rows.count)…" + do { + var metadata = TrackMetadata() + metadata.title = match.title + metadata.artist = match.artist + metadata.acoustID = match.acoustID + metadata.musicBrainzRecordingID = match.recordingID + metadata.musicBrainzReleaseID = "" + + if let releaseOption = match.releases.first(where: { $0.id == rows[index].selectedReleaseID }) { + let release = try await MusicBrainzService.loadRelease(id: releaseOption.id) + metadata.album = release.title + metadata.albumArtist = release.artist.isEmpty ? releaseOption.artist : release.artist + metadata.year = release.year.isEmpty ? releaseOption.year : release.year + metadata.genre = release.genre + metadata.musicBrainzReleaseID = release.id + if let recording = release.tracks.first(where: { $0.recording?.id == match.recordingID }) { + metadata.title = recording.title + metadata.artist = recording.artist.isEmpty ? (release.artist.isEmpty ? match.artist : release.artist) : recording.artist + metadata.trackNumber = recording.displayNumber + metadata.discNumber = recording.discNumber + } + } else { + metadata.album = "" + metadata.albumArtist = "" + metadata.trackNumber = "" + metadata.discNumber = "" + metadata.year = "" + metadata.genre = "" + } + + try await Task.detached(priority: .userInitiated) { + try ID3Metadata.write(metadata, to: track.url) + try ID3Metadata.verify(metadata, at: track.url) + }.value + rows[index].status = "Tags geschrieben und überprüft" + written += 1 + } catch { + rows[index].status = "Fehler beim Schreiben: \(error.localizedDescription)" + failures.append(track.url.lastPathComponent) + } + } + isWriting = false + progressText = "" + onComplete() + message = failures.isEmpty + ? "\(written) Datei\(written == 1 ? " wurde" : "en wurden") getaggt und überprüft." + : "\(written) Dateien getaggt. Fehler bei: \(failures.joined(separator: ", "))." + } + } +} diff --git a/Sources/TonuinoManager/UI/CardsView.swift b/Sources/TonuinoManager/UI/CardsView.swift new file mode 100644 index 0000000..1245fea --- /dev/null +++ b/Sources/TonuinoManager/UI/CardsView.swift @@ -0,0 +1,282 @@ +import AppKit +import SwiftUI + +struct CardsView: View { + @EnvironmentObject private var app: AppModel + @State private var route = CardRoute.tonuino + @State private var folder = 1 + @State private var mode: PlaybackMode = .album + @State private var category: CardEditorCategory = .playback + @State private var modifier: TonuinoModifier = .sleepTimer + @State private var special = 1 + @State private var special2 = 1 + @State private var sleepTimerMinutes = 30 + @State private var stopAfterCurrentTrack = false + @State private var danceDifficulty = 1 + @State private var busy = false + @State private var tonuinoWritePending = false + @State private var lastRead: TonuinoCard? + @State private var lastUID = "" + @AppStorage("reader.route") private var defaultRoute = "tonuino" + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 5) { + Text("NFC-Karten").font(.largeTitle.bold()) + Text("Ordnerkarten, Wiedergabemodi und Admin-Karten programmieren oder auslesen.").foregroundStyle(.secondary) + }.padding() + Divider() + Form { + Section("Programmierweg") { + Picker("Kartenleser", selection: $route) { ForEach(CardRoute.allCases) { Text($0.title).tag($0) } } + .pickerStyle(.segmented) + if route == .tonuino { serialControls } + else { readerControls } + } + Section("Karteninhalt") { + Picker("Kategorie", selection: $category) { + ForEach(CardEditorCategory.allCases) { Text($0.title).tag($0) } + } + if category == .playback { + Picker("Kartenart", selection: $mode) { ForEach(PlaybackMode.allCases) { Text($0.title).tag($0) } } + if mode.needsFolder && mode != .admin { + if let library = app.library.folders.first(where: { $0.number == folder }) { + Picker("Ordner", selection: $folder) { + ForEach(app.library.folders) { item in Text("\(String(format: "%02d", item.number)) — \(item.title)").tag(item.number) } + } + .onAppear { if !app.library.folders.contains(where: { $0.number == folder }) { folder = library.number } } + } else { + Stepper("Ordner \(String(format: "%02d", folder))", value: $folder, in: 1...99) + } + } + if mode.needsSpecial && mode != .quiz { Stepper(specialLabel, value: $special, in: mode == .audiobookSingle ? 0...29 : 1...255) } + if mode.needsSpecial2 { + if mode == .quiz { + Picker("Fragen", selection: $special) { Text("Alle").tag(0); Text("2 Fragen").tag(2); Text("4 Fragen").tag(4) } + Picker("Antworten pro Frage", selection: $special2) { Text("2").tag(0); Text("3").tag(1) } + } else { Stepper("Endtrack: \(special2)", value: $special2, in: max(1, special)...255) } + } + Text("\(mode.title)" + (mode.needsFolder ? " · Ordner \(String(format: "%02d", folder))" : "")) + .font(.caption).foregroundStyle(.secondary) + } else { + Picker("Sonderkarte", selection: $modifier) { + ForEach(TonuinoModifier.allCases) { Text($0.title).tag($0) } + } + switch modifier { + case .sleepTimer: + Stepper("Laufzeit: \(sleepTimerMinutes) Minuten", value: $sleepTimerMinutes, + in: 1...(stopAfterCurrentTrack ? 127 : 255)) + Toggle("Nach dem aktuellen Track stoppen", isOn: $stopAfterCurrentTrack) + case .freezeDance, .fireWaterAir: + Picker("Pausenstufe", selection: $danceDifficulty) { + Text("1").tag(0); Text("2").tag(1); Text("3").tag(2) + } + default: + EmptyView() + } + if let note = modifier.firmwareNote { + Label(note, systemImage: "info.circle") + .font(.caption).foregroundStyle(.secondary) + } + Text(modifier.title + " · Ordnernummer 00") + .font(.caption).foregroundStyle(.secondary) + } + } + Section("Karte") { + HStack { + Label(lastUID.isEmpty ? "Keine Karte gelesen" : "UID \(lastUID)", systemImage: "wave.3.right") + Spacer() + if !lastUID.isEmpty { + Text(lastRead.map { $0.displayTitle + ($0.folder > 0 ? " · Ordner \(String(format: "%02d", $0.folder))" : "") } ?? "Unprogrammierte Karte") + .foregroundStyle(.secondary) + } + } + HStack { + Button("Karte lesen", systemImage: "doc.text.magnifyingglass") { Task { await readCard() } }.disabled(busy || route != .acr122u) + Button("Programmieren", systemImage: "wave.3.right", role: .none) { Task { await writeCard() } } + .buttonStyle(.borderedProminent).disabled(busy || tonuinoWritePending || !isRouteReady || (category == .modifier && route == .tonuino)) + Button("Dump exportieren…", systemImage: "square.and.arrow.up") { exportDump() } + .disabled(busy || tonuinoWritePending) + if tonuinoWritePending { + Button("Abbrechen", role: .cancel) { + do { try app.cards.cancelWrite(); tonuinoWritePending = false } + catch { app.report(error) } + } + Button("Fertig") { + do { try app.cards.cancelWrite(); tonuinoWritePending = false } + catch { app.report(error) } + } + } + if busy && route == .tonuino { + Button("Abbrechen", role: .cancel) { do { try app.cards.cancelWrite() } catch { app.report(error) } } + } + if route == .acr122u { + Button("Karte löschen", systemImage: "eraser", role: .destructive) { Task { await eraseCard() } }.disabled(busy || !isRouteReady) + } + if busy { ProgressView().controlSize(.small); Text("TonUINO-Menü wird vorbereitet…").foregroundStyle(.secondary) } + else if tonuinoWritePending { Label("Karte auf den TonUINO-Leser legen", systemImage: "wave.3.right").foregroundStyle(.secondary) } + } + if category == .modifier && route == .tonuino { + Label("Der serielle TonUINO-Menüassistent kann diese Sonderkarte nicht anlegen. Nutze einen ACR122U-Reader oder exportiere einen Dump.", systemImage: "info.circle") + .font(.caption).foregroundStyle(.secondary) + } + } + }.formStyle(.grouped).scrollContentBackground(.hidden) + Spacer(minLength: 0) + Text(route == .tonuino + ? "TonUINO muss im Leerlauf sein. Für USB-Steuerung muss SerialInputAsCommand in der Firmware aktiviert sein. Folge den Audioansagen und lege die Karte auf, wenn TonUINO dazu auffordert." + : "ACR122U unterstützt MIFARE Mini, Classic 1K/4K und Ultralight/NTAG21x.") + .font(.caption).foregroundStyle(.secondary).padding() + } + .onAppear { + route = defaultRoute == "acr122u" ? .acr122u : .tonuino + app.cards.refreshReaders(); app.cards.refreshSerialPorts() + } + .onChange(of: special) { newValue in + if mode != .quiz, mode.needsSpecial2, special2 < newValue { special2 = newValue } + } + .onChange(of: stopAfterCurrentTrack) { enabled in + if enabled { sleepTimerMinutes = min(sleepTimerMinutes, 127) } + } + .task(id: route) { + guard route == .acr122u else { return } + while !Task.isCancelled { + if !busy { + do { let (uid, card) = try await app.cards.readViaReader(); lastUID = uid; lastRead = card } + catch { lastUID = ""; lastRead = nil } + } + try? await Task.sleep(nanoseconds: 1_500_000_000) + } + } + } + + private var isRouteReady: Bool { route == .tonuino ? app.cards.serialConnected : !app.cards.selectedReader.isEmpty } + private var specialLabel: String { mode == .single ? "Tracknummer: \(special)" : mode == .audiobookSingle ? "Zusatztracks: \(special)" : "Starttrack: \(special)" } + + private var serialControls: some View { + HStack { + Picker("Port", selection: $app.cards.selectedSerialPort) { ForEach(app.cards.serialPorts, id: \.self) { Text($0).tag($0) } } + Button("Aktualisieren", systemImage: "arrow.clockwise") { app.cards.refreshSerialPorts() } + if app.cards.serialConnected { + Label(app.cards.firmwareVersion.map { "Verbunden · \($0)" } ?? "Verbunden · Firmwareversion nicht erkannt", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + Button("Trennen") { app.cards.disconnectSerial() } + } else { + Button("Verbinden") { Task { do { try await app.cards.connectSerial() } catch { app.report(error) } } }.disabled(app.cards.selectedSerialPort.isEmpty) + } + } + } + + private var readerControls: some View { + HStack { + Picker("Reader", selection: $app.cards.selectedReader) { ForEach(app.cards.readerNames, id: \.self) { Text($0).tag($0) } } + Button("Reader suchen", systemImage: "arrow.clockwise") { app.cards.refreshReaders() } + Text(app.cards.readerNames.isEmpty ? "Kein Smartcard-Reader gefunden" : "Reader bereit") + .foregroundStyle(app.cards.readerNames.isEmpty ? Color.secondary : Color.green) + } + } + + private func readCard() async { + busy = true; defer { busy = false } + do { + let (uid, card) = try await app.cards.readViaReader() + lastUID = uid + lastRead = card + if let card { + folder = Int(card.folder) + mode = card.mode + special = Int(card.special) + special2 = Int(card.special2) + if let cardModifier = card.modifier { + category = .modifier + modifier = cardModifier + if cardModifier == .sleepTimer { + stopAfterCurrentTrack = card.special > 128 + sleepTimerMinutes = Int(card.special) - (stopAfterCurrentTrack ? 128 : 0) + } else if cardModifier == .freezeDance || cardModifier == .fireWaterAir { + danceDifficulty = min(Int(card.special), 2) + } + } else { + category = .playback + } + } + app.status = card == nil ? "Unprogrammierte Karte" : "Karte ausgelesen" + } + catch { app.report(error) } + } + private func writeCard() async { + busy = true; defer { busy = false } + do { + let card = try makeCard() + if route == .tonuino { + try await app.cards.writeViaTonuino(card) + tonuinoWritePending = true + app.status = "TonUINO-Schreibassistent gestartet; folge den Audioansagen." + } else { + try await app.cards.writeViaReader(card) + lastRead = card + app.status = "Karte erfolgreich programmiert" + } + } catch { app.report(error) } + } + private func eraseCard() async { + busy = true; defer { busy = false } + do { try await app.cards.eraseViaReader(); lastRead = nil; app.status = "Karte gelöscht" } catch { app.report(error) } + } + private func makeCard() throws -> TonuinoCard { + if category == .modifier { + let parameter: UInt8 + switch modifier { + case .sleepTimer: + parameter = UInt8(sleepTimerMinutes + (stopAfterCurrentTrack ? 128 : 0)) + case .freezeDance, .fireWaterAir: + parameter = UInt8(danceDifficulty) + default: + parameter = 0 + } + let value = TonuinoCard.makeModifier(modifier, special: parameter) + try value.validate() + return value + } + let value = TonuinoCard(folder: mode == .admin ? 0 : UInt8(folder), mode: mode, special: UInt8(special), special2: UInt8(special2)) + try value.validate() + return value + } + + private func exportDump() { + do { + let card = try makeCard() + let panel = NSSavePanel() + panel.nameFieldStringValue = dumpFilename(for: card) + panel.message = "Speichere einen MIFARE-Classic-1K-Dump für den Pixl.js-Kartenemulator." + panel.prompt = "Speichern" + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let url = panel.url else { return } + try MifareClassicDump.make1K(for: card).write(to: url, options: .atomic) + app.status = "Kartendump exportiert: \(card.displayTitle)" + } catch { app.report(error) } + } + + private func dumpFilename(for card: TonuinoCard) -> String { + let title = card.displayTitle + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + .map { $0.isLetter || $0.isNumber ? $0 : "-" } + .split(separator: "-") + .joined(separator: "-") + if card.modifier != nil { return "Tonuino-Sonderkarte-\(title).bin" } + return "Tonuino-\(title)-Ordner-\(String(format: "%02d", card.folder)).bin" + } +} + +private enum CardEditorCategory: String, CaseIterable, Identifiable { + case playback, modifier + var id: String { rawValue } + var title: String { self == .playback ? "Wiedergabekarte" : "Sonderkarte" } +} + +enum CardRoute: String, CaseIterable, Identifiable { + case tonuino, acr122u + var id: String { rawValue } + var title: String { self == .tonuino ? "Über TonUINO" : "ACR122U Reader" } +} diff --git a/Sources/TonuinoManager/UI/FolderCardWriterView.swift b/Sources/TonuinoManager/UI/FolderCardWriterView.swift new file mode 100644 index 0000000..5223246 --- /dev/null +++ b/Sources/TonuinoManager/UI/FolderCardWriterView.swift @@ -0,0 +1,218 @@ +import AppKit +import SwiftUI + +struct FolderCardWriterView: View { + let folder: MusicFolder + @EnvironmentObject private var app: AppModel + @Environment(\.dismiss) private var dismiss + @AppStorage("reader.route") private var savedRoute = "tonuino" + @State private var route: CardRoute = .tonuino + @State private var mode: PlaybackMode = .album + @State private var special = 1 + @State private var special2 = 1 + @State private var isWriting = false + @State private var tonuinoWritePending = false + @State private var resultMessage: String? + @State private var writeSucceeded = false + + private var isRouteReady: Bool { + route == .tonuino ? app.cards.serialConnected : !app.cards.selectedReader.isEmpty + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("NFC-Karte programmieren").font(.title2.bold()) + Text("\(folder.title) · Ordner \(String(format: "%02d", folder.number)) · \(folder.tracks.count) Tracks") + .foregroundStyle(.secondary) + } + + Form { + Section("Wiedergabe") { + Picker("Modus", selection: $mode) { + ForEach(PlaybackMode.allCases) { item in Text(item.title).tag(item) } + } + if mode.needsSpecial && mode != .quiz { + Stepper(specialLabel, value: $special, in: mode == .audiobookSingle ? 0...29 : 1...255) + } + if mode.needsSpecial2 { + if mode == .quiz { + Picker("Fragen", selection: $special) { + Text("Alle").tag(0); Text("2 Fragen").tag(2); Text("4 Fragen").tag(4) + } + Picker("Antworten pro Frage", selection: $special2) { + Text("2").tag(0); Text("3").tag(1) + } + } else { + Stepper("Endtrack: \(special2)", value: $special2, in: max(1, special)...255) + } + } + Text(mode.needsFolder && mode != .admin + ? "Die Karte verweist auf Ordner \(String(format: "%02d", folder.number))." + : "Dieser Modus verwendet keine Ordnernummer.") + .font(.caption).foregroundStyle(.secondary) + } + + Section("Kartenleser") { + Picker("Schreibweg", selection: $route) { + ForEach(CardRoute.allCases) { Text($0.title).tag($0) } + }.pickerStyle(.segmented) + + if route == .tonuino { + HStack { + Picker("Serieller Port", selection: $app.cards.selectedSerialPort) { + ForEach(app.cards.serialPorts, id: \.self) { Text($0).tag($0) } + } + Button("Suchen", systemImage: "arrow.clockwise") { app.cards.refreshSerialPorts() } + if app.cards.serialConnected { + Label(app.cards.firmwareVersion.map { "Verbunden · \($0)" } ?? "Verbunden · Firmwareversion nicht erkannt", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + Button("Trennen") { app.cards.disconnectSerial() } + } else { + Button("Verbinden") { Task { await connectSerial() } } + .disabled(app.cards.selectedSerialPort.isEmpty || isWriting) + } + } + } else { + HStack { + Picker("Reader", selection: $app.cards.selectedReader) { + ForEach(app.cards.readerNames, id: \.self) { Text($0).tag($0) } + } + Button("Suchen", systemImage: "arrow.clockwise") { app.cards.refreshReaders() } + } + Text(app.cards.readerNames.isEmpty ? "Kein PC/SC-Reader gefunden." : "Karte auf den ausgewählten Reader legen, wenn die App wartet.") + .font(.caption).foregroundStyle(.secondary) + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + + if let resultMessage { + Label(resultMessage, systemImage: writeSucceeded ? "checkmark.circle.fill" : (tonuinoWritePending ? "info.circle.fill" : "exclamationmark.triangle.fill")) + .foregroundStyle(writeSucceeded ? Color.green : (tonuinoWritePending ? Color.secondary : Color.red)) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Text(route == .tonuino + ? "TonUINO muss im Leerlauf sein. Lege die Karte auf, sobald die Firmware dazu auffordert." + : "Lege eine unterstützte MIFARE- oder NTAG-Karte auf den PC/SC-Reader.") + .font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading) + Button("Schließen") { + if tonuinoWritePending { try? app.cards.cancelWrite() } + dismiss() + }.keyboardShortcut(.cancelAction) + if tonuinoWritePending { + Button("Abbrechen", role: .cancel) { + do { try app.cards.cancelWrite(); tonuinoWritePending = false; resultMessage = "Schreibvorgang abgebrochen." } + catch { resultMessage = error.localizedDescription } + } + Button("Fertig") { + do { try app.cards.cancelWrite(); tonuinoWritePending = false; dismiss() } + catch { resultMessage = error.localizedDescription } + } + } + Button("Dump exportieren…", systemImage: "square.and.arrow.up") { + exportDump() + } + .disabled(isWriting || tonuinoWritePending) + Button(isWriting ? "Menü wird vorbereitet…" : (tonuinoWritePending ? "Warte auf Karte…" : "Karte programmieren"), systemImage: "wave.3.right") { + Task { await writeCard() } + } + .buttonStyle(.borderedProminent) + .disabled(isWriting || tonuinoWritePending || !isRouteReady) + } + } + .padding(22) + .frame(width: 700, height: 610) + .onAppear { + route = savedRoute == "acr122u" ? .acr122u : .tonuino + app.cards.refreshReaders() + app.cards.refreshSerialPorts() + } + .onChange(of: route) { newValue in savedRoute = newValue.rawValue } + .onChange(of: special) { newValue in + if mode != .quiz, mode.needsSpecial2, special2 < newValue { special2 = newValue } + } + } + + private var specialLabel: String { + switch mode { + case .single: "Tracknummer: \(special)" + case .audiobookSingle: "Zusatztracks: \(special)" + default: "Starttrack: \(special)" + } + } + + private func connectSerial() async { + do { try await app.cards.connectSerial() } + catch { resultMessage = error.localizedDescription; writeSucceeded = false } + } + + private func makeCard() throws -> TonuinoCard { + let value = TonuinoCard( + folder: mode == .admin ? 0 : UInt8(folder.number), + mode: mode, + special: UInt8(special), + special2: UInt8(special2) + ) + try value.validate() + return value + } + + private func exportDump() { + do { + let dump = MifareClassicDump.make1K(for: try makeCard()) + let panel = NSSavePanel() + panel.nameFieldStringValue = dumpFilename + panel.message = "Speichere einen MIFARE-Classic-1K-Dump für den Pixl.js-Kartenemulator." + panel.prompt = "Speichern" + panel.canCreateDirectories = true + + guard panel.runModal() == .OK, let url = panel.url else { return } + try dump.write(to: url, options: .atomic) + resultMessage = "Kartendump erfolgreich exportiert (1.024 Byte)." + writeSucceeded = true + app.status = "Kartendump für Ordner \(String(format: "%02d", folder.number)) exportiert" + } catch { + resultMessage = error.localizedDescription + writeSucceeded = false + } + } + + private var dumpFilename: String { + let normalizedMode = mode.title + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + .map { $0.isLetter || $0.isNumber ? $0 : "-" } + let modeName = String(normalizedMode) + .split(separator: "-") + .joined(separator: "-") + return "Tonuino-Ordner-\(String(format: "%02d", folder.number))-\(modeName).bin" + } + + private func writeCard() async { + isWriting = true + resultMessage = nil + defer { isWriting = false } + do { + let value = try makeCard() + if route == .tonuino { + try await app.cards.writeViaTonuino(value) + tonuinoWritePending = true + resultMessage = "TonUINO-Schreibassistent läuft. Folge den Audioansagen, lege die Karte auf und klicke nach der Bestätigung auf „Fertig“." + writeSucceeded = false + app.status = "TonUINO-Schreibassistent gestartet; folge den Audioansagen." + } else { + try await app.cards.writeViaReader(value) + resultMessage = "Karte erfolgreich programmiert." + writeSucceeded = true + app.status = "Karte für Ordner \(String(format: "%02d", folder.number)) programmiert" + } + } catch { + resultMessage = error.localizedDescription + writeSucceeded = false + } + } +} diff --git a/Sources/TonuinoManager/UI/LibraryView.swift b/Sources/TonuinoManager/UI/LibraryView.swift new file mode 100644 index 0000000..6c59bad --- /dev/null +++ b/Sources/TonuinoManager/UI/LibraryView.swift @@ -0,0 +1,1150 @@ +import AppKit +import SwiftUI + +struct LibraryView: View { + @EnvironmentObject private var app: AppModel + @AppStorage("library.folderSidebarWidth") private var folderSidebarWidth = 285.0 + @StateObject private var player = AudioPlayerModel() + @State private var showImporter = false + @State private var showYouTubeImport = false + @State private var showCoverPicker = false + @State private var showMetadata = false + @State private var showTagging = false + @State private var showAcoustIDTagging = false + @State private var showCardWriter = false + @State private var showCleanConfirm = false + @State private var showCreateProfile = false + @State private var showSyncProfile = false + @State private var showProfileManager = false + @State private var profileToRename: LibraryProfile? + @State private var showDeleteProfileConfirm = false + @State private var editingTrack: MusicTrack? + @State private var editingFolderNumber: Int? + @State private var folderToRename: MusicFolder? + @State private var taggingTracks: [MusicTrack] = [] + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Musikbibliothek").font(.largeTitle.bold()) + Text(app.library.activeProfile.map { "Lokales Profil · \($0.name)" } ?? app.library.rootURL?.path ?? "Wähle eine SD-Karte oder erstelle ein lokales Profil") + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Menu { + if !app.library.profiles.isEmpty { + Section("Lokale Profile") { + ForEach(app.library.profiles) { profile in + Button { + app.library.openProfile(profile.id) + app.status = "Lokales Profil \(profile.name) geöffnet" + } label: { + if app.library.activeProfileID == profile.id { + Label(profile.name, systemImage: "checkmark") + } else { Text(profile.name) } + } + } + } + Divider() + } + Button("Lokales Profil erstellen…", systemImage: "plus") { showCreateProfile = true } + Button("Profile verwalten…", systemImage: "slider.horizontal.3") { showProfileManager = true } + if let profile = app.library.activeProfile { + Button("Profil umbenennen…", systemImage: "pencil") { profileToRename = profile } + Button("Profil löschen…", systemImage: "trash", role: .destructive) { showDeleteProfileConfirm = true } + } + } label: { + Label(app.library.activeProfile?.name ?? "Profile", systemImage: "externaldrive.badge.person.crop") + } + .menuStyle(.borderedButton) + if app.library.activeProfile != nil { + Button("Mit SD-Karte synchronisieren…", systemImage: "arrow.triangle.2.circlepath") { showSyncProfile = true } + .disabled(app.library.isLoading || app.library.isSyncing) + } + Button("SD-Karte direkt öffnen…", systemImage: "externaldrive") { chooseCard() } + .help("Eine eingelegte SD-Karte direkt bearbeiten") + }.padding() + Divider() + if app.library.rootURL == nil { + mountedVolumesView + } else { + PersistentFolderSplitView(sidebarWidth: $folderSidebarWidth) { + folderSidebar.environmentObject(app) + } detail: { + trackPanel.environmentObject(app) + } + } + Divider() + PlayerBar(player: player, tracks: app.library.currentTracks).padding(.horizontal).padding(.vertical, 8) + } + .onChange(of: app.library.selectedFolder) { newValue in + if newValue != nil { app.library.selectedSystemFolderName = nil } + } + .fileImporter(isPresented: $showImporter, allowedContentTypes: [.audio], allowsMultipleSelection: true) { result in + Task { + do { try await app.library.importFiles(try result.get(), to: app.library.selectedFolder ?? 0); app.status = "Tracks importiert" } + catch { app.report(error) } + } + } + .fileImporter(isPresented: $showCoverPicker, allowedContentTypes: [.image], allowsMultipleSelection: false) { result in + do { + guard let url = try result.get().first else { return } + if let editingFolderNumber, let folder = app.library.folders.first(where: { $0.number == editingFolderNumber }) { + for track in folder.tracks { try CoverMetadata.write(url, to: track.url) } + } else { try CoverMetadata.write(url, to: editingTrack?.url) } + editingFolderNumber = nil + app.library.reload() + } catch { app.report(error) } + } + .sheet(isPresented: $showMetadata) { + if let track = editingTrack { MetadataEditor(track: track) { metadata in + do { try ID3Metadata.write(metadata, to: track.url); app.library.reload(); return true } + catch { app.report(error); return false } + }} + } + .sheet(isPresented: $showTagging) { + MusicBrainzTaggingView(tracks: taggingTracks) { app.library.reload() } + } + .sheet(isPresented: $showAcoustIDTagging) { + AcoustIDTaggingView(tracks: taggingTracks) { app.library.reload() } + } + .sheet(isPresented: $showCardWriter) { + if let folder = app.library.currentFolder { + FolderCardWriterView(folder: folder) + } + } + .sheet(isPresented: $showYouTubeImport) { + if let folder = app.library.currentFolder { + YouTubeImportView(folderNumber: folder.number) { } + } + } + .sheet(item: $folderToRename) { folder in + FolderNameEditor(folder: folder) { name in + do { try app.library.renameFolder(folder.number, to: name); return true } + catch { app.report(error); return false } + } + } + .sheet(isPresented: $showCreateProfile) { LocalProfileCreateView() } + .sheet(isPresented: $showProfileManager) { ProfileManagementView() } + .sheet(item: $profileToRename) { profile in + LocalProfileRenameView(profile: profile) { name in + do { try app.library.renameProfile(profile.id, to: name); return true } + catch { app.report(error); return false } + } + } + .sheet(isPresented: $showSyncProfile) { + if let profile = app.library.activeProfile { ProfileSyncView(profile: profile) } + } + .confirmationDialog("Lokales Profil löschen?", isPresented: $showDeleteProfileConfirm, titleVisibility: .visible) { + Button("Profil und lokale Dateien in den Papierkorb verschieben", role: .destructive) { + guard let profile = app.library.activeProfile else { return } + do { try app.library.deleteProfile(profile.id); app.status = "Profil \(profile.name) gelöscht" } + catch { app.report(error) } + } + Button("Abbrechen", role: .cancel) { } + } message: { + Text("Das lokale Profil einschließlich aller Audiodateien wird in den macOS-Papierkorb verschoben.") + } + .confirmationDialog("SD-Karte bereinigen?", isPresented: $showCleanConfirm, titleVisibility: .visible) { + Button("Bereinigen", role: .destructive) { do { try app.library.cleanCard(); app.status = "SD-Karte bereinigt" } catch { app.report(error) } } + Button("Abbrechen", role: .cancel) { } + } message: { + let count = app.library.cleanPreview.map { $0.rootItems.count + $0.malformedFiles.count } ?? 0 + Text("Es werden \(count) nicht zum TonUINO-Schema passende Einträge entfernt. Ordnernummern bleiben erhalten; Tracks werden neu nummeriert.") + } + } + + private var mountedVolumesView: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Eingehängte Laufwerke").font(.headline) + Text("TonUINO-Treffer werden anhand der Ordnerstruktur geschätzt.") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + if app.library.isDiscoveringVolumes { ProgressView().controlSize(.small) } + Button("Aktualisieren", systemImage: "arrow.clockwise") { + app.library.refreshMountedVolumes() + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + + Divider() + + if app.library.mountedVolumes.isEmpty { + EmptyState( + title: app.library.isDiscoveringVolumes ? "Laufwerke werden gesucht…" : "Keine Laufwerke gefunden", + icon: "externaldrive", + detail: app.library.isDiscoveringVolumes ? "" : "Schließe eine SD-Karte an oder aktualisiere die Laufwerksliste." + ) + } else { + List(app.library.mountedVolumes) { volume in + HStack(alignment: .center, spacing: 12) { + Image(systemName: volume.match == .likely ? "sdcard.fill" : "externaldrive") + .font(.title3) + .foregroundStyle(matchColor(volume.match)) + .frame(width: 28) + VStack(alignment: .leading, spacing: 3) { + Text(volume.name) + .font(.headline) + .lineLimit(1) + .truncationMode(.middle) + Text(volume.url.path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + Text(matchTitle(volume.match)) + .font(.caption) + .foregroundStyle(matchColor(volume.match)) + .lineLimit(1) + Text(matchDetail(volume.match)) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) + Button("Öffnen") { + app.library.open(volume.url) + app.status = "SD-Karte wird eingelesen…" + } + .buttonStyle(.bordered) + .fixedSize(horizontal: true, vertical: false) + } + .padding(.vertical, 5) + } + .listStyle(.inset) + } + } + } + + private func matchTitle(_ match: TonuinoVolumeMatch) -> String { + switch match { + case .likely: "Wahrscheinliche TonUINO-Karte" + case .possible: "Möglicher Treffer" + case .notRecognized: "Kein TonUINO-Muster" + case .unreadable: "Nicht lesbar" + } + } + + private func matchDetail(_ match: TonuinoVolumeMatch) -> String { + switch match { + case .likely: "mp3 und advert mit Audiodateien" + case .possible: "Teilweise passendes Ordnerschema" + case .notRecognized: "Laufwerk kann manuell geöffnet werden" + case .unreadable: "Zugriff auf den Inhalt nicht möglich" + } + } + + private func matchColor(_ match: TonuinoVolumeMatch) -> Color { + switch match { + case .likely: .green + case .possible: .orange + case .notRecognized, .unreadable: .secondary + } + } + + private var folderSidebar: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Ordner").font(.headline) + Spacer() + Button { do { try app.library.createFolder() } catch { app.report(error) } } label: { Image(systemName: "plus") } + .help("Ordner anlegen") + }.padding(.horizontal).padding(.top, 12) + List(selection: $app.library.selectedFolder) { + ForEach(app.library.folders) { folder in + let isEmpty = folder.tracks.isEmpty + HStack { + CoverThumbnail(url: folder.tracks.first?.url) + .onTapGesture { editingFolderNumber = folder.number; editingTrack = nil; showCoverPicker = true } + .help("Cover für alle Tracks dieses Ordners ändern") + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(folder.title) + .foregroundStyle(isEmpty ? Color.secondary : Color.primary) + .lineLimit(1) + if isEmpty { + Label("Leer", systemImage: "circle.slash") + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + } + } + Text("Ordner \(String(format: "%02d", folder.number)) · \(folder.tracks.count) Tracks") + .font(.caption) + .foregroundStyle(.secondary.opacity(isEmpty ? 0.75 : 1)) + } + }.tag(folder.number) + .contextMenu { + Button("Ordner benennen…", systemImage: "pencil") { folderToRename = folder } + Button("Ordner löschen", role: .destructive) { do { try app.library.deleteFolder(folder.number) } catch { app.report(error) } } + } + } + Section("Systemordner") { + ForEach(app.library.systemFolders) { folder in + Button { + app.library.selectedFolder = nil + app.library.selectedSystemFolderName = folder.name + } label: { + Label("\(folder.name) · \(folder.tracks.count) Tracks", systemImage: "folder") + .foregroundStyle(app.library.selectedSystemFolderName == folder.name ? Color.accentColor : Color.primary) + }.buttonStyle(.plain) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + Button("Karte bereinigen…", systemImage: "sparkles") { + do { _ = try app.library.cleanPreviewForCard(); showCleanConfirm = true } catch { app.report(error) } + }.disabled(app.library.isLoading).padding(.horizontal).padding(.bottom, 10) + } + } + + private var trackPanel: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading) { + Text(app.library.currentFolder?.title ?? app.library.currentSystemFolder?.name ?? "Ordner auswählen").font(.title2.bold()) + Text(app.library.currentFolder != nil ? "\(app.library.currentFolder?.tracks.count ?? 0) von maximal 255 Tracks" : "Systemordner · nur Anzeige und Wiedergabe") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + if app.library.currentFolder != nil { + Menu("Auswahl", systemImage: "checkmark.circle") { + Button("Alle Tracks auswählen") { + app.library.selectedTrackIDs = Set(app.library.currentTracks.map(\.id)) + }.keyboardShortcut("a", modifiers: [.command]) + Button("Auswahl aufheben") { + app.library.selectedTrackIDs.removeAll() + } + } + } + if !app.library.selectedTrackIDs.isEmpty && app.library.currentFolder != nil { + Button("Löschen", systemImage: "trash", role: .destructive) { do { try app.library.deleteTracks(app.library.selectedTrackIDs) } catch { app.report(error) } } + } + if !app.library.selectedTrackIDs.isEmpty && app.library.currentFolder != nil { + Button("Nach oben", systemImage: "arrow.up") { do { try app.library.moveSelectedTracks(by: -1) } catch { app.report(error) } } + Button("Nach unten", systemImage: "arrow.down") { do { try app.library.moveSelectedTracks(by: 1) } catch { app.report(error) } } + } + if app.library.currentFolder != nil { + Button("MusicBrainz taggen…", systemImage: "tag") { + let folderTracks = app.library.currentTracks + taggingTracks = app.library.selectedTrackIDs.isEmpty ? folderTracks : folderTracks.filter { app.library.selectedTrackIDs.contains($0.id) } + showTagging = true + }.disabled(app.library.currentTracks.isEmpty || app.library.isLoading) + .help(app.library.selectedTrackIDs.isEmpty ? "Alle Tracks dieses Ordners taggen" : "Ausgewählte Tracks taggen") + Button("AcoustID identifizieren…", systemImage: "waveform") { + let folderTracks = app.library.currentTracks + taggingTracks = app.library.selectedTrackIDs.isEmpty ? folderTracks : folderTracks.filter { app.library.selectedTrackIDs.contains($0.id) } + showAcoustIDTagging = true + }.disabled(app.library.currentTracks.isEmpty || app.library.isLoading) + .help(app.library.selectedTrackIDs.isEmpty ? "Alle Tracks dieses Ordners identifizieren" : "Ausgewählte Tracks identifizieren") + Button("NFC-Karte schreiben…", systemImage: "wave.3.right") { showCardWriter = true } + .disabled(app.library.currentFolder == nil || app.library.isLoading) + .help("Ordner \(String(format: "%02d", app.library.currentFolder?.number ?? 0)) auf eine NFC-Karte schreiben") + } + Menu("Musik hinzufügen…", systemImage: "plus") { + Button("Audiodateien…", systemImage: "music.note") { showImporter = true } + Button("YouTube-Video…", systemImage: "play.rectangle") { showYouTubeImport = true } + }.disabled(app.library.selectedFolder == nil || app.library.isLoading) + }.padding() + Divider() + if app.library.isLoading { + VStack(spacing: 12) { + ProgressView() + Text(app.library.scanProgress.isEmpty ? "SD-Karte wird eingelesen…" : app.library.scanProgress) + .foregroundStyle(.secondary).monospacedDigit() + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let scanError = app.library.scanError { + EmptyState( + title: app.library.activeProfile == nil ? "SD-Karte konnte nicht gelesen werden" : "Profil-Speicherort nicht verfügbar", + icon: "exclamationmark.triangle", + detail: scanError + ) + } else { + let tracks = app.library.currentTracks + if !tracks.isEmpty { + Table(tracks, selection: $app.library.selectedTrackIDs) { + TableColumn("Nr.") { track in Text(track.url.deletingPathExtension().lastPathComponent).foregroundStyle(.secondary).frame(width: 38, alignment: .trailing) }.width(50) + TableColumn("Titel") { track in + HStack(spacing: 6) { + if player.currentURL?.standardizedFileURL == track.url.standardizedFileURL { + Image(systemName: player.isPlaying ? "speaker.wave.2.fill" : "pause.circle.fill") + .foregroundStyle(.tint) + .frame(width: 15) + .help(player.isPlaying ? "Wird gerade abgespielt" : "Aktueller Track, pausiert") + } else { + Color.clear.frame(width: 15, height: 12) + } + Text(track.displayTitle).lineLimit(1) + } + } + TableColumn("Interpret", value: \.artist) + TableColumn("Album", value: \.album) + TableColumn("") { track in + HStack(spacing: 8) { + Button { if let index = tracks.firstIndex(of: track) { player.play(tracks, startingAt: index) } } label: { Image(systemName: "play.fill") }.buttonStyle(.plain).help("Abspielen") + if app.library.currentFolder != nil { + Menu { + Button("Metadaten bearbeiten/anlegen…") { editingTrack = track; showMetadata = true } + Button("Cover einbetten…") { editingTrack = track; showCoverPicker = true } + if let index = tracks.firstIndex(of: track), index > 0 { Button("Nach oben") { do { try app.library.moveTrack(from: index, to: index - 1) } catch { app.report(error) } } } + if let index = tracks.firstIndex(of: track), index + 1 < tracks.count { Button("Nach unten") { do { try app.library.moveTrack(from: index, to: index + 1) } catch { app.report(error) } } } + Button("Track löschen", role: .destructive) { do { try app.library.deleteTracks([track.id]) } catch { app.report(error) } } + } label: { Image(systemName: "ellipsis") }.buttonStyle(.plain) + } + } + }.width(70) + } + } else { + EmptyState(title: "Noch keine Musik", icon: "music.note.list", detail: "Füge MP3s oder andere Audiodateien hinzu. Nicht-MP3-Dateien werden mit dem gebündelten FFmpeg konvertiert.") + } + } + } + } + + private func chooseCard() { + let panel = NSOpenPanel(); panel.canChooseFiles = false; panel.canChooseDirectories = true; panel.allowsMultipleSelection = false + panel.message = "Wähle das Volume der TonUINO-SD-Karte aus." + if panel.runModal() == .OK, let url = panel.url { + app.library.open(url) + app.status = "SD-Karte wird eingelesen…" + } + } +} + +private struct LocalProfileCreateView: View { + @EnvironmentObject private var app: AppModel + @Environment(\.dismiss) private var dismiss + @State private var name = "Neue TonUINO-Box" + @State private var sourceID = "" + @State private var storageBaseURL: URL? + @State private var isCreating = false + + private var candidateVolumes: [TonuinoMountedVolume] { + app.library.mountedVolumes.filter { $0.isRemovable || $0.match == .likely || $0.match == .possible } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Lokales Profil erstellen").font(.title2.bold()) + Text("Das Profil speichert eine eigene Kopie der TonUINO-Karteninhalte auf diesem Mac.") + .foregroundStyle(.secondary) + Form { + TextField("Profilname", text: $name) + Picker("Startinhalt", selection: $sourceID) { + Text("Leere Bibliothek").tag("") + ForEach(candidateVolumes) { volume in + Text("Von \(volume.name) kopieren · \(volume.url.path)").tag(volume.id) + } + } + .pickerStyle(.menu) + VStack(alignment: .leading, spacing: 6) { + Text("Speicherort") + Text(storageBaseURL?.path ?? app.library.profileStorageURL.path) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(3) + HStack { + Button(storageBaseURL == nil ? "Anderen Ordner auswählen…" : "Ordner ändern…", systemImage: "folder") { + chooseStorageLocation() + } + if storageBaseURL != nil { + Button("Standardort", systemImage: "arrow.uturn.backward") { storageBaseURL = nil } + } + } + Text("Im ausgewählten Ordner wird „Tonuino Manager Profiles“ für dieses Profil angelegt.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + if isCreating || app.library.isSyncing { + HStack(spacing: 10) { + ProgressView().controlSize(.small) + Text(app.library.syncProgress.isEmpty ? "Profil wird erstellt…" : app.library.syncProgress) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + } + HStack { + Spacer() + Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Profil erstellen") { create() } + .buttonStyle(.borderedProminent) + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isCreating || app.library.isSyncing) + } + } + .padding(24) + .frame(width: 540) + } + + private func create() { + let source = candidateVolumes.first { $0.id == sourceID }?.url + isCreating = true + Task { + defer { isCreating = false } + do { + try await app.library.createProfile(name: name, copyingFrom: source, storageBaseURL: storageBaseURL) + app.status = "Lokales Profil \(name) erstellt" + dismiss() + } catch { app.report(error) } + } + } + + private func chooseStorageLocation() { + let panel = NSOpenPanel() + panel.title = "Speicherort für dieses Profil auswählen" + panel.prompt = "Diesen Ordner verwenden" + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.allowsMultipleSelection = false + if panel.runModal() == .OK { storageBaseURL = panel.url } + } +} + +private struct LocalProfileRenameView: View { + let profile: LibraryProfile + let save: (String) -> Bool + @Environment(\.dismiss) private var dismiss + @State private var name: String + + init(profile: LibraryProfile, save: @escaping (String) -> Bool) { + self.profile = profile + self.save = save + _name = State(initialValue: profile.name) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Profil umbenennen").font(.title2.bold()) + TextField("Profilname", text: $name).textFieldStyle(.roundedBorder) + HStack { + Spacer() + Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Sichern") { if save(name) { dismiss() } } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(24) + .frame(width: 420) + } +} + +private enum ProfileManagerSheet: Identifiable { + case create + case rename(LibraryProfile) + case compare(LibraryProfile) + + var id: String { + switch self { + case .create: "create" + case .rename(let profile): "rename-\(profile.id.uuidString)" + case .compare(let profile): "compare-\(profile.id.uuidString)" + } + } +} + +private struct ProfileManagementView: View { + @EnvironmentObject private var app: AppModel + @Environment(\.dismiss) private var dismiss + @State private var presentedSheet: ProfileManagerSheet? + @State private var profileToDelete: LibraryProfile? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Profile verwalten").font(.title2.bold()) + Text("Prüfe Speicherorte und vergleiche lokale Profile mit einer eingelegten SD-Karte. Der Vergleich liest Dateiinformationen; er schreibt nichts auf die Karte.") + .font(.callout).foregroundStyle(.secondary) + + if app.library.profiles.isEmpty { + EmptyState(title: "Noch keine Profile", icon: "externaldrive.badge.person.crop", detail: "Erstelle ein lokales Profil, um eine Bibliothek unabhängig von der SD-Karte zu verwalten.") + } else { + List(app.library.profiles) { profile in + profileRow(profile) + } + .listStyle(.inset) + } + + HStack { + Button("Profil erstellen…", systemImage: "plus") { presentedSheet = .create } + Spacer() + Button("Fertig") { dismiss() }.keyboardShortcut(.cancelAction) + } + } + .padding(20) + .frame(width: 860, height: 560) + .sheet(item: $presentedSheet) { sheet in + switch sheet { + case .create: + LocalProfileCreateView() + case .rename(let profile): + LocalProfileRenameView(profile: profile) { name in + do { try app.library.renameProfile(profile.id, to: name); return true } + catch { app.report(error); return false } + } + case .compare(let profile): + ProfileSyncView(profile: profile) + } + } + .confirmationDialog( + "Lokales Profil löschen?", + isPresented: Binding( + get: { profileToDelete != nil }, + set: { if !$0 { profileToDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("Profil und lokale Dateien in den Papierkorb verschieben", role: .destructive) { + guard let profile = profileToDelete else { return } + do { + try app.library.deleteProfile(profile.id) + app.status = "Profil \(profile.name) gelöscht" + } catch { app.report(error) } + profileToDelete = nil + } + Button("Abbrechen", role: .cancel) { profileToDelete = nil } + } message: { + Text("Das Profil wird aus der Übersicht entfernt. Seine lokalen Dateien werden in den macOS-Papierkorb verschoben.") + } + } + + private func profileRow(_ profile: LibraryProfile) -> some View { + let isAvailable = FileManager.default.fileExists(atPath: profile.cardRootURL.path) + return HStack(alignment: .center, spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text(profile.name).font(.headline) + if app.library.activeProfileID == profile.id { + Text("Geöffnet").font(.caption2).padding(.horizontal, 6).padding(.vertical, 2) + .background(.quaternary, in: Capsule()) + } + Label(isAvailable ? "Daten verfügbar" : "Speicherort nicht verfügbar", + systemImage: isAvailable ? "checkmark.circle.fill" : "externaldrive.badge.exclamationmark") + .font(.caption) + .foregroundStyle(isAvailable ? Color.green : Color.orange) + } + Text(profile.cardRootURL.path) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + Spacer(minLength: 6) + Button("Öffnen", systemImage: "arrow.up.right.square") { + app.library.openProfile(profile.id) + app.status = "Lokales Profil \(profile.name) geöffnet" + dismiss() + } + Button("Vergleichen…", systemImage: "arrow.left.arrow.right") { + presentedSheet = .compare(profile) + } + Menu { + Button("Umbenennen…", systemImage: "pencil") { presentedSheet = .rename(profile) } + Button("Löschen…", systemImage: "trash", role: .destructive) { profileToDelete = profile } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .help("Weitere Profilaktionen") + } + .padding(.vertical, 5) + } +} + +private struct ProfileSyncView: View { + let profile: LibraryProfile + @EnvironmentObject private var app: AppModel + @Environment(\.dismiss) private var dismiss + @State private var selectedVolumeID = "" + @State private var preview: LibrarySyncPreview? + @State private var isPreviewing = false + @State private var showSyncConfirmation = false + + private var candidateVolumes: [TonuinoMountedVolume] { + app.library.mountedVolumes.filter { $0.isRemovable || $0.match == .likely || $0.match == .possible } + } + private var selectedVolume: TonuinoMountedVolume? { candidateVolumes.first { $0.id == selectedVolumeID } } + private var changesToShow: [LibrarySyncChange] { Array((preview?.changes ?? []).prefix(500)) } + private var requiresExplicitRebind: Bool { preview?.requiresRebinding == true } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Profil vergleichen und reparieren").font(.title2.bold()) + Text("Vergleiche „\(profile.name)“ mit einer SD-Karte. Du kannst fehlende lokale Dateien ergänzen oder das lokale Profil ausdrücklich auf die Karte anwenden.") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Picker("Zielkarte", selection: $selectedVolumeID) { + Text("SD-Karte auswählen").tag("") + ForEach(candidateVolumes) { volume in + Text("\(volume.name) · \(volume.url.path)").tag(volume.id) + } + } + .frame(maxWidth: .infinity) + Button("Aktualisieren", systemImage: "arrow.clockwise") { app.library.refreshMountedVolumes() } + } + .disabled(isPreviewing || app.library.isSyncing) + + if let preview { + GroupBox { + VStack(alignment: .leading, spacing: 8) { + Text("Ziel: \(preview.targetName) · \(preview.targetURL.path)") + .font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + HStack(spacing: 14) { + Label("\(preview.profileOnlyFileCount) nur lokal", systemImage: "internaldrive") + Label("\(preview.cardOnlyFileCount) nur auf SD", systemImage: "sdcard") + Label("\(preview.conflictingFileCount) abweichend", systemImage: "exclamationmark.circle") + } + .font(.caption) + Text("Vergleich anhand von Pfad, Dateigröße und Änderungszeit. Es wurden keine vollständigen Audiodateien gelesen und nichts auf die SD-Karte geschrieben.") + .font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + if requiresExplicitRebind { + Label("Diese SD-Karte ist bisher einem anderen Profil zugeordnet. Die Bestätigung ordnet „\(profile.name)“ dieser Karte neu zu.", systemImage: "exclamationmark.triangle.fill") + .font(.caption).foregroundStyle(.orange).fixedSize(horizontal: false, vertical: true) + } else if preview.requiresBinding { + Label(preview.targetUUID == nil + ? "Die Karte stellt keine Volume-UUID bereit. Bitte prüfe den Zielpfad bei jedem Abgleich sorgfältig." + : "Mit der Bestätigung wird dieses Profil dauerhaft dieser SD-Karte zugeordnet.", + systemImage: "info.circle") + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + + if preview.changes.isEmpty { + Label("Keine Unterschiede bei Pfad, Dateigröße und Änderungszeit gefunden.", systemImage: "checkmark.circle") + .font(.caption).foregroundStyle(.green) + } else { + List(changesToShow) { change in + HStack(spacing: 8) { + Image(systemName: change.isDirectory ? "folder" : "doc") + .foregroundStyle(change.operation == .updated ? .orange : .secondary) + Text(changeLabel(change)).frame(width: 100, alignment: .leading) + .foregroundStyle(change.operation == .updated ? .orange : .secondary) + Text(change.path).lineLimit(1).truncationMode(.middle) + Spacer(minLength: 4) + if !change.isDirectory { + Text(ByteCountFormatter.string(fromByteCount: change.size, countStyle: .file)) + .font(.caption2).foregroundStyle(.secondary) + } + } + .font(.caption) + .padding(.vertical, 2) + } + if preview.changes.count > changesToShow.count { + Text("Weitere \(preview.changes.count - changesToShow.count) Einträge sind in der Vorschau enthalten.") + .font(.caption2).foregroundStyle(.secondary) + } + } + } else if candidateVolumes.isEmpty { + EmptyState(title: "Keine SD-Karte gefunden", icon: "sdcard", detail: "Schließe eine Karte an und aktualisiere die Laufwerksliste.") + .frame(height: 150) + } else { + Text("Die Vorschau vergleicht Dateigröße und Änderungszeit. Sie liest keine kompletten Audiodateien ein und belastet große SD-Karten dadurch deutlich weniger.") + .font(.caption).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 120, alignment: .center) + } + + if isPreviewing || app.library.isSyncing { + HStack(spacing: 10) { + ProgressView().controlSize(.small) + Text(app.library.syncProgress.isEmpty ? "Bitte warten…" : app.library.syncProgress) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + } + + HStack { + Spacer() + Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction) + if preview == nil { + Button("Vergleich starten") { makePreview() } + .buttonStyle(.borderedProminent) + .disabled(selectedVolume == nil || isPreviewing || app.library.isSyncing) + } else { + Button("Andere Karte wählen") { preview = nil } + .disabled(isPreviewing || app.library.isSyncing) + if (preview?.cardOnlyFileCount ?? 0) > 0 { + Button("Fehlende lokale Dateien ergänzen…", systemImage: "arrow.down.to.line") { + restoreMissingFiles() + } + .disabled(isPreviewing || app.library.isSyncing) + } + if preview?.changes.isEmpty == true && preview?.requiresBinding == false && preview?.requiresRebinding == false { + Button("Fertig") { dismiss() } + } else { + Button(requiresExplicitRebind ? "Profil auf SD neu zuordnen…" : "Lokales Profil auf SD anwenden…", systemImage: "arrow.up.to.line") { + showSyncConfirmation = true + } + .buttonStyle(.borderedProminent) + .disabled(app.library.isSyncing) + .confirmationDialog("Lokales Profil auf SD-Karte anwenden?", isPresented: $showSyncConfirmation, titleVisibility: .visible) { + Button(requiresExplicitRebind ? "Neu zuordnen und anwenden" : "Auf SD anwenden", role: .destructive) { synchronize() } + Button("Abbrechen", role: .cancel) { } + } message: { + let removalCount = preview?.removedCount ?? 0 + Text("Die SD-Karte „\(preview?.targetName ?? "")“ wird an das lokale Profil angeglichen. \(removalCount) Einträge, die im Profil fehlen, werden auf der SD-Karte gelöscht.") + } + } + } + } + } + .padding(22) + .frame(width: 760, height: 650) + .onAppear { + if selectedVolumeID.isEmpty, candidateVolumes.count == 1 { selectedVolumeID = candidateVolumes[0].id } + } + .onChange(of: selectedVolumeID) { _ in preview = nil } + } + + private func makePreview() { + guard let selectedVolume else { return } + isPreviewing = true + Task { + defer { isPreviewing = false } + do { preview = try await app.library.previewSync(profileID: profile.id, to: selectedVolume) } + catch { app.report(error) } + } + } + + private func changeLabel(_ change: LibrarySyncChange) -> String { + switch change.operation { + case .added: "Nur lokal" + case .updated: "Abweichend" + case .removed: "Nur auf SD" + } + } + + private func restoreMissingFiles() { + guard let preview else { return } + Task { + do { + let copiedCount = try await app.library.restoreMissingFiles(from: preview) + app.status = copiedCount == 0 + ? "Keine fehlenden lokalen Dateien mehr vorhanden" + : "\(copiedCount) Dateien in Profil \(profile.name) ergänzt" + self.preview = nil + } catch { app.report(error) } + } + } + + private func synchronize() { + guard let preview else { return } + Task { + do { + try await app.library.synchronize(preview) + app.status = "Profil \(profile.name) mit \(preview.targetName) synchronisiert" + dismiss() + } catch { app.report(error) } + } + } +} + +private struct PersistentFolderSplitView: NSViewRepresentable { + @Binding var sidebarWidth: Double + let sidebar: Sidebar + let detail: Detail + + init( + sidebarWidth: Binding, + @ViewBuilder sidebar: () -> Sidebar, + @ViewBuilder detail: () -> Detail + ) { + _sidebarWidth = sidebarWidth + self.sidebar = sidebar() + self.detail = detail() + } + + func makeCoordinator() -> Coordinator { + Coordinator(sidebarWidth: $sidebarWidth) + } + + func makeNSView(context: Context) -> NSSplitView { + let splitView = NSSplitView() + splitView.isVertical = true + splitView.dividerStyle = .thin + splitView.delegate = context.coordinator + + let sidebarHost = NSHostingView(rootView: sidebar) + sidebarHost.autoresizingMask = [.height] + let detailHost = NSHostingView(rootView: detail) + detailHost.autoresizingMask = [.width, .height] + splitView.addSubview(sidebarHost) + splitView.addSubview(detailHost) + context.coordinator.sidebarHost = sidebarHost + context.coordinator.detailHost = detailHost + return splitView + } + + func updateNSView(_ splitView: NSSplitView, context: Context) { + let coordinator = context.coordinator + coordinator.sidebarWidth = $sidebarWidth + coordinator.sidebarHost?.rootView = sidebar + coordinator.detailHost?.rootView = detail + + guard !coordinator.didApplyInitialWidth else { return } + coordinator.didApplyInitialWidth = true + DispatchQueue.main.async { [weak splitView] in + guard let splitView else { return } + let proposed = CGFloat(self.sidebarWidth) + splitView.setPosition(proposed, ofDividerAt: 0) + splitView.adjustSubviews() + } + } + + static func dismantleNSView(_ splitView: NSSplitView, coordinator: Coordinator) { + coordinator.pendingSave?.cancel() + splitView.delegate = nil + } + + final class Coordinator: NSObject, NSSplitViewDelegate { + var sidebarWidth: Binding + weak var sidebarHost: NSHostingView? + weak var detailHost: NSHostingView? + var didApplyInitialWidth = false + var pendingSave: DispatchWorkItem? + + init(sidebarWidth: Binding) { + self.sidebarWidth = sidebarWidth + } + + func splitView( + _ splitView: NSSplitView, + constrainSplitPosition proposedPosition: CGFloat, + ofSubviewAt dividerIndex: Int + ) -> CGFloat { + let minimumSidebar: CGFloat = 220 + let minimumDetail: CGFloat = 380 + let maximumSidebar = max(minimumSidebar, splitView.bounds.width - minimumDetail - splitView.dividerThickness) + return min(max(proposedPosition, minimumSidebar), min(520, maximumSidebar)) + } + + func splitViewDidResizeSubviews(_ notification: Notification) { + guard let splitView = notification.object as? NSSplitView else { return } + pendingSave?.cancel() + let work = DispatchWorkItem { [weak self, weak splitView] in + guard let self, let splitView else { return } + if splitView.inLiveResize { + self.splitViewDidResizeSubviews(Notification(name: NSSplitView.didResizeSubviewsNotification, object: splitView)) + return + } + guard let sidebar = splitView.subviews.first else { return } + let width = Double(sidebar.frame.width) + guard abs(width - self.sidebarWidth.wrappedValue) >= 1 else { return } + self.sidebarWidth.wrappedValue = width + } + pendingSave = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work) + } + } +} + +private struct EmptyState: View { + let title: String + let icon: String + let detail: String + var body: some View { + VStack(spacing: 10) { + Image(systemName: icon).font(.system(size: 38)).foregroundStyle(.secondary) + Text(title).font(.title3.bold()) + Text(detail).foregroundStyle(.secondary).multilineTextAlignment(.center).frame(maxWidth: 420) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct CoverThumbnail: View { + var url: URL? + @State private var image: NSImage? + var body: some View { + Group { + if let image { Image(nsImage: image).resizable().scaledToFill() } + else { Image(systemName: "music.note").resizable().scaledToFit().padding(8).foregroundStyle(.secondary) } + }.frame(width: 36, height: 36).background(.quaternary).clipShape(RoundedRectangle(cornerRadius: 5)) + .task(id: url) { + guard let url else { image = nil; return } + image = await CoverImageCache.shared.image(for: url)?.image + } + } +} + +private actor CoverImageCache { + static let shared = CoverImageCache() + private var images: [URL: CachedCoverImage] = [:] + + func image(for url: URL) async -> CachedCoverImage? { + if let image = images[url] { return image } + let decoded = await Task.detached(priority: .utility) { CoverMetadata.read(from: url).map(CachedCoverImage.init) }.value + if let decoded { images[url] = decoded } + return decoded + } +} + +private final class CachedCoverImage: @unchecked Sendable { + let image: NSImage + init(_ image: NSImage) { self.image = image } +} + +private struct PlayerBar: View { + @ObservedObject var player: AudioPlayerModel + let tracks: [MusicTrack] + var body: some View { + HStack(spacing: 12) { + CoverThumbnail(url: player.currentURL) + VStack(alignment: .leading, spacing: 3) { + Text(player.title.isEmpty ? "Kein Track ausgewählt" : player.title).lineLimit(1) + let showsStatus = player.isLoading || player.statusMessage.hasPrefix("Wiedergabe fehlgeschlagen") + Text(showsStatus || player.artist.isEmpty ? player.statusMessage : player.artist) + .font(.caption).foregroundStyle(player.statusMessage.hasPrefix("Wiedergabe fehlgeschlagen") ? .red : .secondary).lineLimit(1) + }.frame(width: 210, alignment: .leading) + Button { player.previous() } label: { Image(systemName: "backward.end.fill") }.buttonStyle(.plain).disabled(player.isLoading) + Button { toggleOrPlayFolder() } label: { + if player.isLoading { ProgressView().controlSize(.small) } + else { Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") } + } + .buttonStyle(.plain) + .font(.title3) + .keyboardShortcut(.space, modifiers: []) + .help(player.isCurrentTrack(in: tracks) ? "Wiedergabe/Pause (Leertaste)" : "Ordner abspielen (Leertaste)") + .disabled(player.isLoading || tracks.isEmpty) + Button { player.next() } label: { Image(systemName: "forward.end.fill") }.buttonStyle(.plain).disabled(player.isLoading) + Text(time(player.position)).font(.caption.monospacedDigit()).foregroundStyle(.secondary) + Slider(value: Binding(get: { player.position }, set: { player.seek($0) }), in: 0...max(player.duration, 1)).disabled(player.duration == 0) + Text(time(player.duration)).font(.caption.monospacedDigit()).foregroundStyle(.secondary) + Image(systemName: "speaker.wave.2").foregroundStyle(.secondary) + Slider(value: $player.volume, in: 0...1).frame(width: 90) + }.buttonStyle(.plain) + } + private func toggleOrPlayFolder() { + if player.isCurrentTrack(in: tracks) { + player.toggle() + } else { + player.play(tracks, startingAt: 0) + } + } + private func time(_ seconds: Double) -> String { guard seconds.isFinite else { return "0:00" }; return String(format: "%d:%02d", Int(seconds) / 60, Int(seconds) % 60) } +} + +private struct MetadataEditor: View { + let track: MusicTrack + let save: (TrackMetadata) -> Bool + @Environment(\.dismiss) private var dismiss + @State private var title = "" + @State private var artist = "" + @State private var album = "" + @State private var albumArtist = "" + @State private var trackNumber = "" + @State private var discNumber = "" + @State private var year = "" + @State private var genre = "" + @State private var acoustID = "" + @State private var musicBrainzRecordingID = "" + @State private var musicBrainzReleaseID = "" + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Track-Metadaten bearbeiten oder anlegen").font(.title2.bold()) + Text("Speichern legt bei Bedarf einen neuen ID3-Tag in der MP3-Datei an.").font(.caption).foregroundStyle(.secondary) + LabeledContent("Titel") { TextField("Titel", text: $title) } + LabeledContent("Interpret") { TextField("Interpret", text: $artist) } + LabeledContent("Album") { TextField("Album", text: $album) } + LabeledContent("Albuminterpret") { TextField("Albuminterpret", text: $albumArtist) } + HStack { LabeledContent("Track") { TextField("1", text: $trackNumber) }; LabeledContent("CD") { TextField("1", text: $discNumber) } } + HStack { LabeledContent("Jahr") { TextField("Jahr", text: $year) }; LabeledContent("Genre") { TextField("Genre", text: $genre) } } + LabeledContent("AcoustID-ID") { TextField("Optional", text: $acoustID) } + LabeledContent("MusicBrainz Recording-ID") { TextField("Optional", text: $musicBrainzRecordingID) } + LabeledContent("MusicBrainz Release-ID") { TextField("Optional", text: $musicBrainzReleaseID) } + HStack { Spacer(); Button("Abbrechen") { dismiss() }; Button("Sichern") { + if save(TrackMetadata(title: title, artist: artist, album: album, albumArtist: albumArtist, trackNumber: trackNumber, discNumber: discNumber, year: year, genre: genre, acoustID: acoustID, musicBrainzRecordingID: musicBrainzRecordingID, musicBrainzReleaseID: musicBrainzReleaseID)) { dismiss() } + }.keyboardShortcut(.defaultAction) } + }.padding(24).frame(width: 560).onAppear { + let tags = ID3Metadata.read(track.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 struct FolderNameEditor: View { + let folder: MusicFolder + let save: (String) -> Bool + @Environment(\.dismiss) private var dismiss + @State private var name: String + + init(folder: MusicFolder, save: @escaping (String) -> Bool) { + self.folder = folder + self.save = save + _name = State(initialValue: folder.customName ?? folder.title) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Ordner \(String(format: "%02d", folder.number)) benennen").font(.title2.bold()) + TextField("Anzeigename", text: $name) + .textFieldStyle(.roundedBorder) + .onSubmit { if save(name) { dismiss() } } + Text("Der Name wird auf der SD-Karte gespeichert und bleibt beim nächsten Öffnen erhalten. Leeres Feld entfernt den eigenen Namen.") + .font(.caption).foregroundStyle(.secondary) + HStack { + Spacer() + Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Sichern") { if save(name) { dismiss() } }.keyboardShortcut(.defaultAction) + } + } + .padding(24) + .frame(width: 460) + } +} + +enum CoverMetadata { + static func read(from url: URL) -> NSImage? { + guard let data = try? Data(contentsOf: url, options: .mappedIfSafe), data.count > 10, + String(data: data.prefix(3), encoding: .ascii) == "ID3" else { return nil } + let tagEnd = min(data.count, 10 + data[6..<10].reduce(0) { ($0 << 7) | Int($1 & 0x7f) }) + var i = 10 + while i + 10 < tagEnd { + guard let id = String(data: data[i..= 0x20 && $0 <= 0x5A }) else { return nil } + let size = Int(data[i+4]) << 24 | Int(data[i+5]) << 16 | Int(data[i+6]) << 8 | Int(data[i+7]); i += 10 + guard size > 0, i + size <= tagEnd else { return nil } + if id == "APIC" { + let frame = data[i.. 5 else { return nil } + let rest = Data(frame.dropFirst()) + if let terminator = rest.firstIndex(of: 0), terminator + 2 < rest.endIndex { + let imageData = Data(rest[terminator + 2..= 10, + String(data: data.prefix(3), encoding: .ascii) == "ID3" else { throw AudioImportError.conversionFailed } + let tagEnd = min(data.count, 10 + data[6..<10].reduce(0) { ($0 << 7) | Int($1 & 0x7f) }) + var frames = Data(); var i = 10 + while i + 10 <= tagEnd { + guard let id = String(data: data[i.. 0, i + 10 + size <= tagEnd else { break } + if id != "APIC" { frames.append(data[i..> 24)&255), UInt8((n >> 16)&255), UInt8((n >> 8)&255), UInt8(n&255), 0, 0]); frame.append(body); frames.append(frame) + let prefix = data.prefix(10); let oldAudioStart = tagEnd + let newTag = Data("ID3".utf8) + Data([3, 0, 0, UInt8((frames.count >> 21)&127), UInt8((frames.count >> 14)&127), UInt8((frames.count >> 7)&127), UInt8(frames.count&127)]) + frames + data = newTag + data[oldAudioStart...] + _ = prefix + try data.write(to: audioURL, options: .atomic) + } +} diff --git a/Sources/TonuinoManager/UI/MusicBrainzTaggingView.swift b/Sources/TonuinoManager/UI/MusicBrainzTaggingView.swift new file mode 100644 index 0000000..f79d9aa --- /dev/null +++ b/Sources/TonuinoManager/UI/MusicBrainzTaggingView.swift @@ -0,0 +1,187 @@ +import SwiftUI + +struct MusicBrainzTaggingView: View { + let tracks: [MusicTrack] + let onComplete: () -> Void + @Environment(\.dismiss) private var dismiss + @State private var artistQuery: String + @State private var albumQuery: String + @State private var results: [MusicBrainzRelease] = [] + @State private var release: MusicBrainzRelease? + @State private var mappings: [String: Int] = [:] + @State private var isSearching = false + @State private var isLoadingRelease = false + @State private var isWriting = false + @State private var message: String? + + init(tracks: [MusicTrack], onComplete: @escaping () -> Void) { + self.tracks = tracks + self.onComplete = onComplete + _artistQuery = State(initialValue: tracks.first(where: { !$0.albumArtist.isEmpty })?.albumArtist ?? tracks.first?.artist ?? "") + _albumQuery = State(initialValue: tracks.first?.album ?? "") + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Musik mit MusicBrainz taggen").font(.title2.bold()) + Text("\(tracks.count) ausgewählte Datei\(tracks.count == 1 ? "" : "en") · Tags werden erst nach deiner Prüfung geschrieben") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary).font(.title2) }.buttonStyle(.plain) + } + HStack(spacing: 10) { + TextField("Interpret", text: $artistQuery) + TextField("Album", text: $albumQuery) + Button("Suchen", systemImage: "magnifyingglass") { search() }.disabled(isSearching || isWriting) + } + if isSearching || isLoadingRelease { ProgressView(isSearching ? "Suche in MusicBrainz…" : "Albumdaten werden geladen…") } + if let message { Text(message).font(.callout).foregroundStyle(.secondary) } + + if let release { + VStack(alignment: .leading, spacing: 8) { + Text("Treffer prüfen").font(.headline) + Text("\(release.artist) — \(release.title)\(release.year.isEmpty ? "" : " (\(release.year))") · \(release.tracks.count) Albumtracks") + .font(.subheadline) + Text("Ordne jede Datei dem passenden Albumtrack zu. Die vorgeschlagenen Zuordnungen folgen der Dateinummer.") + .font(.caption).foregroundStyle(.secondary) + ScrollView { + LazyVStack(spacing: 0) { + ForEach(tracks) { track in + HStack(spacing: 14) { + Text("\(track.url.lastPathComponent) · \(track.displayTitle)").lineLimit(1).frame(maxWidth: .infinity, alignment: .leading) + Picker("Zuordnung", selection: binding(for: track)) { + Text("Nicht schreiben").tag(-1) + ForEach(Array(release.tracks.enumerated()), id: \.offset) { index, item in + Text("\(item.discNumber).\(item.displayNumber) · \(item.title)\(item.artist.isEmpty ? "" : " — \(item.artist)")").tag(index) + } + }.labelsHidden().frame(width: 350) + }.padding(.vertical, 5).padding(.horizontal, 4) + Divider() + } + } + }.frame(minHeight: 180, maxHeight: 330) + } + } else if !results.isEmpty { + Text("Wähle die passende Veröffentlichung:").font(.headline) + List(results) { candidate in + Button { + Task { await load(candidate) } + } label: { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(candidate.title).font(.body.weight(.medium)) + Text("\(candidate.artist)\(candidate.year.isEmpty ? "" : " · \(candidate.year)")") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right").foregroundStyle(.tertiary) + }.contentShape(Rectangle()) + }.buttonStyle(.plain) + }.frame(minHeight: 180, maxHeight: 330) + } + + HStack { + if release != nil { Button("Anderen Treffer suchen") { release = nil; results = [] } } + Spacer() + Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction) + if release != nil { + Button(isWriting ? "Schreibe Tags…" : "Zuordnungen schreiben", systemImage: "tag") { writeTags() } + .buttonStyle(.borderedProminent).disabled(isWriting) + } + } + } + .padding(22) + .frame(width: 860, height: 650) + .onAppear { if !albumQuery.isEmpty || !artistQuery.isEmpty { search() } } + } + + private func binding(for track: MusicTrack) -> Binding { + Binding(get: { mappings[track.id] ?? -1 }, set: { mappings[track.id] = $0 }) + } + + private func search() { + isSearching = true; message = nil; release = nil; results = [] + Task { + do { + let found = try await MusicBrainzService.searchReleases(artist: artistQuery, album: albumQuery) + await MainActor.run { + results = found + message = found.isEmpty ? "Keine Veröffentlichungen gefunden. Verfeinere Interpret oder Album." : nil + isSearching = false + } + } catch { + await MainActor.run { message = error.localizedDescription; isSearching = false } + } + } + } + + private func load(_ candidate: MusicBrainzRelease) async { + isLoadingRelease = true; message = nil + do { + let full = try await MusicBrainzService.loadRelease(id: candidate.id) + await MainActor.run { + release = full + mappings = Dictionary(uniqueKeysWithValues: tracks.map { track in + (track.id, suggestedIndex(for: track, in: full.tracks)) + }) + isLoadingRelease = false + } + } catch { + await MainActor.run { message = error.localizedDescription; isLoadingRelease = false } + } + } + + private func suggestedIndex(for track: MusicTrack, in recordings: [MusicBrainzRecording]) -> Int { + let rawNumber = track.trackNumber.isEmpty ? track.url.deletingPathExtension().lastPathComponent : track.trackNumber + let number = Int(rawNumber.split(separator: "/").first ?? "") + let disc = Int(track.discNumber) ?? 1 + if let number, let exact = recordings.firstIndex(where: { (Int($0.displayNumber) ?? -1) == number && (Int($0.discNumber) ?? 1) == disc }) { return exact } + if let number, recordings.indices.contains(number - 1) { return number - 1 } + return -1 + } + + private func writeTags() { + guard let release else { return } + let selected = tracks.compactMap { track -> (MusicTrack, MusicBrainzRecording)? in + guard let index = mappings[track.id], release.tracks.indices.contains(index) else { return nil } + return (track, release.tracks[index]) + } + guard !selected.isEmpty else { message = "Es ist keine Datei einem Albumtrack zugeordnet."; return } + isWriting = true; message = "Schreibe Tags auf die SD-Karte…" + Task { + do { + try await Task.detached(priority: .userInitiated) { + for (track, recording) in selected { + var metadata = ID3Metadata.read(track.url) + metadata.title = recording.title + metadata.artist = recording.artist.isEmpty ? release.artist : recording.artist + metadata.album = release.title + metadata.albumArtist = release.artist + metadata.trackNumber = recording.displayNumber + metadata.discNumber = recording.discNumber + metadata.year = release.year + metadata.genre = release.genre + metadata.musicBrainzRecordingID = recording.recording?.id ?? "" + metadata.musicBrainzReleaseID = release.id + try ID3Metadata.write(metadata, to: track.url) + try ID3Metadata.verify(metadata, at: track.url) + } + }.value + await MainActor.run { + isWriting = false + message = "\(selected.count) Datei\(selected.count == 1 ? " wurde" : "en wurden") getaggt." + onComplete() + } + } catch { + await MainActor.run { + isWriting = false + message = "Fehler beim Schreiben: \(error.localizedDescription)" + onComplete() + } + } + } + } +} diff --git a/Sources/TonuinoManager/UI/SettingsView.swift b/Sources/TonuinoManager/UI/SettingsView.swift new file mode 100644 index 0000000..11938e3 --- /dev/null +++ b/Sources/TonuinoManager/UI/SettingsView.swift @@ -0,0 +1,127 @@ +import AppKit +import SwiftUI +import Sparkle + +struct SettingsView: View { + @EnvironmentObject private var app: AppModel + @AppStorage("reader.route") private var defaultRoute = "tonuino" + @State private var acoustIDClientKey = "" + @State private var acoustIDKeyMessage: String? + @State private var profileStorageMessage: String? + + var body: some View { + Form { + Section("Standardwerte") { + Picker("Bevorzugter Kartenleser", selection: $defaultRoute) { + Text("TonUINO über USB").tag("tonuino") + Text("ACR122U").tag("acr122u") + } + Toggle("Update-Prüfung beim Start", isOn: Binding( + get: { AppDelegate.updater?.updater.automaticallyChecksForUpdates ?? true }, + set: { AppDelegate.updater?.updater.automaticallyChecksForUpdates = $0 } + )) + } + Section("Hardware") { + LabeledContent("TonUINO-Verbindung", value: app.cards.serialConnected ? app.cards.selectedSerialPort : "Nicht verbunden") + LabeledContent("Verfügbare serielle Ports", value: "\(app.cards.serialPorts.count)") + LabeledContent("PC/SC Reader", value: "\(app.cards.readerNames.count)") + Button("Geräte erneut suchen", systemImage: "arrow.clockwise") { app.cards.refreshSerialPorts(); app.cards.refreshReaders() } + } + Section("Lokale Profile") { + VStack(alignment: .leading, spacing: 4) { + Text("Speicherort") + .font(.caption) + .foregroundStyle(.secondary) + Text(app.library.profileStorageURL.path) + .textSelection(.enabled) + .font(.system(.body, design: .monospaced)) + .lineLimit(3) + } + if let error = app.library.profileStorageError { + Label("Profilspeicher nicht erreichbar", systemImage: "externaldrive.badge.exclamationmark") + .foregroundStyle(.orange) + Text("\(error) Die vorhandenen Profile bleiben gespeichert; verbinde das Laufwerk oder wähle einen anderen Speicherort.") + .font(.caption) + .foregroundStyle(.secondary) + } + HStack { + Button("Speicherort ändern…", systemImage: "folder") { chooseProfileStorageLocation() } + if app.library.isProfileStorageCustom { + Button("Standardort verwenden", systemImage: "arrow.uturn.backward") { + changeProfileStorage(to: nil) + } + } + } + .disabled(app.library.isSyncing) + if app.library.isSyncing { + ProgressView(app.library.syncProgress) + .font(.caption) + } + if let profileStorageMessage { + Text(profileStorageMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + Text("Profile und ihre Audiodateien werden beim Wechsel vollständig kopiert. Ein externes Laufwerk muss beim Start verfügbar sein; nach dem erneuten Anschließen wird der Speicher automatisch neu eingelesen.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Musikerkennung") { + SecureField("AcoustID-Anwendungsschlüssel", text: $acoustIDClientKey) + .textContentType(.password) + HStack { + Button("Schlüssel speichern", systemImage: "key") { + do { + try AcoustIDCredentials.saveClientKey(acoustIDClientKey) + acoustIDKeyMessage = acoustIDClientKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Schlüssel entfernt." : "Schlüssel sicher im macOS-Schlüsselbund gespeichert." + } catch { acoustIDKeyMessage = error.localizedDescription } + } + if let acoustIDKeyMessage { Text(acoustIDKeyMessage).font(.caption).foregroundStyle(.secondary) } + } + Text("Registriere die App bei AcoustID und trage hier den Anwendungsschlüssel ein. Er wird im macOS-Schlüsselbund gespeichert.") + .font(.caption).foregroundStyle(.secondary) + Link("AcoustID-Anwendung registrieren", destination: URL(string: "https://acoustid.org/application")!) + } + Section("Updates") { + Text("Version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unbekannt")") + .foregroundStyle(.secondary) + Button("Auf Updates prüfen", systemImage: "arrow.clockwise") { AppDelegate.updater?.checkForUpdates(nil) } + .disabled(AppDelegate.updater?.updater.canCheckForUpdates != true) + } + Section("Über die App") { + Text("Tonuino Manager für macOS") + Text("Verwaltet TonUINO Musik-SD-Karten und programmiert RFID/NFC-Karten.").foregroundStyle(.secondary) + Link("TonUINO-TNG Firmware", destination: URL(string: "https://github.com/tonuino/TonUINO-TNG")!) + } + } + .formStyle(.grouped) + .padding(.top, 10) + .navigationTitle("Einstellungen") + .onAppear { acoustIDClientKey = AcoustIDCredentials.loadClientKey() ?? "" } + } + + private func chooseProfileStorageLocation() { + let panel = NSOpenPanel() + panel.title = "Speicherort für lokale Profile auswählen" + panel.prompt = "Diesen Ordner verwenden" + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.allowsMultipleSelection = false + if panel.runModal() == .OK, let url = panel.url { + changeProfileStorage(to: url) + } + } + + private func changeProfileStorage(to url: URL?) { + profileStorageMessage = nil + Task { + do { + try await app.library.changeProfileStorageBase(to: url) + profileStorageMessage = "Speicherort geändert." + } catch { + profileStorageMessage = error.localizedDescription + } + } + } +} diff --git a/Sources/TonuinoManager/UI/TonuinoSettingsView.swift b/Sources/TonuinoManager/UI/TonuinoSettingsView.swift new file mode 100644 index 0000000..4f37134 --- /dev/null +++ b/Sources/TonuinoManager/UI/TonuinoSettingsView.swift @@ -0,0 +1,244 @@ +import SwiftUI + +struct TonuinoSettingsView: View { + @EnvironmentObject private var app: AppModel + @State private var currentPIN = "" + @State private var newPIN = "" + @State private var minVolume = 5 + @State private var maxVolume = 25 + @State private var initialVolume = 15 + @State private var standbyOption = 5 + @State private var equalizerOption = 1 + @State private var pauseWhenRemoved = false + @State private var invertButtons = false + @State private var adminLockOption = 1 + @State private var showResetConfirmation = false + + private let equalizers = ["Normal", "Pop", "Rock", "Jazz", "Klassik", "Bass"] + + var body: some View { + Form { + Section("Verbindung zum TonUINO") { + HStack { + if app.cards.serialConnected { + Label(app.cards.firmwareVersion.map { "Verbunden · \($0)" } ?? "Verbunden · Firmwareversion nicht erkannt", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + Spacer() + if app.cards.serialAdminMenuActive { + Button("Admin-Menü beenden", systemImage: "stop.circle") { + do { try app.cards.exitTonuinoAdminMenu(); app.status = "TonUINO-Adminmenü beendet." } + catch { app.report(error) } + } + } + Button("Trennen") { app.cards.disconnectSerial() } + } else { + Picker("Serieller Port", selection: $app.cards.selectedSerialPort) { + ForEach(app.cards.serialPorts, id: \.self) { Text($0).tag($0) } + } + Button("Suchen", systemImage: "arrow.clockwise") { app.cards.refreshSerialPorts() } + Button("Verbinden") { + Task { + do { try await app.cards.connectSerial() } + catch { app.report(error) } + } + } + .disabled(app.cards.selectedSerialPort.isEmpty) + } + } + Text("USB-Menüsteuerung benötigt eine TonUINO-TNG-Firmware mit aktiviertem SerialInputAsCommand. Die Firmware muss im Leerlauf sein. Nach der ersten Änderung bleibt das Admin-Menü für weitere Einstellungen geöffnet; beende es danach hier.") + .font(.caption).foregroundStyle(.secondary) + } + + Section("Ausgelesene Geräteeinstellungen") { + if let settings = app.cards.tonuinoSettings { + Text("Beim Verbinden aus der Startmeldung gelesen. Änderungen, die du danach über das Sprachmenü vornimmst, erscheinen nach erneutem Verbinden.") + .font(.caption).foregroundStyle(.secondary) + if let version = settings.firmwareSettingsVersion { LabeledContent("Einstellungsformat", value: String(version)) } + settingsRow("Lautsprecher · Maximum", settings.speakerMaximum) + settingsRow("Lautsprecher · Minimum", settings.speakerMinimum) + settingsRow("Lautsprecher · Startlautstärke", settings.speakerInitial) + settingsRow("Kopfhörer · Maximum", settings.headphoneMaximum) + settingsRow("Kopfhörer · Minimum", settings.headphoneMinimum) + settingsRow("Kopfhörer · Startlautstärke", settings.headphoneInitial) + settingsRow("Equalizer (Firmwarewert)", settings.equalizer) + settingsRow("Abschalt-Timer (Firmwarewert)", settings.standbyTimer) + settingsRow("Lautstärketasten vertauscht (0/1)", settings.invertedVolumeButtons) + settingsRow("Admin-Menü gesperrt (0/1)", settings.adminMenuLocked) + settingsRow("Pause bei Kartenentfernung (0/1)", settings.pauseWhenCardRemoved) + } else if app.cards.serialConnected { + Text("Diese Firmware hat beim Verbinden keine auslesbaren Einstellungswerte ausgegeben. Das ist bei älteren AiO-Versionen möglich. Die Verbindung und die vorhandenen Menüsteuerungen bleiben davon unberührt.") + .font(.caption).foregroundStyle(.secondary) + } else { + Text("Verbinde den TonUINO, um die von der Firmware ausgegebenen Werte zu lesen.") + .font(.caption).foregroundStyle(.secondary) + } + } + + Section("Lautstärke") { + Text("Wähle die drei Lautstärkewerte aus. Auslesbare aktuelle Werte erscheinen im Abschnitt oben.") + .font(.caption).foregroundStyle(.secondary) + HStack { + volumePicker("Minimum", selection: $minVolume, values: 1...29) + volumePicker("Maximum", selection: $maxVolume, values: 2...30) + volumePicker("Startlautstärke", selection: $initialVolume, values: 1...30) + } + if !validVolume { + Text("Das Minimum muss kleiner als das Maximum sein, die Startlautstärke muss dazwischen liegen.") + .font(.caption).foregroundStyle(.red) + } + Button("Lautstärke übernehmen", systemImage: "speaker.wave.2") { applyVolume() } + .disabled(!app.cards.serialConnected || app.cards.configuringSettings || !validVolume) + } + + Section("Admin-Zugang") { + SecureField("Aktuelle Admin-PIN (optional)", text: $currentPIN) + .frame(maxWidth: 280) + Text("Lass das Feld leer, wenn das Admin-Menü nicht geschützt ist. Eine vorhandene Firmware-PIN besteht aus vier Tastenwerten 1, 2 oder 3.") + .font(.caption).foregroundStyle(.secondary) + } + + Section("Abschalt-Timer") { + Picker("TonUINO nach Inaktivität ausschalten", selection: $standbyOption) { + Text("5 Minuten").tag(1) + Text("15 Minuten").tag(2) + Text("30 Minuten").tag(3) + Text("60 Minuten").tag(4) + Text("Nie").tag(5) + } + HStack { + Button("Timer übernehmen", systemImage: "timer") { apply(menuItem: 8, option: standbyOption, message: "Abschalt-Timer an TonUINO übermittelt.") } + .disabled(!app.cards.serialConnected || app.cards.configuringSettings) + Spacer() + settingProgress + } + } + + Section("Wiedergabe") { + Picker("Equalizer", selection: $equalizerOption) { + ForEach(equalizers.indices, id: \.self) { index in Text(equalizers[index]).tag(index + 1) } + } + HStack { + Button("Equalizer übernehmen", systemImage: "slider.horizontal.3") { apply(menuItem: 5, option: equalizerOption, message: "Equalizer an TonUINO übermittelt.") } + .disabled(!app.cards.serialConnected || app.cards.configuringSettings) + Spacer() + settingProgress + } + Toggle("Bei Entfernen der Karte pausieren", isOn: $pauseWhenRemoved) + Button("Wiedergabeoption übernehmen", systemImage: "playpause") { + apply(menuItem: 13, option: pauseWhenRemoved ? 2 : 1, message: "Verhalten beim Entfernen der Karte an TonUINO übermittelt.") + } + .disabled(!app.cards.serialConnected || app.cards.configuringSettings) + } + + Section("Tasten") { + Toggle("Lautstärketasten vertauschen", isOn: $invertButtons) + Button("Tastenoption übernehmen", systemImage: "arrow.up.arrow.down") { + apply(menuItem: 10, option: invertButtons ? 2 : 1, message: "Tastenoption an TonUINO übermittelt.") + } + .disabled(!app.cards.serialConnected || app.cards.configuringSettings) + } + + Section("Admin-Menü") { + Picker("Zugangsschutz", selection: $adminLockOption) { + Text("Entsperrt").tag(1) + Text("Gesperrt, ohne PIN").tag(2) + Text("Gesperrt, mit neuer PIN").tag(3) + } + if adminLockOption == 3 { + SecureField("Neue 4-stellige PIN (nur 1, 2, 3)", text: $newPIN) + .frame(maxWidth: 280) + } + Button("Zugangsschutz übernehmen", systemImage: "lock") { + apply(menuItem: 12, option: adminLockOption, message: "Zugangsschutz an TonUINO übermittelt.", newPIN: adminLockOption == 3 ? newPIN : "") + } + .disabled(!app.cards.serialConnected || app.cards.configuringSettings || (adminLockOption == 3 && (newPIN.count != 4 || !newPIN.allSatisfy({ ["1", "2", "3"].contains(String($0)) })))) + } + + Section("Zurücksetzen") { + Text("Setzt die in der TonUINO-Firmware gespeicherten Einstellungen auf Werkseinstellungen zurück.") + .font(.caption).foregroundStyle(.secondary) + Button("TonUINO-Einstellungen zurücksetzen…", systemImage: "arrow.counterclockwise", role: .destructive) { + showResetConfirmation = true + } + .disabled(!app.cards.serialConnected || app.cards.configuringSettings) + } + + Section { + Text("Die App steuert Änderungen über das Admin-Sprachmenü der Firmware. Folge den TonUINO-Audioansagen und achte auf die Bestätigung. Das automatische Auslesen hängt davon ab, ob die Firmware ihre Einstellungen beim Start über USB meldet.") + .font(.caption).foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .padding(.top, 10) + .navigationTitle("TonUINO-Einstellungen") + .onAppear { app.cards.refreshSerialPorts() } + .confirmationDialog("TonUINO-Einstellungen zurücksetzen?", isPresented: $showResetConfirmation, titleVisibility: .visible) { + Button("Einstellungen zurücksetzen", role: .destructive) { + Task { + do { + try await app.cards.resetTonuinoSettings(currentPIN: currentPIN) + app.status = "TonUINO-Einstellungen wurden zurückgesetzt." + } catch { app.report(error) } + } + } + Button("Abbrechen", role: .cancel) { } + } message: { + Text("Alle TonUINO-Einstellungen im EEPROM werden gelöscht. Die SD-Karte und NFC-Karten bleiben unverändert.") + } + .overlay { + if app.cards.configuringSettings { + ProgressView("Einstellungen werden übertragen…") + .padding(18) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + } + } + + private var settingProgress: some View { + Group { + if app.cards.configuringSettings { ProgressView().controlSize(.small) } + } + } + + private var validVolume: Bool { + return minVolume < maxVolume && (minVolume...maxVolume).contains(initialVolume) + } + + private func volumePicker(_ title: String, selection: Binding, values: ClosedRange) -> some View { + Picker(title, selection: selection) { + ForEach(values, id: \.self) { value in + Text(String(value)).tag(value) + } + } + .frame(maxWidth: 180) + } + + @ViewBuilder + private func settingsRow(_ title: String, _ value: Int?) -> some View { + if let value { + LabeledContent(title, value: String(value)) + } + } + + private func applyVolume() { + Task { + do { + try await app.cards.applyTonuinoVolume(minimum: minVolume, maximum: maxVolume, initial: initialVolume, currentPIN: currentPIN) + app.status = "Lautstärkeeinstellungen an TonUINO übermittelt." + } catch { app.report(error) } + } + } + + private func apply(menuItem: Int, option: Int, message: String, newPIN: String = "") { + Task { + do { + try await app.cards.applyTonuinoSetting(menuItem: menuItem, option: option, currentPIN: currentPIN, newPIN: newPIN) + if menuItem == 12 { + currentPIN = option == 3 ? newPIN : "" + } + app.status = message + } catch { app.report(error) } + } + } + +} diff --git a/Sources/TonuinoManager/UI/YouTubeImportView.swift b/Sources/TonuinoManager/UI/YouTubeImportView.swift new file mode 100644 index 0000000..486312f --- /dev/null +++ b/Sources/TonuinoManager/UI/YouTubeImportView.swift @@ -0,0 +1,133 @@ +import SwiftUI + +struct YouTubeImportView: View { + let folderNumber: Int + let onComplete: () -> Void + @EnvironmentObject private var app: AppModel + @Environment(\.dismiss) private var dismiss + @State private var urlText = "" + @State private var video: YouTubeVideoInfo? + @State private var splitByChapters = false + @State private var isInspecting = false + @State private var isImporting = false + @State private var imported = false + @State private var errorMessage: String? + + private var chapters: [YouTubeChapter] { video?.usableChapters ?? [] } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("YouTube-Audio importieren").font(.title2.bold()) + Text("Ziel: Ordner \(String(format: "%02d", folderNumber))") + .foregroundStyle(.secondary) + } + + HStack { + TextField("https://www.youtube.com/watch?v=…", text: $urlText) + .textFieldStyle(.roundedBorder) + .onSubmit { inspectVideo() } + .onChange(of: urlText) { _ in video = nil; imported = false; errorMessage = nil } + Button("Video prüfen", systemImage: "magnifyingglass") { inspectVideo() } + .disabled(isInspecting || isImporting || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + if isInspecting { + ProgressView("YouTube-Video wird analysiert…") + } else if let video { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text(video.title).font(.headline).lineLimit(2) + Text([video.artist.isEmpty ? nil : video.artist, durationText(video.duration)].compactMap { $0 }.joined(separator: " · ")) + .font(.caption).foregroundStyle(.secondary) + } + Picker("Importart", selection: $splitByChapters) { + Text("Gesamtes Video als eine MP3").tag(false) + Text("Einzeldatei je Kapitel (\(chapters.count))").tag(true).disabled(chapters.isEmpty) + } + if chapters.isEmpty { + Text("Keine Kapitel gefunden. Das Video kann als eine Datei importiert werden.") + .font(.caption).foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 4) { + Text("Kapitelvorschau").font(.subheadline.weight(.semibold)) + ForEach(Array(chapters.indices.prefix(6)), id: \.self) { index in + let chapter = chapters[index] + HStack(spacing: 8) { + Text(String(format: "%02d", index + 1)).foregroundStyle(.secondary).monospacedDigit() + Text(chapter.title?.isEmpty == false ? chapter.title! : "Kapitel \(index + 1)").lineLimit(1) + Spacer() + Text(durationText((chapter.end ?? video.duration).map { $0 - chapter.start })) + .foregroundStyle(.secondary).monospacedDigit() + }.font(.caption) + } + if chapters.count > 6 { Text("und \(chapters.count - 6) weitere Kapitel…").font(.caption).foregroundStyle(.secondary) } + } + .padding(12) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 8)) + } + } + } + + if isImporting { + ProgressView("Audio wird heruntergeladen, konvertiert und importiert…") + } else if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .font(.callout).foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) + } else if imported { + Label("Audio wurde in Ordner \(String(format: "%02d", folderNumber)) importiert.", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } + + Spacer(minLength: 0) + HStack { + Text("yt-dlp lädt die Audiospur; FFmpeg erstellt MP3-Dateien mit Titel- und Interpret-Tags.") + .font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading) + Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Importieren", systemImage: "arrow.down.to.line") { importVideo() } + .buttonStyle(.borderedProminent) + .disabled(video == nil || isInspecting || isImporting || (splitByChapters && chapters.isEmpty)) + } + } + .padding(22) + .frame(width: 640, height: 540) + } + + private func inspectVideo() { + let value = urlText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return } + isInspecting = true; errorMessage = nil; imported = false + Task { + do { + let result = try await Task.detached(priority: .userInitiated) { try YouTubeImportService.inspect(value) }.value + video = result + splitByChapters = false + isInspecting = false + } catch { + errorMessage = error.localizedDescription + isInspecting = false + } + } + } + + private func importVideo() { + guard let video else { return } + isImporting = true; errorMessage = nil; imported = false + Task { + do { + try await app.library.importYouTube(video, splitByChapters: splitByChapters, to: folderNumber) + isImporting = false; imported = true + app.status = "YouTube-Audio in Ordner \(String(format: "%02d", folderNumber)) importiert" + onComplete() + } catch { + isImporting = false; errorMessage = error.localizedDescription + } + } + } + + private func durationText(_ seconds: Double?) -> String { + guard let seconds, seconds.isFinite, seconds >= 0 else { return "" } + let total = Int(seconds.rounded()) + return total >= 3600 ? String(format: "%d:%02d:%02d", total / 3600, (total / 60) % 60, total % 60) : String(format: "%d:%02d", total / 60, total % 60) + } +} diff --git a/appcast.xml b/appcast.xml new file mode 100644 index 0000000..749dc42 --- /dev/null +++ b/appcast.xml @@ -0,0 +1,9 @@ + + + + Tonuino Manager Updates + https://tonuino.maddin.app/ + Updates for Tonuino Manager for macOS + de + + diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..1a3969c --- /dev/null +++ b/project.yml @@ -0,0 +1,40 @@ +name: TonuinoManager +options: + bundleIdPrefix: earth.voss + deploymentTarget: + macOS: "13.0" + createIntermediateGroups: true +settings: + base: + SWIFT_VERSION: "5.0" + MACOSX_DEPLOYMENT_TARGET: "13.0" + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + PRODUCT_NAME: Tonuino Manager + CODE_SIGN_ENTITLEMENTS: Sources/TonuinoManager/TonuinoManager.entitlements +targets: + TonuinoManager: + type: application + platform: macOS + sources: + - Sources/TonuinoManager + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: app.maddin.tonuino + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: Tonuino Manager + INFOPLIST_KEY_CFBundleShortVersionString: "$(MARKETING_VERSION)" + INFOPLIST_KEY_CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.music + INFOPLIST_KEY_NSAppleEventsUsageDescription: The app uses attached TonUINO hardware to program NFC cards. + INFOPLIST_KEY_SUFeedURL: "https://git.maddin.app/maddin/Tonuino-Manager/raw/branch/main/appcast.xml" + INFOPLIST_KEY_SUPublicEDKey: "5sZRW8IyZ+8qULrkYlAKLez/ENHUzJr10yQXARLRnL8=" + ENABLE_HARDENED_RUNTIME: YES + CODE_SIGN_STYLE: Automatic + dependencies: + - package: Sparkle + product: Sparkle +packages: + Sparkle: + url: https://github.com/sparkle-project/Sparkle + from: 2.6.4 diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..5027153 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,62 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/.build" +ARCHIVE_PATH="$BUILD_DIR/TonuinoManager.xcarchive" +APP_VERSION="${APP_VERSION:-1.0.0}" +APP_BUILD="${APP_BUILD:-1}" + +: "${DEVELOPMENT_TEAM:?Set DEVELOPMENT_TEAM to the Apple Developer Team ID}" +: "${DEVELOPER_ID_APPLICATION:?Set DEVELOPER_ID_APPLICATION to the Developer ID Application signing identity}" +: "${NOTARY_PROFILE:?Set NOTARY_PROFILE to a stored notarytool keychain profile}" +: "${FFMPEG_BINARY:?Set FFMPEG_BINARY to a self-contained universal FFmpeg executable with libmp3lame enabled}" +: "${YTDLP_BINARY:?Set YTDLP_BINARY to a standalone universal yt-dlp executable}" +: "${DENO_BINARY:?Set DENO_BINARY to a universal Deno executable for YouTube JavaScript challenges}" +: "${FPCALC_BINARY:?Set FPCALC_BINARY to the universal Chromaprint fpcalc executable}" +[[ -x "$FFMPEG_BINARY" ]] || { echo "FFMPEG_BINARY is not executable" >&2; exit 1; } +[[ -x "$YTDLP_BINARY" ]] || { echo "YTDLP_BINARY is not executable" >&2; exit 1; } +[[ -x "$DENO_BINARY" ]] || { echo "DENO_BINARY is not executable" >&2; exit 1; } +[[ -x "$FPCALC_BINARY" ]] || { echo "FPCALC_BINARY is not executable" >&2; exit 1; } +for TOOL_BINARY in "$FFMPEG_BINARY" "$YTDLP_BINARY" "$DENO_BINARY" "$FPCALC_BINARY"; do + TOOL_ARCHS="$(lipo -archs "$TOOL_BINARY")" + [[ "$TOOL_ARCHS" == *arm64* && "$TOOL_ARCHS" == *x86_64* ]] || { echo "$TOOL_BINARY must contain arm64 and x86_64 slices" >&2; exit 1; } +done + +cd "$ROOT_DIR" +xcodegen generate +xcodebuild -project TonuinoManager.xcodeproj -scheme TonuinoManager -configuration Release \ + -archivePath "$ARCHIVE_PATH" -derivedDataPath "$BUILD_DIR/DerivedData" \ + -clonedSourcePackagesDirPath "$BUILD_DIR/DerivedData/SourcePackages" \ + -destination 'generic/platform=macOS' \ + ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \ + CODE_SIGN_IDENTITY="$DEVELOPER_ID_APPLICATION" MARKETING_VERSION="$APP_VERSION" \ + CURRENT_PROJECT_VERSION="$APP_BUILD" archive + +APP_PATH="$(find "$ARCHIVE_PATH/Products/Applications" -maxdepth 1 -type d -name '*.app' -print -quit)" +[[ -n "$APP_PATH" ]] || { echo "App bundle is missing from the archive" >&2; exit 1; } + +mkdir -p "$APP_PATH/Contents/Resources" +install -m 755 "$FFMPEG_BINARY" "$APP_PATH/Contents/Resources/ffmpeg" +install -m 755 "$YTDLP_BINARY" "$APP_PATH/Contents/Resources/yt-dlp" +install -m 755 "$DENO_BINARY" "$APP_PATH/Contents/Resources/deno" +install -m 755 "$FPCALC_BINARY" "$APP_PATH/Contents/Resources/fpcalc" +codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID_APPLICATION" "$APP_PATH/Contents/Resources/ffmpeg" +codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID_APPLICATION" "$APP_PATH/Contents/Resources/yt-dlp" +codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID_APPLICATION" "$APP_PATH/Contents/Resources/deno" +codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID_APPLICATION" "$APP_PATH/Contents/Resources/fpcalc" +codesign --force --deep --options runtime --timestamp --entitlements Sources/TonuinoManager/TonuinoManager.entitlements \ + --sign "$DEVELOPER_ID_APPLICATION" "$APP_PATH" + +mkdir -p "$BUILD_DIR/release" +NOTARY_ZIP="$BUILD_DIR/Tonuino-Manager-notary.zip" +ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$NOTARY_ZIP" +xcrun notarytool submit "$NOTARY_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait +xcrun stapler staple "$APP_PATH" +rm -f "$NOTARY_ZIP" +ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$BUILD_DIR/release/Tonuino-Manager-$APP_VERSION.zip" +hdiutil create -volname 'Tonuino Manager' -srcfolder "$APP_PATH" -ov -format UDZO \ + "$BUILD_DIR/release/Tonuino-Manager-$APP_VERSION-universal.dmg" +xcrun notarytool submit "$BUILD_DIR/release/Tonuino-Manager-$APP_VERSION-universal.dmg" --keychain-profile "$NOTARY_PROFILE" --wait +xcrun stapler staple "$BUILD_DIR/release/Tonuino-Manager-$APP_VERSION-universal.dmg" +echo "Created $BUILD_DIR/release/Tonuino-Manager-$APP_VERSION-universal.dmg and Sparkle ZIP" diff --git a/scripts/bundle-local-media-tools.sh b/scripts/bundle-local-media-tools.sh new file mode 100755 index 0000000..edcfb56 --- /dev/null +++ b/scripts/bundle-local-media-tools.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail + +if [[ $# -ne 5 ]]; then + echo "Usage: $0 /path/to/App.app /path/to/ffmpeg /path/to/yt-dlp /path/to/deno /path/to/fpcalc" >&2 + exit 2 +fi + +APP_PATH="$1" +FFMPEG_BINARY="$2" +YTDLP_BINARY="$3" +DENO_BINARY="$4" +FPCALC_BINARY="$5" + +[[ -d "$APP_PATH/Contents" ]] || { echo "App bundle not found: $APP_PATH" >&2; exit 1; } +for TOOL_BINARY in "$FFMPEG_BINARY" "$YTDLP_BINARY" "$DENO_BINARY" "$FPCALC_BINARY"; do + [[ -x "$TOOL_BINARY" ]] || { echo "Tool is not executable: $TOOL_BINARY" >&2; exit 1; } +done + +FFMPEG_ENCODERS="$("$FFMPEG_BINARY" -hide_banner -encoders 2>/dev/null)" +[[ "$FFMPEG_ENCODERS" == *libmp3lame* ]] || { echo "FFmpeg must include libmp3lame" >&2; exit 1; } + +RESOURCES="$APP_PATH/Contents/Resources" +mkdir -p "$RESOURCES" +install -m 755 "$FFMPEG_BINARY" "$RESOURCES/ffmpeg" +install -m 755 "$YTDLP_BINARY" "$RESOURCES/yt-dlp" +install -m 755 "$DENO_BINARY" "$RESOURCES/deno" +install -m 755 "$FPCALC_BINARY" "$RESOURCES/fpcalc" +codesign --force --deep --sign - "$APP_PATH" +echo "Bundled FFmpeg, yt-dlp, Deno and fpcalc into $APP_PATH" diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh new file mode 100755 index 0000000..16581d4 --- /dev/null +++ b/scripts/publish-release.sh @@ -0,0 +1,108 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/.build" +RELEASE_DIR="$BUILD_DIR/release" +APP_VERSION="${APP_VERSION:?Set APP_VERSION, for example 1.0.1}" +APP_BUILD="${APP_BUILD:?Set APP_BUILD to a monotonically increasing build number}" +GITEA_TOKEN="${GITEA_TOKEN:?Set GITEA_TOKEN with permission to create releases}" +GITEA_API="${GITEA_API:-https://git.maddin.app/api/v1}" +GITEA_REPO="${GITEA_REPO:-maddin/Tonuino-Manager}" +WEBSITE_HOST="${WEBSITE_HOST:-mln}" +WEBSITE_ROOT="${WEBSITE_ROOT:-/home/maddin/docker/tonuino-manager/site}" +FEED_URL="https://git.maddin.app/maddin/Tonuino-Manager/raw/branch/main/appcast.xml" +TAG="v$APP_VERSION" +ZIP_PATH="$RELEASE_DIR/Tonuino-Manager-$APP_VERSION.zip" +DMG_PATH="$RELEASE_DIR/Tonuino-Manager-$APP_VERSION-universal.dmg" +SIGN_UPDATE="${SPARKLE_SIGN_UPDATE:-$BUILD_DIR/DerivedData/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update}" + +[[ -f "$ZIP_PATH" ]] || { echo "Missing Sparkle archive: $ZIP_PATH" >&2; exit 1; } +[[ -f "$DMG_PATH" ]] || { echo "Missing installer: $DMG_PATH" >&2; exit 1; } +[[ -x "$SIGN_UPDATE" ]] || { echo "Missing Sparkle sign_update: $SIGN_UPDATE" >&2; exit 1; } +command -v rsync >/dev/null || { echo "rsync is required" >&2; exit 1; } +command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 1; } + +cd "$ROOT_DIR" +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "Run this from a Git checkout" >&2; exit 1; } +[[ "$(git branch --show-current)" == main ]] || { echo "Publish from the main branch" >&2; exit 1; } +[[ -z "$(git status --porcelain)" ]] || { echo "Commit or discard local changes before publishing" >&2; exit 1; } +git remote get-url origin >/dev/null 2>&1 || { echo "Configure origin to the Gitea repository first" >&2; exit 1; } + +if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + echo "Release tag $TAG already exists" >&2 + exit 1 +fi + +SIGNATURE="$("$SIGN_UPDATE" -p "$ZIP_PATH")" +ZIP_LENGTH="$(stat -f '%z' "$ZIP_PATH")" +RELEASE_URL="https://git.maddin.app/$GITEA_REPO/releases/download/$TAG/$(basename "$ZIP_PATH")" +APPCAST_TMP="$(mktemp)" +trap 'rm -f "$APPCAST_TMP"' EXIT +python3 - "$APPCAST_TMP" "$APP_VERSION" "$APP_BUILD" "$RELEASE_URL" "$ZIP_LENGTH" "$SIGNATURE" <<'PY' +import datetime, html, sys +path, version, build, url, length, signature = sys.argv[1:] +pubdate = datetime.datetime.now(datetime.timezone.utc).strftime("%a, %d %b %Y %H:%M:%S +0000") +item = f''' + Version {html.escape(version)} + {pubdate} + {html.escape(build)} + {html.escape(version)} + + ''' +xml = f''' + + + Tonuino Manager Updates + https://tonuino.maddin.app/ + Updates for Tonuino Manager for macOS + de + {item} + + +''' +open(path, "w", encoding="utf-8").write(xml) +PY + +RELEASE_JSON="$(python3 - "$TAG" <<'PY' +import json, sys +tag = sys.argv[1] +print(json.dumps({"tag_name": tag, "target_commitish": "main", "name": "Tonuino Manager " + tag, "body": "Universal macOS-Version mit automatischen Sparkle-Updates.", "draft": False, "prerelease": False})) +PY +)" +RELEASE_RESPONSE="$(curl --fail-with-body --silent --show-error \ + -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \ + -d "$RELEASE_JSON" "$GITEA_API/repos/$GITEA_REPO/releases")" +RELEASE_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$RELEASE_RESPONSE")" + +upload_asset() { + local file="$1" + curl --fail-with-body --silent --show-error -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -F "attachment=@$file" \ + "$GITEA_API/repos/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$(basename "$file")" >/dev/null +} +upload_asset "$ZIP_PATH" + +cp "$APPCAST_TMP" "$ROOT_DIR/appcast.xml" +git add appcast.xml +git commit -m "Publish Sparkle appcast for $TAG" +git push origin main + +SITE_STAGE="$(mktemp -d)" +trap 'rm -f "$APPCAST_TMP"; rm -rf "$SITE_STAGE"' EXIT +cp "$ROOT_DIR/website/icon.png" "$SITE_STAGE/icon.png" +python3 - "$ROOT_DIR/website/index.html.template" "$SITE_STAGE/index.html" "$APP_VERSION" <<'PY' +import html, sys +source, target, version = sys.argv[1:] +page = open(source, encoding="utf-8").read() +link = '↓  App für macOS laden' +page = page.replace("", link) +page = page.replace("", html.escape(version)) +open(target, "w", encoding="utf-8").write(page) +PY +mkdir -p "$SITE_STAGE/downloads" +cp "$DMG_PATH" "$SITE_STAGE/downloads/Tonuino-Manager.dmg" +ssh "$WEBSITE_HOST" "mkdir -p '$WEBSITE_ROOT/downloads'" +rsync -az --delete "$SITE_STAGE/" "$WEBSITE_HOST:$WEBSITE_ROOT/" +echo "Published $TAG, Sparkle feed, and website download." diff --git a/website/icon.png b/website/icon.png new file mode 100644 index 0000000..78cda91 Binary files /dev/null and b/website/icon.png differ diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..1a9e3db --- /dev/null +++ b/website/index.html @@ -0,0 +1,36 @@ + + + + + + + + Tonuino Manager für macOS + + + + +
+ Tonuino Manager App Icon +

Tonuino Manager

+

Musik auf der TonUINO-Karte organisieren, Metadaten pflegen und NFC-Karten erstellen – direkt auf dem Mac.

+ Erster Download folgt +
Version Noch kein Release · macOS 13 oder neuer · Apple Silicon und Intel
+ +
+ + diff --git a/website/index.html.template b/website/index.html.template new file mode 100644 index 0000000..a0eba76 --- /dev/null +++ b/website/index.html.template @@ -0,0 +1,36 @@ + + + + + + + + Tonuino Manager für macOS + + + + +
+ Tonuino Manager App Icon +

Tonuino Manager

+

Musik auf der TonUINO-Karte organisieren, Metadaten pflegen und NFC-Karten erstellen – direkt auf dem Mac.

+ +
Version · macOS 13 oder neuer · Apple Silicon und Intel
+ +
+ +