import Foundation import SwiftUI import UniformTypeIdentifiers /// What an export file holds: stem playlists (with each song's mix and any cover picked from /// Photos), each song's saved mix, custom presets and the preset bar, and the non-secret sound /// settings. Plex and stem server tokens and URLs are never included. struct StemifyExport: Codable { static let format = "stemify-export" /// 2 added song mixes, presets and playlist covers. Version 1 files still import. static let version = 2 struct Prefs: Codable { var stemQuality: String? var keepStemMix: Bool? var karaokeVocals: Int? var karaokeSoftDb: Int? var playbackSpeed: Double? var downloadsWifiOnly: Bool? var presets: String? var presetOrder: String? var hiddenPresets: String? var lockedPreset: String? } var format = StemifyExport.format var version = StemifyExport.version var exported = Date() var playlists: [StemPlaylist] var prefs: Prefs /// Saved per-song mixes, by rating key. var songMixes: [String: [String: StemSetting]]? /// Covers picked from Photos ("local:"), base64 by file name. var playlistArt: [String: String]? @MainActor static func capture(_ lists: StemPlaylists, _ pm: PlaybackManager, _ s: Settings) -> StemifyExport { var art: [String: String] = [:] for pl in lists.lists { guard let t = pl.artThumb, let u = StemPlaylists.localArt(t), let d = try? Data(contentsOf: u) else { continue } art[u.lastPathComponent] = d.base64EncodedString() } return StemifyExport(playlists: lists.lists, prefs: Prefs( stemQuality: s.stemQuality.rawValue, keepStemMix: s.keepStemMix, karaokeVocals: s.karaokeVocals, karaokeSoftDb: s.karaokeSoftDb, playbackSpeed: s.playbackSpeed, downloadsWifiOnly: s.downloadsWifiOnly, presets: s.presets, presetOrder: s.presetOrder, hiddenPresets: s.hiddenPresets, lockedPreset: s.lockedPreset), songMixes: pm.songMixes, playlistArt: art) } func encoded() throws -> Data { let e = JSONEncoder() e.outputFormatting = [.prettyPrinted, .sortedKeys] e.dateEncodingStrategy = .iso8601 return try e.encode(self) } static func decode(_ data: Data) throws -> StemifyExport { let d = JSONDecoder() d.dateDecodingStrategy = .iso8601 let x = try d.decode(StemifyExport.self, from: data) guard x.format == Self.format else { throw BackupError.notAnExport } guard x.version <= Self.version else { throw BackupError.newerVersion } return x } /// Restores everything: playlists and song mixes from the file replace the ones with the same /// id or song, presets merge by name, and the rest is left alone. @MainActor func restore(_ lists: StemPlaylists, _ pm: PlaybackManager, _ s: Settings) -> (playlists: Int, mixes: Int) { if let art = playlistArt, !art.isEmpty { let dir = StemPlaylists.artDir try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) for (name, b64) in art where !name.isEmpty && !name.contains("/") { if let d = Data(base64Encoded: b64) { try? d.write(to: dir.appendingPathComponent(name), options: .atomic) } } } let n = lists.restore(playlists) let m = songMixes ?? [:] pm.importSongMixes(m) apply(to: s) return (n, m.count) } @MainActor private func apply(to s: Settings) { if let v = prefs.stemQuality.flatMap(StemQuality.init(rawValue:)) { s.stemQuality = v } if let v = prefs.keepStemMix { s.keepStemMix = v } if let v = prefs.karaokeVocals { s.karaokeVocals = v } if let v = prefs.karaokeSoftDb { s.karaokeSoftDb = v } if let v = prefs.playbackSpeed { s.playbackSpeed = v } if let v = prefs.downloadsWifiOnly { s.downloadsWifiOnly = v } if let v = prefs.presets { // Merge by name: the file's presets win, ones only on this phone stay. let incoming = (v.data(using: .utf8)).flatMap { try? JSONDecoder().decode([CustomPreset].self, from: $0) } ?? [] let names = Set(incoming.map(\.name)) let merged = CustomPreset.load(s).filter { !names.contains($0.name) } + incoming if let d = try? JSONEncoder().encode(merged), let str = String(data: d, encoding: .utf8) { s.presets = str } } if let v = prefs.presetOrder, !v.isEmpty { s.presetOrder = v } if let v = prefs.hiddenPresets { s.hiddenPresets = v } if let v = prefs.lockedPreset { s.lockedPreset = v } } } enum BackupError: LocalizedError { case notAnExport, newerVersion var errorDescription: String? { switch self { case .notAnExport: "That file isn't a Stemify export." case .newerVersion: "That export is from a newer version of Stemify." } } } /// Where an export goes and comes from. The Files sheet covers local storage and iCloud Drive; /// a direct iCloud (ubiquity container / CloudKit) backend would be another conformer. protocol BackupDestination { func write(_ data: Data, name: String) async throws func read() async throws -> Data } /// The export as a document for fileExporter, which also reaches iCloud Drive through Files. struct ExportDocument: FileDocument { static var readableContentTypes: [UTType] { [.json] } var data: Data init(data: Data = Data()) { self.data = data } init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) } }