NavidromeApp/iOS/Data/BackupManager.swift
Dallas Groot b9844b23cd Performance audit, Now Playing widget, crossfade stability, cover art embedding, DJ profile bulk cache
PERFORMANCE AUDIT
- Removed 16 dead SubsonicClient methods (~117 lines)
- Added NSCache memory tier to LibraryCache, AlbumCoverStore,
ArtistCoverStore, RadioCoverStore
- Replaced weak polynomial hash with FNV-1a 64-bit in ImageCache
- Split PlaybackStateStore into save() (full queue) and savePosition()
(time only)
- Reused single SubsonicClient in OfflineManager instead of
per-download allocation
- Added periodic ImageCache disk trim every 50 writes
- Changed AudioPreFetcher to fuzzy offline match
(isSongAvailableOffline)
- Removed dead code: hasCompanionLibrary, downloadedSongIds, isActive,
CachedImageLoader.task
- Fixed thread safety: inline JSONEncoder/JSONDecoder in LibraryCache
(no shared instances)

WIDGET EXTENSION (new target: NavidromeWidget)
- v2 glassmorphism design: blurred album art background + frosted
glass panel
- Waveform scrubber: 40-bar Canvas with tap-to-seek (20 segments via
SeekToIntent)
- Color-adaptive theming: CIAreaAverage dominant color extraction with
HSB contrast adjustment
- Transport controls: previous/play-pause/next with interactive
AppIntents
- Up Next footer with crossfade countdown from Smart DJ profiles
- Large widget: 3-item queue list with numbered rows
- Small/Medium/Large sizes matching design mockups
- App Group communication via WidgetSharedState (UserDefaults)
- Darwin notification observer for widget→app commands
- Foreground command pickup for suspended app recovery
- Idempotency guards on all widget commands

CROSSFADE & PLAYBACK FIXES
- Fixed dual audio on single-song queue: guard nextSong.id ==
currentSong?.id in prepareNextForCrossfade
- Fixed crossfade play path never calling pushWidgetState (returned
before reaching it)
- Fixed crossfade needsNextTrack callback missing queue persistence +
widget push
- Fixed toggleShuffle queue not persisted after PlaybackStateStore
split
- Added nowPlayingSyncTimer restart on foreground (Lock Screen seek
bar drift)
- Added AVPlayer currentTime/duration sync in resumeVisTimers before
vis timer restart
(fixes waveform distortion after background — confirmed by Apple
Forums + SoundCloud engineering)

COVER ART PIPELINE
- Fixed pushWidgetState cover art size mismatch (300→600 to match
fetchAndSetArtwork)
- Added custom cover art key differentiation ("custom_" prefix forces
re-blur)
- Changed server art lookup from memoryOnlyImage to cachedImage
(memory+disk fallback)
- Added POST /library/cover-art-by-path endpoint (was missing — iOS
fallback hit 404)
- Added navidrome_id fallback on existing cover art endpoint
- Added embed_cover_art_in_file/embed_cover_art_in_directory: mutagen
writes cover art
directly into FLAC/MP3/M4A/OGG metadata tags so Navidrome serves
updated art
- All three upload paths (by-id, by-path, upload-tracks) now embed +
trigger_scan

COMPANION API FIXES
- Fixed _create_task recursion (was calling itself instead of
asyncio.create_task)
- Fixed navidrome_db NameError on /library/conflicts endpoint
- Reduced WebSocket connect/disconnect logging (only first-client and
all-disconnected)

SMART DJ PROFILE PREFETCH
- New endpoint: GET /smart-dj/profiles/export (bulk JSON, gzip
automatic)
- SmartDJCache.loadBulkCache() reads single file on launch (instant)
- SmartDJCache.bulkImport() writes all profiles in one atomic file
- CompanionAPIService.fetchAllProfiles() fetches entire profile set in
one request
- Wired into NavidromePlayerApp.task after server connect
- SmartCrossfadeManager unchanged — already reads from SmartDJCache
first

WEBSOCKET NOISE REDUCTION
- iOS: silent reconnect retries, only log milestones (#1, #5, every
20th)
- iOS: log "reconnected after N attempts" on success, silent initial
connect
- Python: only log first client connect and all-clients-disconnected

Files: 13 modified, 8 new (including companion-api/main.py)
2026-04-12 19:24:22 -07:00

348 lines
16 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 (passwords stripped already blanked in UserDefaults)
if let serverData = UserDefaults.standard.data(forKey: "navidrome_servers") {
try serverData.write(to: tempDir.appendingPathComponent("servers.json"))
}
if let activeData = UserDefaults.standard.data(forKey: "navidrome_active_server") {
try activeData.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] = [
"realAudioAnalysis": visSettings.realAudioAnalysis,
"barCount": visSettings.barCount,
"gain": visSettings.gain,
"colorScheme": visSettings.colorScheme,
]
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 manifest = try JSONDecoder().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["realAudioAnalysis"] as? Bool { vis.realAudioAnalysis = v }
if let v = dict["barCount"] as? Int { vis.barCount = v }
if let v = dict["gain"] as? Double { vis.gain = Float(v) }
if let v = dict["colorScheme"] as? String { vis.colorScheme = 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)
}
}