Companion API (4 new endpoints): - GET /lyrics/search — proxy to LRCLIB, returns matches with sync status - GET /lyrics/fetch — exact match by artist/title/duration, caches in SQLite - GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache - POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar - New lyrics table in companion database iOS data layer (LyricsModels.swift): - LyricLine/LyricWord structs with Codable conformance - Full LRC parser (line-level + enhanced word-level timestamps) - LRC serializer (toLRC) for saving edited timings - LyricsCache with memory + disk tiers keyed by artist-title iOS manager (LyricsManager.swift): - Source pipeline: embedded > .lrc sidecar > local cache > LRCLIB auto-fetch - Binary search line/word tracking (O(log n)) - Word-level progress for karaoke gradient fill - Hooked into AudioPlayer time observer (0.5s sync) and pushWidgetState (song change) iOS views: - LyricsOverlayView: karaoke word-by-word highlighting via FlowLayout + KaraokeWordView gradient fill sweep, ScrollViewReader auto-scroll, tap any line to seek - LyricsSearchSheet: LRCLIB search with debounced input, duration mismatch warnings, preview sheet, import to cache - LyricsEditorView: tap-to-sync mode, per-line ±0.1s adjust buttons, global offset sheet, save to device or embed in file via Companion API NowPlayingView integration: - Lyrics toggle button (quote.bubble) in bottom controls bar - Portrait layout swaps album art for lyrics overlay with animation - Blur panel: .ultraThinMaterial with gradient mask so visualizer fades through - Lyrics search/editor sheets wired with notification observer Mini player lyric ticker: - Both standard and compact mini player bars show current lyric line - Accent pink with ♪ prefix, falls back to artist name when no lyrics - Push transition animation on line changes Bug fixes included: - NavidromePlayerApp: removed unnecessary try on CompanionAPIService.shared - Info.plist: added LSSupportsOpeningDocumentsInPlace for document type support
384 lines
18 KiB
Swift
384 lines
18 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import ZIPFoundation
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// BackupManager.swift
|
|
// Export and import .nvdbackup files (zip archives) containing all app
|
|
// state except server passwords and downloaded audio files.
|
|
//
|
|
// Export: collects UserDefaults, server configs (passwords stripped),
|
|
// DJ profiles, custom covers, pending ops → zip → share sheet.
|
|
// Import: unzips, validates manifest, restores everything, marks
|
|
// servers as needing re-authentication.
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
struct BackupManifest: Codable {
|
|
let version: Int // backup format version
|
|
let appVersion: String
|
|
let exportDate: Date
|
|
let deviceName: String
|
|
let deviceModel: String
|
|
|
|
static let currentVersion = 1
|
|
}
|
|
|
|
class BackupManager {
|
|
static let shared = BackupManager()
|
|
private init() {}
|
|
|
|
enum BackupError: LocalizedError {
|
|
case noData
|
|
case invalidManifest
|
|
case versionMismatch(Int)
|
|
case zipCreationFailed
|
|
case missingFile(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .noData: return "No backup data found"
|
|
case .invalidManifest: return "Invalid backup file — missing manifest"
|
|
case .versionMismatch(let v): return "Unsupported backup version: \(v)"
|
|
case .zipCreationFailed: return "Failed to create backup archive"
|
|
case .missingFile(let f): return "Missing file in backup: \(f)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - UserDefaults keys to backup
|
|
|
|
private let settingsKeys: [String] = [
|
|
"cache_bitrate", "cache_format",
|
|
"cell_bitrate", "cell_format",
|
|
"wifi_bitrate", "wifi_format",
|
|
"scrobble_enabled",
|
|
"debug_console_enabled", "debug_track_view_bodies",
|
|
"smart_crossfade_enabled", "smart_crossfade_duration",
|
|
"smart_skip_silence", "smart_target_lufs",
|
|
]
|
|
|
|
private let companionKeys: [String] = [
|
|
"companion_enabled", "companion_host", "companion_port",
|
|
"companion_smart_dj",
|
|
]
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// MARK: - EXPORT
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
/// Build a .nvdbackup file and return its URL for the share sheet.
|
|
func exportBackup() throws -> URL {
|
|
let fm = FileManager.default
|
|
let tempDir = fm.temporaryDirectory.appendingPathComponent("nvdbackup_\(UUID().uuidString)")
|
|
try fm.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
|
|
defer { try? fm.removeItem(at: tempDir) }
|
|
|
|
// 1. Manifest
|
|
let manifest = BackupManifest(
|
|
version: BackupManifest.currentVersion,
|
|
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0",
|
|
exportDate: Date(),
|
|
deviceName: UIDevice.current.name,
|
|
deviceModel: UIDevice.current.model
|
|
)
|
|
try writeJSON(manifest, to: tempDir.appendingPathComponent("manifest.json"))
|
|
|
|
// 2. Server configs — decode, strip passwords, re-encode
|
|
if let serverData = UserDefaults.standard.data(forKey: "navidrome_servers"),
|
|
var servers = try? JSONDecoder().decode([ServerConfig].self, from: serverData) {
|
|
for i in servers.indices { servers[i].password = "" }
|
|
let strippedData = try JSONEncoder().encode(servers)
|
|
try strippedData.write(to: tempDir.appendingPathComponent("servers.json"))
|
|
}
|
|
if let activeData = UserDefaults.standard.data(forKey: "navidrome_active_server"),
|
|
var active = try? JSONDecoder().decode(ServerConfig.self, from: activeData) {
|
|
active.password = ""
|
|
let strippedData = try JSONEncoder().encode(active)
|
|
try strippedData.write(to: tempDir.appendingPathComponent("active_server.json"))
|
|
}
|
|
|
|
// 3. App settings
|
|
var settings: [String: Any] = [:]
|
|
for key in settingsKeys {
|
|
if let val = UserDefaults.standard.object(forKey: key) {
|
|
settings[key] = val
|
|
}
|
|
}
|
|
let settingsData = try JSONSerialization.data(withJSONObject: settings, options: .prettyPrinted)
|
|
try settingsData.write(to: tempDir.appendingPathComponent("settings.json"))
|
|
|
|
// 4. Companion settings
|
|
var companion: [String: Any] = [:]
|
|
for key in companionKeys {
|
|
if let val = UserDefaults.standard.object(forKey: key) {
|
|
companion[key] = val
|
|
}
|
|
}
|
|
let companionData = try JSONSerialization.data(withJSONObject: companion, options: .prettyPrinted)
|
|
try companionData.write(to: tempDir.appendingPathComponent("companion_settings.json"))
|
|
|
|
// 5. Visualizer settings
|
|
let visSettings = VisualizerSettings.shared
|
|
let visDict: [String: Any] = [
|
|
"enabled": visSettings.enabled,
|
|
"nowPlayingEnabled": visSettings.nowPlayingEnabled,
|
|
"miniPlayerEnabled": visSettings.miniPlayerEnabled,
|
|
"realAudioAnalysis": visSettings.realAudioAnalysis,
|
|
"dynamicGainEnabled": visSettings.dynamicGainEnabled,
|
|
"fps": visSettings.fps,
|
|
"viscosity": visSettings.viscosity,
|
|
"frequencyCutoff": visSettings.frequencyCutoff,
|
|
"baseMultiplier": visSettings.baseMultiplier,
|
|
"waveStrokeThickness": visSettings.waveStrokeThickness,
|
|
"barSpacing": visSettings.barSpacing,
|
|
"barCornerRadius": visSettings.barCornerRadius,
|
|
"lineThickness": visSettings.lineThickness,
|
|
"nowPlayingHeightPct": visSettings.nowPlayingHeightPct,
|
|
"waveOffsetTop": visSettings.waveOffsetTop,
|
|
"npAmplitude": visSettings.npAmplitude,
|
|
"npBaseLift": visSettings.npBaseLift,
|
|
"depthOffset": visSettings.depthOffset,
|
|
]
|
|
let visData = try JSONSerialization.data(withJSONObject: visDict, options: .prettyPrinted)
|
|
try visData.write(to: tempDir.appendingPathComponent("visualizer_settings.json"))
|
|
|
|
// 6. Playback state
|
|
if let state = PlaybackStateStore.shared.exportData() {
|
|
try state.write(to: tempDir.appendingPathComponent("playback_state.json"))
|
|
}
|
|
|
|
// 7. Smart DJ profiles (bulk cache)
|
|
let djCacheURL = fm.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
|
.appendingPathComponent("dj_profiles_bulk.json")
|
|
if fm.fileExists(atPath: djCacheURL.path) {
|
|
try fm.copyItem(at: djCacheURL, to: tempDir.appendingPathComponent("dj_profiles_bulk.json"))
|
|
}
|
|
|
|
// 8. Offline catalog (metadata only, not actual audio files)
|
|
if let catalogData = UserDefaults.standard.data(forKey: "offline_catalog") {
|
|
try catalogData.write(to: tempDir.appendingPathComponent("offline_catalog.json"))
|
|
}
|
|
|
|
// 9. Pending operations
|
|
if let opsData = PendingOperationsQueue.shared.exportData() {
|
|
try opsData.write(to: tempDir.appendingPathComponent("pending_operations.json"))
|
|
}
|
|
|
|
// 10. Custom covers
|
|
let coversDir = tempDir.appendingPathComponent("covers")
|
|
try fm.createDirectory(at: coversDir, withIntermediateDirectories: true)
|
|
|
|
let albumCoversDir = coversDir.appendingPathComponent("albums")
|
|
try fm.createDirectory(at: albumCoversDir, withIntermediateDirectories: true)
|
|
copyCovers(from: albumCoverDirectory(), to: albumCoversDir)
|
|
|
|
let artistCoversDir = coversDir.appendingPathComponent("artists")
|
|
try fm.createDirectory(at: artistCoversDir, withIntermediateDirectories: true)
|
|
copyCovers(from: artistCoverDirectory(), to: artistCoversDir)
|
|
|
|
// Package into zip
|
|
let backupURL = fm.temporaryDirectory.appendingPathComponent(
|
|
"NavidromeBackup_\(dateString()).nvdbackup"
|
|
)
|
|
try? fm.removeItem(at: backupURL) // remove stale
|
|
try fm.zipItem(at: tempDir, to: backupURL, shouldKeepParent: false)
|
|
|
|
DebugLogger.shared.log(
|
|
"Backup exported: \(backupURL.lastPathComponent) (\(fileSize(backupURL)))",
|
|
category: "Backup"
|
|
)
|
|
|
|
return backupURL
|
|
}
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// MARK: - IMPORT
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
/// Import a .nvdbackup file. Returns the manifest for UI display.
|
|
/// Servers are restored with passwords cleared — user must re-authenticate.
|
|
@discardableResult
|
|
func importBackup(from url: URL) throws -> BackupManifest {
|
|
let fm = FileManager.default
|
|
let tempDir = fm.temporaryDirectory.appendingPathComponent("nvdimport_\(UUID().uuidString)")
|
|
try fm.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
|
|
defer { try? fm.removeItem(at: tempDir) }
|
|
|
|
// Access security-scoped resource (AirDrop / Files)
|
|
let accessing = url.startAccessingSecurityScopedResource()
|
|
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
|
|
|
|
// Unzip
|
|
try fm.unzipItem(at: url, to: tempDir)
|
|
|
|
// 1. Validate manifest
|
|
let manifestURL = tempDir.appendingPathComponent("manifest.json")
|
|
guard fm.fileExists(atPath: manifestURL.path) else {
|
|
throw BackupError.invalidManifest
|
|
}
|
|
let manifestData = try Data(contentsOf: manifestURL)
|
|
let manifestDecoder = JSONDecoder()
|
|
manifestDecoder.dateDecodingStrategy = .iso8601
|
|
let manifest = try manifestDecoder.decode(BackupManifest.self, from: manifestData)
|
|
|
|
guard manifest.version <= BackupManifest.currentVersion else {
|
|
throw BackupError.versionMismatch(manifest.version)
|
|
}
|
|
|
|
// 2. Restore servers (passwords blank — user re-enters)
|
|
let serversURL = tempDir.appendingPathComponent("servers.json")
|
|
if fm.fileExists(atPath: serversURL.path) {
|
|
let data = try Data(contentsOf: serversURL)
|
|
UserDefaults.standard.set(data, forKey: "navidrome_servers")
|
|
}
|
|
let activeURL = tempDir.appendingPathComponent("active_server.json")
|
|
if fm.fileExists(atPath: activeURL.path) {
|
|
let data = try Data(contentsOf: activeURL)
|
|
UserDefaults.standard.set(data, forKey: "navidrome_active_server")
|
|
}
|
|
|
|
// 3. Restore app settings
|
|
let settingsURL = tempDir.appendingPathComponent("settings.json")
|
|
if fm.fileExists(atPath: settingsURL.path),
|
|
let data = try? Data(contentsOf: settingsURL),
|
|
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
for (key, value) in dict {
|
|
UserDefaults.standard.set(value, forKey: key)
|
|
}
|
|
}
|
|
|
|
// 4. Restore companion settings
|
|
let companionURL = tempDir.appendingPathComponent("companion_settings.json")
|
|
if fm.fileExists(atPath: companionURL.path),
|
|
let data = try? Data(contentsOf: companionURL),
|
|
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
for (key, value) in dict {
|
|
UserDefaults.standard.set(value, forKey: key)
|
|
}
|
|
}
|
|
|
|
// 5. Restore visualizer settings
|
|
let visURL = tempDir.appendingPathComponent("visualizer_settings.json")
|
|
if fm.fileExists(atPath: visURL.path),
|
|
let data = try? Data(contentsOf: visURL),
|
|
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
let vis = VisualizerSettings.shared
|
|
if let v = dict["enabled"] as? Bool { vis.enabled = v }
|
|
if let v = dict["nowPlayingEnabled"] as? Bool { vis.nowPlayingEnabled = v }
|
|
if let v = dict["miniPlayerEnabled"] as? Bool { vis.miniPlayerEnabled = v }
|
|
if let v = dict["realAudioAnalysis"] as? Bool { vis.realAudioAnalysis = v }
|
|
if let v = dict["dynamicGainEnabled"] as? Bool { vis.dynamicGainEnabled = v }
|
|
if let v = dict["fps"] as? Double { vis.fps = v }
|
|
if let v = dict["viscosity"] as? Double { vis.viscosity = v }
|
|
if let v = dict["frequencyCutoff"] as? Int { vis.frequencyCutoff = v }
|
|
if let v = dict["baseMultiplier"] as? Double { vis.baseMultiplier = v }
|
|
if let v = dict["waveStrokeThickness"] as? Double { vis.waveStrokeThickness = v }
|
|
if let v = dict["barSpacing"] as? Double { vis.barSpacing = v }
|
|
if let v = dict["barCornerRadius"] as? Double { vis.barCornerRadius = v }
|
|
if let v = dict["lineThickness"] as? Double { vis.lineThickness = v }
|
|
if let v = dict["nowPlayingHeightPct"] as? Double { vis.nowPlayingHeightPct = v }
|
|
if let v = dict["waveOffsetTop"] as? Double { vis.waveOffsetTop = v }
|
|
if let v = dict["npAmplitude"] as? Double { vis.npAmplitude = v }
|
|
if let v = dict["npBaseLift"] as? Double { vis.npBaseLift = v }
|
|
if let v = dict["depthOffset"] as? Double { vis.depthOffset = v }
|
|
}
|
|
|
|
// 6. Restore playback state
|
|
let playbackURL = tempDir.appendingPathComponent("playback_state.json")
|
|
if fm.fileExists(atPath: playbackURL.path) {
|
|
let data = try Data(contentsOf: playbackURL)
|
|
PlaybackStateStore.shared.importData(data)
|
|
}
|
|
|
|
// 7. Restore DJ profiles
|
|
let djURL = tempDir.appendingPathComponent("dj_profiles_bulk.json")
|
|
if fm.fileExists(atPath: djURL.path),
|
|
let data = try? Data(contentsOf: djURL),
|
|
let profiles = try? JSONDecoder().decode([String: SmartDJProfile].self, from: data) {
|
|
SmartDJCache.shared.bulkImport(profiles)
|
|
}
|
|
|
|
// 8. Restore offline catalog (metadata only)
|
|
let catalogURL = tempDir.appendingPathComponent("offline_catalog.json")
|
|
if fm.fileExists(atPath: catalogURL.path) {
|
|
let data = try Data(contentsOf: catalogURL)
|
|
UserDefaults.standard.set(data, forKey: "offline_catalog")
|
|
}
|
|
|
|
// 9. Restore pending operations
|
|
let opsURL = tempDir.appendingPathComponent("pending_operations.json")
|
|
if fm.fileExists(atPath: opsURL.path) {
|
|
let data = try Data(contentsOf: opsURL)
|
|
PendingOperationsQueue.shared.importData(data)
|
|
}
|
|
|
|
// 10. Restore custom covers
|
|
let albumCoversSource = tempDir.appendingPathComponent("covers/albums")
|
|
if fm.fileExists(atPath: albumCoversSource.path) {
|
|
copyCovers(from: albumCoversSource, to: albumCoverDirectory())
|
|
}
|
|
let artistCoversSource = tempDir.appendingPathComponent("covers/artists")
|
|
if fm.fileExists(atPath: artistCoversSource.path) {
|
|
copyCovers(from: artistCoversSource, to: artistCoverDirectory())
|
|
}
|
|
|
|
DebugLogger.shared.log(
|
|
"Backup imported: v\(manifest.version) from \(manifest.deviceName) (\(manifest.exportDate))",
|
|
category: "Backup"
|
|
)
|
|
|
|
return manifest
|
|
}
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// MARK: - Helpers
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
private func writeJSON<T: Encodable>(_ value: T, to url: URL) throws {
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
encoder.outputFormatting = .prettyPrinted
|
|
let data = try encoder.encode(value)
|
|
try data.write(to: url, options: .atomic)
|
|
}
|
|
|
|
private func albumCoverDirectory() -> URL {
|
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
let dir = docs.appendingPathComponent("AlbumCovers", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
return dir
|
|
}
|
|
|
|
private func artistCoverDirectory() -> URL {
|
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
let dir = docs.appendingPathComponent("ArtistCovers", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
return dir
|
|
}
|
|
|
|
private func copyCovers(from source: URL, to dest: URL) {
|
|
let fm = FileManager.default
|
|
guard let files = try? fm.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) else { return }
|
|
for file in files {
|
|
let target = dest.appendingPathComponent(file.lastPathComponent)
|
|
try? fm.removeItem(at: target)
|
|
try? fm.copyItem(at: file, to: target)
|
|
}
|
|
}
|
|
|
|
private func dateString() -> String {
|
|
let df = DateFormatter()
|
|
df.dateFormat = "yyyy-MM-dd"
|
|
return df.string(from: Date())
|
|
}
|
|
|
|
private func fileSize(_ url: URL) -> String {
|
|
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
|
let size = attrs[.size] as? Int else { return "?" }
|
|
if size < 1024 { return "\(size) B" }
|
|
if size < 1024 * 1024 { return "\(size / 1024) KB" }
|
|
return String(format: "%.1f MB", Double(size) / 1024.0 / 1024.0)
|
|
}
|
|
}
|