2026-03-28 13:49:47 -07:00
|
|
|
import Foundation
|
2026-04-11 08:07:55 -07:00
|
|
|
import UIKit
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
// MARK: - Companion API Settings
|
|
|
|
|
|
|
|
|
|
class CompanionSettings: ObservableObject {
|
|
|
|
|
static let shared = CompanionSettings()
|
|
|
|
|
|
|
|
|
|
@Published var host: String {
|
|
|
|
|
didSet { UserDefaults.standard.set(host, forKey: "companion_host") }
|
|
|
|
|
}
|
|
|
|
|
@Published var port: Int {
|
|
|
|
|
didSet { UserDefaults.standard.set(port, forKey: "companion_port") }
|
|
|
|
|
}
|
|
|
|
|
@Published var isEnabled: Bool {
|
2026-04-04 16:12:28 -07:00
|
|
|
didSet {
|
|
|
|
|
UserDefaults.standard.set(isEnabled, forKey: "companion_enabled")
|
|
|
|
|
if !isEnabled {
|
|
|
|
|
CompanionPushClient.shared.disconnect()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
}
|
|
|
|
|
@Published var smartDJEnabled: Bool {
|
|
|
|
|
didSet { UserDefaults.standard.set(smartDJEnabled, forKey: "companion_smart_dj") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var baseURL: URL? {
|
|
|
|
|
URL(string: "http://\(host):\(port)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private init() {
|
|
|
|
|
host = UserDefaults.standard.string(forKey: "companion_host") ?? "192.168.1.100"
|
|
|
|
|
port = UserDefaults.standard.integer(forKey: "companion_port").nonZero ?? 8000
|
|
|
|
|
isEnabled = UserDefaults.standard.bool(forKey: "companion_enabled")
|
|
|
|
|
smartDJEnabled = UserDefaults.standard.bool(forKey: "companion_smart_dj")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension Int {
|
|
|
|
|
var nonZero: Int? { self == 0 ? nil : self }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Smart DJ Profile
|
|
|
|
|
|
|
|
|
|
struct SmartDJProfile: Codable, Equatable {
|
|
|
|
|
let bpm: Double?
|
|
|
|
|
let silenceStart: Double?
|
|
|
|
|
let silenceEnd: Double?
|
|
|
|
|
let loudnessLUFS: Double?
|
|
|
|
|
|
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
|
|
|
case bpm
|
|
|
|
|
case silenceStart = "silence_start"
|
|
|
|
|
case silenceEnd = "silence_end"
|
|
|
|
|
case loudnessLUFS = "loudness_lufs"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Local Smart DJ Cache
|
|
|
|
|
|
|
|
|
|
class SmartDJCache {
|
|
|
|
|
static let shared = SmartDJCache()
|
|
|
|
|
|
|
|
|
|
private let fileManager = FileManager.default
|
|
|
|
|
private let cacheDir: URL
|
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
|
|
|
private let bulkCacheURL: URL
|
2026-03-28 13:49:47 -07:00
|
|
|
private var memoryCache: [String: SmartDJProfile] = [:]
|
|
|
|
|
|
|
|
|
|
private init() {
|
|
|
|
|
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
|
|
|
|
cacheDir = caches.appendingPathComponent("SmartDJProfiles", isDirectory: true)
|
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
|
|
|
bulkCacheURL = caches.appendingPathComponent("dj_profiles_bulk.json")
|
2026-03-28 13:49:47 -07:00
|
|
|
try? fileManager.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func cacheURL(for relativePath: String) -> URL {
|
|
|
|
|
let safe = relativePath
|
|
|
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
|
|
|
.replacingOccurrences(of: " ", with: "_")
|
|
|
|
|
return cacheDir.appendingPathComponent("\(safe).json")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func get(_ relativePath: String) -> SmartDJProfile? {
|
|
|
|
|
if let cached = memoryCache[relativePath] { return cached }
|
|
|
|
|
let url = cacheURL(for: relativePath)
|
|
|
|
|
guard let data = try? Data(contentsOf: url),
|
|
|
|
|
let profile = try? JSONDecoder().decode(SmartDJProfile.self, from: data) else { return nil }
|
|
|
|
|
memoryCache[relativePath] = profile
|
|
|
|
|
return profile
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func store(_ profile: SmartDJProfile, for relativePath: String) {
|
|
|
|
|
memoryCache[relativePath] = profile
|
|
|
|
|
let url = cacheURL(for: relativePath)
|
|
|
|
|
if let data = try? JSONEncoder().encode(profile) {
|
|
|
|
|
try? data.write(to: url, options: .atomic)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// MARK: - Bulk Cache (single-file import/export for all profiles)
|
|
|
|
|
|
|
|
|
|
/// Load the bulk cache file into memory. Call on app launch — one disk read,
|
|
|
|
|
/// populates memoryCache so every subsequent get() is a zero-I/O memory hit.
|
|
|
|
|
func loadBulkCache() {
|
|
|
|
|
guard let data = try? Data(contentsOf: bulkCacheURL),
|
|
|
|
|
let profiles = try? JSONDecoder().decode([String: SmartDJProfile].self, from: data)
|
|
|
|
|
else { return }
|
|
|
|
|
// Merge: individual stores (from cache misses) take priority over bulk
|
|
|
|
|
for (path, profile) in profiles where memoryCache[path] == nil {
|
|
|
|
|
memoryCache[path] = profile
|
|
|
|
|
}
|
|
|
|
|
DebugLogger.shared.log(
|
|
|
|
|
"DJ bulk cache loaded: \(profiles.count) profiles from disk",
|
|
|
|
|
category: "SmartDJ"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Import all profiles from the server export. Updates memory immediately,
|
|
|
|
|
/// writes a single bulk JSON file to disk for next launch.
|
|
|
|
|
func bulkImport(_ profiles: [String: SmartDJProfile]) {
|
|
|
|
|
for (path, profile) in profiles {
|
|
|
|
|
memoryCache[path] = profile
|
|
|
|
|
}
|
|
|
|
|
// Write single bulk file — one atomic write, no per-profile I/O
|
|
|
|
|
if let data = try? JSONEncoder().encode(profiles) {
|
|
|
|
|
try? data.write(to: bulkCacheURL, options: .atomic)
|
|
|
|
|
}
|
|
|
|
|
DebugLogger.shared.log(
|
|
|
|
|
"DJ bulk import: \(profiles.count) profiles cached (\(memoryCache.count) total in memory)",
|
|
|
|
|
category: "SmartDJ"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
func clearAll() {
|
|
|
|
|
memoryCache.removeAll()
|
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
|
|
|
try? fileManager.removeItem(at: bulkCacheURL)
|
2026-03-28 13:49:47 -07:00
|
|
|
if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
|
|
|
|
|
for file in files { try? fileManager.removeItem(at: file) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
var cachedCount: Int { memoryCache.count }
|
2026-03-28 13:49:47 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Metadata Edit Request (matches Python MetadataUpdate model)
|
|
|
|
|
|
|
|
|
|
struct MetadataEditRequest: Codable {
|
|
|
|
|
let relativePath: String
|
|
|
|
|
var title: String?
|
|
|
|
|
var artist: String?
|
|
|
|
|
var album: String?
|
|
|
|
|
var albumArtist: String?
|
|
|
|
|
var genre: String?
|
|
|
|
|
var year: Int?
|
|
|
|
|
var trackNumber: Int?
|
|
|
|
|
|
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
|
|
|
case relativePath = "relative_path"
|
|
|
|
|
case title, artist, album
|
|
|
|
|
case albumArtist = "album_artist"
|
|
|
|
|
case genre, year
|
|
|
|
|
case trackNumber = "track_number"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Request body for PATCH /batch-edit-metadata
|
|
|
|
|
struct BatchMetadataEditRequest: Codable {
|
|
|
|
|
let relativePaths: [String]
|
|
|
|
|
var title: String?
|
|
|
|
|
var artist: String?
|
|
|
|
|
var album: String?
|
|
|
|
|
var albumArtist: String?
|
|
|
|
|
var genre: String?
|
|
|
|
|
var year: Int?
|
|
|
|
|
|
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
|
|
|
case relativePaths = "relative_paths"
|
|
|
|
|
case title, artist, album
|
|
|
|
|
case albumArtist = "album_artist"
|
|
|
|
|
case genre, year
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Upload Metadata (matches Python upload-track Form fields)
|
|
|
|
|
|
|
|
|
|
struct UploadMetadata {
|
|
|
|
|
var title: String
|
|
|
|
|
var artist: String
|
|
|
|
|
var album: String
|
|
|
|
|
var albumArtist: String
|
|
|
|
|
var genre: String
|
|
|
|
|
var year: String
|
|
|
|
|
var trackNumber: String
|
batch upload quick fix
Companion (main.py):
• NAVIDROME_TAGS whitelist — the single source of truth for what tags
survive
• enforce_tag_whitelist() — whitelist enforcer, replaces blacklist
approach
• All 5 write points updated: apply_tags, apply_tags_dict,
upload-track, upload-tracks, restructure_all
• preserve_composer and preserve_lyrics flags on both upload
endpoints (default False)
• /library/clean-tags now uses whitelist enforcer
iOS:
• UploadMetadata — preserveComposer and preserveLyrics fields
• buildMultipartBody — sends both flags as form fields
• BatchUploadView — two toggles, both off by default, wired end-to-end
• MultiAlbumEditorSheet — full rewrite matching
BatchAlbumEditorSheet: MusicBrainz search, swipe to exclude/include
tracks, Reset button, cover art widget with red glow
2026-04-11 09:37:22 -07:00
|
|
|
var preserveComposer: Bool = false
|
|
|
|
|
var preserveLyrics: Bool = false
|
2026-03-28 13:49:47 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Companion API Service
|
|
|
|
|
|
|
|
|
|
actor CompanionAPIService {
|
2026-04-11 15:09:06 -07:00
|
|
|
|
|
|
|
|
/// Shared singleton — use this instead of creating new instances per call-site.
|
|
|
|
|
/// Each instance allocates its own URLSession; using the singleton ensures
|
|
|
|
|
/// connection pooling across all Companion API requests.
|
|
|
|
|
static let shared = CompanionAPIService()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
private let session: URLSession
|
|
|
|
|
|
|
|
|
|
init() {
|
|
|
|
|
let config = URLSessionConfiguration.default
|
|
|
|
|
config.timeoutIntervalForRequest = 30
|
|
|
|
|
config.timeoutIntervalForResource = 600
|
|
|
|
|
self.session = URLSession(configuration: config)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func baseURL() throws -> URL {
|
|
|
|
|
guard let url = CompanionSettings.shared.baseURL else {
|
|
|
|
|
throw CompanionError.notConfigured
|
|
|
|
|
}
|
|
|
|
|
return url
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Health Check (GET /health)
|
|
|
|
|
|
|
|
|
|
func healthCheck() async throws -> Bool {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
let url = base.appendingPathComponent("health")
|
|
|
|
|
var req = URLRequest(url: url)
|
|
|
|
|
req.timeoutInterval = 5
|
|
|
|
|
let (_, response) = try await session.data(for: req)
|
|
|
|
|
if let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Smart DJ Profile (GET /smart-dj/profile?relative_path=...)
|
|
|
|
|
|
|
|
|
|
func fetchProfile(relativePath: String) async throws -> SmartDJProfile {
|
|
|
|
|
if let cached = SmartDJCache.shared.get(relativePath) {
|
|
|
|
|
return cached
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
// Use addingPercentEncoding directly instead of URLQueryItem
|
|
|
|
|
// which double-encodes paths with brackets, unicode, etc.
|
|
|
|
|
guard let encoded = relativePath.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
|
|
|
|
let url = URL(string: "\(base)/smart-dj/profile?relative_path=\(encoded)") else {
|
|
|
|
|
throw CompanionError.invalidURL
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
|
|
|
|
|
let profile = try JSONDecoder().decode(SmartDJProfile.self, from: data)
|
|
|
|
|
SmartDJCache.shared.store(profile, for: relativePath)
|
|
|
|
|
|
|
|
|
|
return profile
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/// Fetch ALL Smart DJ profiles in one request. Returns a dict keyed by relative path.
|
|
|
|
|
/// The server compresses with gzip automatically (~200KB → ~30KB).
|
|
|
|
|
/// Call once on app launch to populate SmartDJCache.bulkImport().
|
|
|
|
|
func fetchAllProfiles() async throws -> [String: SmartDJProfile] {
|
|
|
|
|
let base = try baseURL()
|
2026-04-14 00:27:10 -07:00
|
|
|
let url = URL(string: "\(base)/smart-dj/profiles/export")!
|
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
|
|
|
let (data, response) = try await session.data(from: url)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
|
|
|
|
|
struct ExportResponse: Codable {
|
|
|
|
|
let count: Int
|
|
|
|
|
let profiles: [String: SmartDJProfile]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let export = try JSONDecoder().decode(ExportResponse.self, from: data)
|
|
|
|
|
DebugLogger.shared.log(
|
|
|
|
|
"Fetched \(export.count) DJ profiles (\(data.count) bytes)",
|
|
|
|
|
category: "SmartDJ"
|
|
|
|
|
)
|
|
|
|
|
return export.profiles
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
/// Prefetch profiles for a batch of songs (e.g. queue, album)
|
|
|
|
|
func prefetchProfiles(for songs: [Song]) async {
|
|
|
|
|
await withTaskGroup(of: Void.self) { group in
|
|
|
|
|
for song in songs {
|
|
|
|
|
guard let path = song.path else { continue }
|
|
|
|
|
if SmartDJCache.shared.get(path) != nil { continue }
|
|
|
|
|
group.addTask { _ = try? await self.fetchProfile(relativePath: path) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Edit Metadata (PATCH /edit-metadata)
|
|
|
|
|
|
|
|
|
|
func editMetadata(_ request: MetadataEditRequest) async throws {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
let url = base.appendingPathComponent("edit-metadata")
|
|
|
|
|
|
|
|
|
|
var req = URLRequest(url: url)
|
|
|
|
|
req.httpMethod = "PATCH"
|
|
|
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
|
req.httpBody = try JSONEncoder().encode(request)
|
|
|
|
|
|
|
|
|
|
let (data, response) = try await session.data(for: req)
|
|
|
|
|
guard let http = response as? HTTPURLResponse else { throw CompanionError.invalidResponse }
|
|
|
|
|
|
|
|
|
|
if !(200...299).contains(http.statusCode) {
|
|
|
|
|
// Parse server error detail for better debugging
|
|
|
|
|
let detail = (try? JSONDecoder().decode([String:String].self, from: data))?["detail"]
|
|
|
|
|
?? String(data: data, encoding: .utf8)
|
|
|
|
|
?? "Unknown error"
|
|
|
|
|
throw CompanionError.serverErrorDetail(http.statusCode, detail)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Batch Edit Metadata (PATCH /batch-edit-metadata)
|
|
|
|
|
|
|
|
|
|
struct BatchEditResult: Codable {
|
|
|
|
|
let succeeded: [String]?
|
|
|
|
|
let failed: [BatchEditFailure]?
|
|
|
|
|
}
|
|
|
|
|
struct BatchEditFailure: Codable {
|
|
|
|
|
let path: String
|
|
|
|
|
let error: String
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Edit the same tags on multiple files in a single request + single Navidrome scan.
|
|
|
|
|
func batchEditMetadata(_ request: BatchMetadataEditRequest) async throws -> BatchEditResult {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
let url = base.appendingPathComponent("batch-edit-metadata")
|
|
|
|
|
|
|
|
|
|
var req = URLRequest(url: url)
|
|
|
|
|
req.httpMethod = "PATCH"
|
|
|
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
|
req.httpBody = try JSONEncoder().encode(request)
|
|
|
|
|
|
|
|
|
|
let (data, response) = try await session.data(for: req)
|
|
|
|
|
guard let http = response as? HTTPURLResponse else { throw CompanionError.invalidResponse }
|
|
|
|
|
|
|
|
|
|
if !(200...299).contains(http.statusCode) {
|
|
|
|
|
let detail = (try? JSONDecoder().decode([String:String].self, from: data))?["detail"]
|
|
|
|
|
?? String(data: data, encoding: .utf8)
|
|
|
|
|
?? "Unknown error"
|
|
|
|
|
throw CompanionError.serverErrorDetail(http.statusCode, detail)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return try JSONDecoder().decode(BatchEditResult.self, from: data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Upload Track (POST /upload-track)
|
|
|
|
|
|
|
|
|
|
func uploadTrack(fileURL: URL, metadata: UploadMetadata) async throws {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
let url = base.appendingPathComponent("upload-track")
|
|
|
|
|
|
|
|
|
|
let boundary = "Boundary-\(UUID().uuidString)"
|
|
|
|
|
var req = URLRequest(url: url)
|
|
|
|
|
req.httpMethod = "POST"
|
|
|
|
|
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
|
|
|
req.httpBody = try buildMultipartBody(fileURL: fileURL, metadata: metadata, boundary: boundary)
|
|
|
|
|
|
|
|
|
|
let (_, response) = try await session.data(for: req)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Bulk Fix (POST /bulk-fix)
|
|
|
|
|
|
|
|
|
|
func triggerBulkFix() async throws {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
let url = base.appendingPathComponent("bulk-fix")
|
|
|
|
|
var req = URLRequest(url: url)
|
|
|
|
|
req.httpMethod = "POST"
|
|
|
|
|
let (_, response) = try await session.data(for: req)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Bulk Profiles (GET /smart-dj/bulk-profiles?paths=...)
|
|
|
|
|
|
|
|
|
|
func fetchBulkProfiles(songs: [Song]) async throws -> [String: SmartDJProfile] {
|
|
|
|
|
let paths = songs.compactMap { $0.path }.filter { SmartDJCache.shared.get($0) == nil }
|
|
|
|
|
guard !paths.isEmpty else { return [:] }
|
|
|
|
|
|
|
|
|
|
let base = try baseURL()
|
2026-04-14 00:27:10 -07:00
|
|
|
var components = URLComponents(string: "\(base)/smart-dj/bulk-profiles")!
|
2026-03-28 13:49:47 -07:00
|
|
|
components.queryItems = [URLQueryItem(name: "paths", value: paths.joined(separator: ","))]
|
|
|
|
|
|
|
|
|
|
guard let url = components.url else { throw CompanionError.invalidURL }
|
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
|
|
|
|
|
let results = try JSONDecoder().decode([String: SmartDJProfile?].self, from: data)
|
|
|
|
|
var profiles: [String: SmartDJProfile] = [:]
|
|
|
|
|
for (path, profile) in results {
|
|
|
|
|
if let p = profile {
|
|
|
|
|
SmartDJCache.shared.store(p, for: path)
|
|
|
|
|
profiles[path] = p
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return profiles
|
|
|
|
|
}
|
|
|
|
|
|
Live lyrics system — foundation + search + editor
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, auto-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
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
// MARK: - Lyrics
|
|
|
|
|
|
|
|
|
|
/// Fetch lyrics for a song by its file path. Checks embedded tags, .lrc sidecar, and DB cache.
|
|
|
|
|
func fetchLyricsForPath(relativePath: String) async throws -> LyricsResponse {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
guard let encoded = relativePath.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
|
|
|
|
let url = URL(string: "\(base)/lyrics/get?relative_path=\(encoded)") else {
|
|
|
|
|
throw CompanionError.invalidURL
|
|
|
|
|
}
|
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
return try JSONDecoder().decode(LyricsResponse.self, from: data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fetch lyrics from LRCLIB via companion API (exact match by artist/title/duration).
|
|
|
|
|
func fetchLyricsFromLRCLIB(artist: String, title: String, duration: Double) async throws -> LyricsResponse {
|
|
|
|
|
let base = try baseURL()
|
2026-04-14 00:27:10 -07:00
|
|
|
var components = URLComponents(string: "\(base)/lyrics/fetch")!
|
Live lyrics system — foundation + search + editor
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, auto-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
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
components.queryItems = [
|
|
|
|
|
URLQueryItem(name: "artist", value: artist),
|
|
|
|
|
URLQueryItem(name: "title", value: title),
|
|
|
|
|
URLQueryItem(name: "duration", value: String(duration))
|
|
|
|
|
]
|
|
|
|
|
guard let url = components.url else { throw CompanionError.invalidURL }
|
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
return try JSONDecoder().decode(LyricsResponse.self, from: data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Search LRCLIB for lyrics matching a query.
|
|
|
|
|
func searchLyrics(query: String) async throws -> [LRCLIBResult] {
|
|
|
|
|
let base = try baseURL()
|
2026-04-14 00:27:10 -07:00
|
|
|
var components = URLComponents(string: "\(base)/lyrics/search")!
|
Live lyrics system — foundation + search + editor
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, auto-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
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
components.queryItems = [URLQueryItem(name: "q", value: query)]
|
|
|
|
|
guard let url = components.url else { throw CompanionError.invalidURL }
|
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
return try JSONDecoder().decode([LRCLIBResult].self, from: data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Embed LRC lyrics into an audio file via Companion API.
|
|
|
|
|
func embedLyrics(relativePath: String, lrcContent: String) async throws {
|
|
|
|
|
let base = try baseURL()
|
2026-04-14 00:27:10 -07:00
|
|
|
let url = URL(string: "\(base)/lyrics/embed")!
|
Live lyrics system — foundation + search + editor
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, auto-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
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
var request = URLRequest(url: url)
|
|
|
|
|
request.httpMethod = "POST"
|
|
|
|
|
|
|
|
|
|
let boundary = UUID().uuidString
|
|
|
|
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
|
|
|
|
|
|
|
|
var body = Data()
|
|
|
|
|
func field(_ name: String, _ value: String) {
|
|
|
|
|
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
|
|
|
|
body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
|
|
|
|
|
body.append("\(value)\r\n".data(using: .utf8)!)
|
|
|
|
|
}
|
|
|
|
|
field("relative_path", relativePath)
|
|
|
|
|
field("lrc_content", lrcContent)
|
|
|
|
|
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
|
|
|
|
request.httpBody = body
|
|
|
|
|
|
|
|
|
|
let (_, response) = try await session.data(for: request)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
// MARK: - Visualizer Frames (GET /visualizer/frames?relative_path=...)
|
|
|
|
|
|
|
|
|
|
/// Fetch pre-computed Mitsuha visualizer frames from the server.
|
|
|
|
|
/// Returns nil if not available. The frames match iOS OfflineAudioAnalyzer format.
|
|
|
|
|
func fetchVisualizerFrames(relativePath: String) async throws -> [[Float]]? {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
guard let encoded = relativePath.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
|
|
|
|
let url = URL(string: "\(base)/visualizer/frames?relative_path=\(encoded)") else {
|
|
|
|
|
throw CompanionError.invalidURL
|
|
|
|
|
}
|
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
|
|
|
|
|
|
|
|
guard let http = response as? HTTPURLResponse else { return nil }
|
|
|
|
|
guard http.statusCode == 200 else { return nil }
|
|
|
|
|
|
|
|
|
|
struct VisResponse: Codable {
|
|
|
|
|
let frame_count: Int
|
|
|
|
|
let fps: Double
|
|
|
|
|
let points: Int
|
|
|
|
|
let frames: [[Float]]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let vis = try JSONDecoder().decode(VisResponse.self, from: data)
|
|
|
|
|
return vis.frames
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Background Upload Helpers
|
|
|
|
|
|
|
|
|
|
nonisolated func buildMultipartPayloadFile(
|
|
|
|
|
fileURL: URL, metadata: UploadMetadata, boundary: String
|
|
|
|
|
) throws -> URL {
|
|
|
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
|
|
|
let payloadURL = tempDir.appendingPathComponent("upload_\(UUID().uuidString).multipart")
|
|
|
|
|
let body = try buildMultipartBody(fileURL: fileURL, metadata: metadata, boundary: boundary)
|
|
|
|
|
try body.write(to: payloadURL)
|
|
|
|
|
return payloadURL
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
nonisolated func uploadURL() throws -> URL {
|
|
|
|
|
guard let url = CompanionSettings.shared.baseURL else { throw CompanionError.notConfigured }
|
|
|
|
|
return url.appendingPathComponent("upload-track")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Multipart Builder
|
|
|
|
|
|
|
|
|
|
private nonisolated func buildMultipartBody(
|
|
|
|
|
fileURL: URL, metadata: UploadMetadata, boundary: String
|
|
|
|
|
) throws -> Data {
|
|
|
|
|
var body = Data()
|
|
|
|
|
let crlf = "\r\n"
|
|
|
|
|
|
|
|
|
|
func field(_ name: String, _ value: String) {
|
|
|
|
|
guard !value.isEmpty else { return }
|
|
|
|
|
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
|
|
|
|
|
body.append("Content-Disposition: form-data; name=\"\(name)\"\(crlf)\(crlf)".data(using: .utf8)!)
|
|
|
|
|
body.append("\(value)\(crlf)".data(using: .utf8)!)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
field("title", metadata.title)
|
|
|
|
|
field("artist", metadata.artist)
|
|
|
|
|
field("album", metadata.album)
|
|
|
|
|
if !metadata.albumArtist.isEmpty { field("album_artist", metadata.albumArtist) }
|
|
|
|
|
if !metadata.genre.isEmpty { field("genre", metadata.genre) }
|
|
|
|
|
if !metadata.year.isEmpty { field("year", metadata.year) }
|
|
|
|
|
if !metadata.trackNumber.isEmpty { field("track_number", metadata.trackNumber) }
|
batch upload quick fix
Companion (main.py):
• NAVIDROME_TAGS whitelist — the single source of truth for what tags
survive
• enforce_tag_whitelist() — whitelist enforcer, replaces blacklist
approach
• All 5 write points updated: apply_tags, apply_tags_dict,
upload-track, upload-tracks, restructure_all
• preserve_composer and preserve_lyrics flags on both upload
endpoints (default False)
• /library/clean-tags now uses whitelist enforcer
iOS:
• UploadMetadata — preserveComposer and preserveLyrics fields
• buildMultipartBody — sends both flags as form fields
• BatchUploadView — two toggles, both off by default, wired end-to-end
• MultiAlbumEditorSheet — full rewrite matching
BatchAlbumEditorSheet: MusicBrainz search, swipe to exclude/include
tracks, Reset button, cover art widget with red glow
2026-04-11 09:37:22 -07:00
|
|
|
field("preserve_composer", metadata.preserveComposer ? "true" : "false")
|
|
|
|
|
field("preserve_lyrics", metadata.preserveLyrics ? "true" : "false")
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
let fileData = try Data(contentsOf: fileURL)
|
|
|
|
|
let filename = fileURL.lastPathComponent
|
|
|
|
|
let mime: String = {
|
|
|
|
|
switch fileURL.pathExtension.lowercased() {
|
|
|
|
|
case "mp3": return "audio/mpeg"
|
|
|
|
|
case "flac": return "audio/flac"
|
|
|
|
|
case "m4a", "aac": return "audio/mp4"
|
|
|
|
|
case "ogg", "opus": return "audio/ogg"
|
|
|
|
|
case "wav": return "audio/wav"
|
|
|
|
|
default: return "application/octet-stream"
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
|
|
|
|
|
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\(crlf)".data(using: .utf8)!)
|
|
|
|
|
body.append("Content-Type: \(mime)\(crlf)\(crlf)".data(using: .utf8)!)
|
|
|
|
|
body.append(fileData)
|
|
|
|
|
body.append(crlf.data(using: .utf8)!)
|
|
|
|
|
body.append("--\(boundary)--\(crlf)".data(using: .utf8)!)
|
|
|
|
|
|
|
|
|
|
return body
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func validateResponse(_ response: URLResponse) throws {
|
|
|
|
|
guard let http = response as? HTTPURLResponse else { throw CompanionError.invalidResponse }
|
|
|
|
|
guard (200...299).contains(http.statusCode) else { throw CompanionError.serverError(http.statusCode) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - WebSocket Push Client
|
|
|
|
|
|
|
|
|
|
/// Connects to the Companion API WebSocket for real-time push events.
|
|
|
|
|
/// Events: metadata_updated, track_uploaded, analysis_complete, vis_ready
|
|
|
|
|
class CompanionPushClient: ObservableObject {
|
|
|
|
|
static let shared = CompanionPushClient()
|
|
|
|
|
|
|
|
|
|
@Published var isConnected = false
|
|
|
|
|
@Published var lastEvent: String?
|
|
|
|
|
|
|
|
|
|
private var webSocketTask: URLSessionWebSocketTask?
|
|
|
|
|
private var pingTimer: Timer?
|
|
|
|
|
private var reconnectTask: Task<Void, Never>?
|
2026-04-04 18:41:21 -07:00
|
|
|
private var reconnectDelay: TimeInterval = 5 // starts at 5s
|
|
|
|
|
private let maxReconnectDelay: TimeInterval = 120 // caps at 2 min
|
|
|
|
|
private var reconnectAttempts = 0
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
private init() {}
|
|
|
|
|
|
|
|
|
|
func connect() {
|
|
|
|
|
guard CompanionSettings.shared.isEnabled,
|
|
|
|
|
CompanionSettings.shared.baseURL != nil else { return }
|
|
|
|
|
|
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
|
|
|
let wasReconnecting = reconnectAttempts > 0
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
disconnect()
|
|
|
|
|
|
|
|
|
|
let wsURL = URL(string: "ws://\(CompanionSettings.shared.host):\(CompanionSettings.shared.port)/ws/push")!
|
|
|
|
|
webSocketTask = URLSession.shared.webSocketTask(with: wsURL)
|
|
|
|
|
webSocketTask?.resume()
|
|
|
|
|
isConnected = true
|
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
|
|
|
|
|
|
|
|
if wasReconnecting {
|
|
|
|
|
DebugLogger.shared.log(
|
|
|
|
|
"Push: reconnected after \(reconnectAttempts) attempts",
|
|
|
|
|
category: "Companion"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 18:41:21 -07:00
|
|
|
// Reset backoff on fresh connect
|
|
|
|
|
reconnectDelay = 5
|
|
|
|
|
reconnectAttempts = 0
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
listen()
|
|
|
|
|
startPing()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func disconnect() {
|
|
|
|
|
pingTimer?.invalidate()
|
|
|
|
|
pingTimer = nil
|
|
|
|
|
reconnectTask?.cancel()
|
|
|
|
|
reconnectTask = nil
|
|
|
|
|
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
|
|
|
|
webSocketTask = nil
|
|
|
|
|
isConnected = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func listen() {
|
|
|
|
|
webSocketTask?.receive { [weak self] result in
|
|
|
|
|
guard let self = self else { return }
|
|
|
|
|
switch result {
|
|
|
|
|
case .success(let message):
|
|
|
|
|
switch message {
|
|
|
|
|
case .string(let text):
|
|
|
|
|
self.handleMessage(text)
|
|
|
|
|
default:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
self.listen() // Keep listening
|
|
|
|
|
|
|
|
|
|
case .failure(let error):
|
2026-04-04 18:41:21 -07:00
|
|
|
// Only log and reschedule if Companion is still enabled —
|
|
|
|
|
// avoids spam when the server is unreachable or disabled
|
|
|
|
|
guard CompanionSettings.shared.isEnabled else { return }
|
|
|
|
|
// Suppress the extremely common "Socket is not connected" noise
|
|
|
|
|
// after the first attempt — it fills the console during backoff
|
|
|
|
|
if reconnectAttempts == 0 {
|
|
|
|
|
DebugLogger.shared.log("Push: disconnected — \(error.localizedDescription)", category: "Companion")
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
self.isConnected = false
|
|
|
|
|
self.scheduleReconnect()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func handleMessage(_ text: String) {
|
|
|
|
|
guard let data = text.data(using: .utf8),
|
|
|
|
|
let msg = try? JSONDecoder().decode(PushMessage.self, from: data) else { return }
|
|
|
|
|
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
self.lastEvent = msg.event
|
2026-04-04 00:14:41 -07:00
|
|
|
|
|
|
|
|
// Suppress noisy pong events from log
|
|
|
|
|
if msg.event != "pong" {
|
|
|
|
|
DebugLogger.shared.log("Push event: \(msg.event)", category: "Companion")
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
switch msg.event {
|
|
|
|
|
case "metadata_updated":
|
|
|
|
|
NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data)
|
updates to companion from ios to server
Models.swift — CompanionSong, CompanionAlbum, CompanionArtist,
CompanionLibraryResponse. Each has a toSong()/toAlbum() converter.
Song.id = navidrome_id (streaming still works), Song.coverArt =
"companion:{id}" (routes cover art to Companion), Album.id =
"companion:{name}|{artist}" (detected by AlbumDetailView).
CompanionAPIService.swift — fetchAllSongs, fetchAlbumSongs,
fetchAllAlbums, fetchAllArtists, searchLibrary are all nonisolated
where needed. coverArtURL/artistPhotoURL are nonisolated so views can
call them synchronously. Push handler now fires
companionCoverArtUpdated and companionArtistPhotoUpdated events and
clears ImageCache immediately on receipt.
LibraryCache.swift — cacheCompanionAlbums/loadCompanionAlbums,
cacheCompanionAlbumSongs/loadCompanionAlbumSongs for local persistence
of companion data between launches.
SyncEngine.swift — syncCompanion() runs after every Navidrome sync
(bootstrap and delta) when Companion is enabled. It fetches albums
from /library/albums, converts them to Album objects, and overwrites
"all_albums" cache — so MyMusicView’s album grid immediately shows
Companion-sorted data. companionLibraryChanged notification triggers
reactive UI updates.
AsyncCoverArt.swift — Detects "companion:{id}" prefix and routes to
CompanionAPIService().coverArtURL(companionId:) instead of Navidrome.
All existing non-companion cover art paths are unchanged.
AlbumDetailView.swift — Detects albumId.hasPrefix("companion:"),
parses "companion:{name}|{artist}", calls
/library/songs?album=...&album_artist=... on Companion, and builds a
synthetic AlbumWithSongs locally. Caches results so the next tap is
instant. Falls back to the existing Navidrome path for all
non-companion album IDs.
2026-04-10 11:14:41 -07:00
|
|
|
case "track_uploaded", "tracks_uploaded":
|
2026-03-28 13:49:47 -07:00
|
|
|
NotificationCenter.default.post(name: .companionTrackUploaded, object: nil, userInfo: msg.data)
|
updates to companion from ios to server
Models.swift — CompanionSong, CompanionAlbum, CompanionArtist,
CompanionLibraryResponse. Each has a toSong()/toAlbum() converter.
Song.id = navidrome_id (streaming still works), Song.coverArt =
"companion:{id}" (routes cover art to Companion), Album.id =
"companion:{name}|{artist}" (detected by AlbumDetailView).
CompanionAPIService.swift — fetchAllSongs, fetchAlbumSongs,
fetchAllAlbums, fetchAllArtists, searchLibrary are all nonisolated
where needed. coverArtURL/artistPhotoURL are nonisolated so views can
call them synchronously. Push handler now fires
companionCoverArtUpdated and companionArtistPhotoUpdated events and
clears ImageCache immediately on receipt.
LibraryCache.swift — cacheCompanionAlbums/loadCompanionAlbums,
cacheCompanionAlbumSongs/loadCompanionAlbumSongs for local persistence
of companion data between launches.
SyncEngine.swift — syncCompanion() runs after every Navidrome sync
(bootstrap and delta) when Companion is enabled. It fetches albums
from /library/albums, converts them to Album objects, and overwrites
"all_albums" cache — so MyMusicView’s album grid immediately shows
Companion-sorted data. companionLibraryChanged notification triggers
reactive UI updates.
AsyncCoverArt.swift — Detects "companion:{id}" prefix and routes to
CompanionAPIService().coverArtURL(companionId:) instead of Navidrome.
All existing non-companion cover art paths are unchanged.
AlbumDetailView.swift — Detects albumId.hasPrefix("companion:"),
parses "companion:{name}|{artist}", calls
/library/songs?album=...&album_artist=... on Companion, and builds a
synthetic AlbumWithSongs locally. Caches results so the next tap is
instant. Falls back to the existing Navidrome path for all
non-companion album IDs.
2026-04-10 11:14:41 -07:00
|
|
|
case "batch_metadata_updated":
|
|
|
|
|
NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data)
|
|
|
|
|
case "cover_art_updated":
|
|
|
|
|
ImageCache.shared.clearAll()
|
|
|
|
|
NotificationCenter.default.post(name: .companionCoverArtUpdated, object: nil, userInfo: msg.data)
|
|
|
|
|
case "artist_photo_updated":
|
|
|
|
|
ImageCache.shared.clearAll()
|
|
|
|
|
NotificationCenter.default.post(name: .companionArtistPhotoUpdated, object: nil, userInfo: msg.data)
|
2026-04-11 02:23:03 -07:00
|
|
|
case "conflicts_updated":
|
|
|
|
|
// Notify ConflictManager to refresh — use NotificationCenter to avoid
|
|
|
|
|
// a cross-module dependency between Foundation and SwiftUI layers.
|
|
|
|
|
NotificationCenter.default.post(name: .companionConflictsUpdated, object: nil, userInfo: msg.data)
|
2026-04-11 08:36:32 -07:00
|
|
|
case "tags_cleaned":
|
|
|
|
|
NotificationCenter.default.post(name: .companionTagsCleaned, object: nil, userInfo: msg.data)
|
2026-03-28 13:49:47 -07:00
|
|
|
case "profile":
|
|
|
|
|
if let path = msg.data?["path"] as? String,
|
|
|
|
|
let jsonData = try? JSONSerialization.data(withJSONObject: msg.data ?? [:]),
|
|
|
|
|
let profile = try? JSONDecoder().decode(SmartDJProfile.self, from: jsonData) {
|
|
|
|
|
SmartDJCache.shared.store(profile, for: path)
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sendAction(_ action: String, data: [String: String] = [:]) {
|
|
|
|
|
var payload = data
|
|
|
|
|
payload["action"] = action
|
|
|
|
|
if let jsonData = try? JSONSerialization.data(withJSONObject: payload),
|
|
|
|
|
let text = String(data: jsonData, encoding: .utf8) {
|
|
|
|
|
webSocketTask?.send(.string(text)) { error in
|
|
|
|
|
if let error = error {
|
|
|
|
|
DebugLogger.shared.log("Push send failed: \(error.localizedDescription)", category: "Companion")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func startPing() {
|
|
|
|
|
pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
|
|
|
|
self?.sendAction("ping")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func scheduleReconnect() {
|
2026-04-04 18:41:21 -07:00
|
|
|
guard CompanionSettings.shared.isEnabled else { return }
|
|
|
|
|
reconnectAttempts += 1
|
|
|
|
|
let delay = reconnectDelay
|
|
|
|
|
// Exponential backoff: 5 → 10 → 20 → 40 → 80 → 120 → 120...
|
|
|
|
|
reconnectDelay = min(reconnectDelay * 2, maxReconnectDelay)
|
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
|
|
|
// Silent retries — only log milestones to avoid console spam
|
|
|
|
|
if reconnectAttempts == 1 || reconnectAttempts == 5 || reconnectAttempts % 20 == 0 {
|
|
|
|
|
DebugLogger.shared.log(
|
|
|
|
|
"Push: reconnecting (attempt \(reconnectAttempts), \(Int(delay))s backoff)",
|
|
|
|
|
category: "Companion"
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
reconnectTask = Task {
|
2026-04-04 18:41:21 -07:00
|
|
|
try? await Task.sleep(for: .seconds(delay))
|
2026-03-28 13:49:47 -07:00
|
|
|
await MainActor.run { self.connect() }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct PushMessage: Codable {
|
|
|
|
|
let event: String
|
|
|
|
|
let data: [String: String]?
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension Notification.Name {
|
2026-04-11 02:23:03 -07:00
|
|
|
static let companionMetadataUpdated = Notification.Name("companionMetadataUpdated")
|
|
|
|
|
static let companionTrackUploaded = Notification.Name("companionTrackUploaded")
|
|
|
|
|
static let companionCoverArtUpdated = Notification.Name("companionCoverArtUpdated")
|
updates to companion from ios to server
Models.swift — CompanionSong, CompanionAlbum, CompanionArtist,
CompanionLibraryResponse. Each has a toSong()/toAlbum() converter.
Song.id = navidrome_id (streaming still works), Song.coverArt =
"companion:{id}" (routes cover art to Companion), Album.id =
"companion:{name}|{artist}" (detected by AlbumDetailView).
CompanionAPIService.swift — fetchAllSongs, fetchAlbumSongs,
fetchAllAlbums, fetchAllArtists, searchLibrary are all nonisolated
where needed. coverArtURL/artistPhotoURL are nonisolated so views can
call them synchronously. Push handler now fires
companionCoverArtUpdated and companionArtistPhotoUpdated events and
clears ImageCache immediately on receipt.
LibraryCache.swift — cacheCompanionAlbums/loadCompanionAlbums,
cacheCompanionAlbumSongs/loadCompanionAlbumSongs for local persistence
of companion data between launches.
SyncEngine.swift — syncCompanion() runs after every Navidrome sync
(bootstrap and delta) when Companion is enabled. It fetches albums
from /library/albums, converts them to Album objects, and overwrites
"all_albums" cache — so MyMusicView’s album grid immediately shows
Companion-sorted data. companionLibraryChanged notification triggers
reactive UI updates.
AsyncCoverArt.swift — Detects "companion:{id}" prefix and routes to
CompanionAPIService().coverArtURL(companionId:) instead of Navidrome.
All existing non-companion cover art paths are unchanged.
AlbumDetailView.swift — Detects albumId.hasPrefix("companion:"),
parses "companion:{name}|{artist}", calls
/library/songs?album=...&album_artist=... on Companion, and builds a
synthetic AlbumWithSongs locally. Caches results so the next tap is
instant. Falls back to the existing Navidrome path for all
non-companion album IDs.
2026-04-10 11:14:41 -07:00
|
|
|
static let companionArtistPhotoUpdated = Notification.Name("companionArtistPhotoUpdated")
|
2026-04-11 02:23:03 -07:00
|
|
|
static let companionLibraryChanged = Notification.Name("companionLibraryChanged")
|
|
|
|
|
static let companionConflictsUpdated = Notification.Name("companionConflictsUpdated")
|
2026-04-11 08:36:32 -07:00
|
|
|
static let companionTagsCleaned = Notification.Name("companionTagsCleaned")
|
2026-04-11 02:23:03 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Conflict Models
|
|
|
|
|
// Defined here so CompanionAPIService can reference them without a separate file.
|
|
|
|
|
|
|
|
|
|
struct AnyCodable: Codable {
|
|
|
|
|
let value: Any
|
|
|
|
|
|
|
|
|
|
init(_ value: Any) { self.value = value }
|
|
|
|
|
|
|
|
|
|
init(from decoder: Decoder) throws {
|
|
|
|
|
let container = try decoder.singleValueContainer()
|
|
|
|
|
if let v = try? container.decode([String].self) { value = v; return }
|
|
|
|
|
if let v = try? container.decode([String: String].self) { value = v; return }
|
|
|
|
|
if let v = try? container.decode(String.self) { value = v; return }
|
|
|
|
|
if let v = try? container.decode(Int.self) { value = v; return }
|
|
|
|
|
value = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
|
|
|
var container = encoder.singleValueContainer()
|
|
|
|
|
switch value {
|
|
|
|
|
case let v as [String]: try container.encode(v)
|
|
|
|
|
case let v as [String: String]: try container.encode(v)
|
|
|
|
|
case let v as String: try container.encode(v)
|
|
|
|
|
case let v as Int: try container.encode(v)
|
|
|
|
|
default: try container.encode("")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct LibraryConflict: Codable, Identifiable {
|
|
|
|
|
let type: String
|
|
|
|
|
let severity: String
|
|
|
|
|
let title: String
|
|
|
|
|
let detail: String
|
|
|
|
|
let affected_paths: [String]
|
|
|
|
|
let fix_action: String?
|
|
|
|
|
let fix_data: [String: AnyCodable]?
|
|
|
|
|
|
|
|
|
|
var id: String { "\(type)|\(title)" }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct ConflictsResponse: Codable {
|
|
|
|
|
let total: Int
|
|
|
|
|
let errors: Int
|
|
|
|
|
let warnings: Int
|
|
|
|
|
let issues: [LibraryConflict]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Conflict Manager
|
|
|
|
|
// Defined here (in CompanionAPIService.swift) so it compiles before
|
|
|
|
|
// DownloadsSettingsView.swift and LibraryConflictsView.swift reference it.
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
class ConflictManager: ObservableObject {
|
|
|
|
|
static let shared = ConflictManager()
|
|
|
|
|
|
|
|
|
|
@Published var conflicts: ConflictsResponse?
|
|
|
|
|
@Published var isLoading = false
|
|
|
|
|
@Published var lastError: String?
|
|
|
|
|
@Published var isFixing: String? = nil
|
|
|
|
|
|
2026-04-11 15:09:06 -07:00
|
|
|
private let api = CompanionAPIService.shared
|
2026-04-11 02:23:03 -07:00
|
|
|
|
|
|
|
|
var badgeCount: Int { conflicts?.errors ?? 0 }
|
|
|
|
|
var totalCount: Int { conflicts?.total ?? 0 }
|
|
|
|
|
|
|
|
|
|
init() {
|
|
|
|
|
NotificationCenter.default.addObserver(
|
|
|
|
|
forName: .companionConflictsUpdated,
|
|
|
|
|
object: nil,
|
|
|
|
|
queue: .main
|
|
|
|
|
) { [weak self] _ in
|
|
|
|
|
Task { await self?.refresh() }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func refresh() async {
|
|
|
|
|
guard CompanionSettings.shared.isEnabled else { return }
|
|
|
|
|
isLoading = true
|
|
|
|
|
lastError = nil
|
|
|
|
|
do {
|
|
|
|
|
conflicts = try await api.fetchConflicts()
|
|
|
|
|
} catch {
|
|
|
|
|
lastError = error.localizedDescription
|
|
|
|
|
}
|
|
|
|
|
isLoading = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fix(_ conflict: LibraryConflict) async {
|
|
|
|
|
guard let action = conflict.fix_action else { return }
|
|
|
|
|
isFixing = conflict.id
|
|
|
|
|
do {
|
|
|
|
|
try await api.fixConflict(action: action, fixData: conflict.fix_data)
|
|
|
|
|
try? await Task.sleep(for: .seconds(2))
|
|
|
|
|
await refresh()
|
|
|
|
|
} catch {
|
|
|
|
|
lastError = error.localizedDescription
|
|
|
|
|
}
|
|
|
|
|
isFixing = nil
|
|
|
|
|
}
|
2026-04-11 01:52:16 -07:00
|
|
|
}
|
|
|
|
|
|
updates to companion from ios to server
Models.swift — CompanionSong, CompanionAlbum, CompanionArtist,
CompanionLibraryResponse. Each has a toSong()/toAlbum() converter.
Song.id = navidrome_id (streaming still works), Song.coverArt =
"companion:{id}" (routes cover art to Companion), Album.id =
"companion:{name}|{artist}" (detected by AlbumDetailView).
CompanionAPIService.swift — fetchAllSongs, fetchAlbumSongs,
fetchAllAlbums, fetchAllArtists, searchLibrary are all nonisolated
where needed. coverArtURL/artistPhotoURL are nonisolated so views can
call them synchronously. Push handler now fires
companionCoverArtUpdated and companionArtistPhotoUpdated events and
clears ImageCache immediately on receipt.
LibraryCache.swift — cacheCompanionAlbums/loadCompanionAlbums,
cacheCompanionAlbumSongs/loadCompanionAlbumSongs for local persistence
of companion data between launches.
SyncEngine.swift — syncCompanion() runs after every Navidrome sync
(bootstrap and delta) when Companion is enabled. It fetches albums
from /library/albums, converts them to Album objects, and overwrites
"all_albums" cache — so MyMusicView’s album grid immediately shows
Companion-sorted data. companionLibraryChanged notification triggers
reactive UI updates.
AsyncCoverArt.swift — Detects "companion:{id}" prefix and routes to
CompanionAPIService().coverArtURL(companionId:) instead of Navidrome.
All existing non-companion cover art paths are unchanged.
AlbumDetailView.swift — Detects albumId.hasPrefix("companion:"),
parses "companion:{name}|{artist}", calls
/library/songs?album=...&album_artist=... on Companion, and builds a
synthetic AlbumWithSongs locally. Caches results so the next tap is
instant. Falls back to the existing Navidrome path for all
non-companion album IDs.
2026-04-10 11:14:41 -07:00
|
|
|
// MARK: - Library Fetch (Phase 1)
|
|
|
|
|
|
|
|
|
|
extension CompanionAPIService {
|
|
|
|
|
|
|
|
|
|
/// Fetch all songs, paginating until complete.
|
|
|
|
|
func fetchAllSongs(sort: String = "sort_album,disc_number,track_number") async throws -> [CompanionSong] {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
var all: [CompanionSong] = []
|
|
|
|
|
var page = 0
|
|
|
|
|
let perPage = 500
|
|
|
|
|
while true {
|
|
|
|
|
guard var comps = URLComponents(url: base.appendingPathComponent("library/songs"),
|
|
|
|
|
resolvingAgainstBaseURL: false) else { break }
|
|
|
|
|
comps.queryItems = [
|
|
|
|
|
URLQueryItem(name: "page", value: "\(page)"),
|
|
|
|
|
URLQueryItem(name: "per_page", value: "\(perPage)"),
|
|
|
|
|
URLQueryItem(name: "sort", value: sort),
|
|
|
|
|
]
|
|
|
|
|
guard let url = comps.url else { break }
|
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data)
|
|
|
|
|
let batch = result.songs ?? []
|
|
|
|
|
all.append(contentsOf: batch)
|
|
|
|
|
if batch.count < perPage { break }
|
|
|
|
|
page += 1
|
|
|
|
|
}
|
|
|
|
|
return all
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fetch songs for a specific album (used by AlbumDetailView).
|
|
|
|
|
func fetchAlbumSongs(album: String, albumArtist: String) async throws -> [CompanionSong] {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
guard var comps = URLComponents(url: base.appendingPathComponent("library/songs"),
|
|
|
|
|
resolvingAgainstBaseURL: false) else {
|
|
|
|
|
throw CompanionError.invalidURL
|
|
|
|
|
}
|
|
|
|
|
comps.queryItems = [
|
|
|
|
|
URLQueryItem(name: "album", value: album),
|
|
|
|
|
URLQueryItem(name: "album_artist", value: albumArtist),
|
|
|
|
|
URLQueryItem(name: "sort", value: "disc_number,track_number"),
|
|
|
|
|
URLQueryItem(name: "per_page", value: "500"),
|
|
|
|
|
]
|
|
|
|
|
guard let url = comps.url else { throw CompanionError.invalidURL }
|
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data)
|
|
|
|
|
return result.songs ?? []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fetch all albums.
|
|
|
|
|
func fetchAllAlbums() async throws -> [CompanionAlbum] {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
let url = base.appendingPathComponent("library/albums")
|
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data)
|
|
|
|
|
return result.albums ?? []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fetch all artists.
|
|
|
|
|
func fetchAllArtists() async throws -> [CompanionArtist] {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
let url = base.appendingPathComponent("library/artists")
|
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data)
|
|
|
|
|
return result.artists ?? []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Full-text search across title/artist/album/genre.
|
|
|
|
|
func searchLibrary(query: String, limit: Int = 50) async throws -> [CompanionSong] {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
guard var comps = URLComponents(url: base.appendingPathComponent("library/search"),
|
|
|
|
|
resolvingAgainstBaseURL: false) else {
|
|
|
|
|
throw CompanionError.invalidURL
|
|
|
|
|
}
|
|
|
|
|
comps.queryItems = [
|
|
|
|
|
URLQueryItem(name: "q", value: query),
|
|
|
|
|
URLQueryItem(name: "limit", value: "\(limit)"),
|
|
|
|
|
]
|
|
|
|
|
guard let url = comps.url else { throw CompanionError.invalidURL }
|
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data)
|
|
|
|
|
return result.songs ?? []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build a cover art URL for a companion song ID.
|
|
|
|
|
nonisolated func coverArtURL(companionId: String) -> URL? {
|
|
|
|
|
CompanionSettings.shared.baseURL?
|
|
|
|
|
.appendingPathComponent("library/cover-art/\(companionId)")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 08:07:55 -07:00
|
|
|
/// Upload a JPEG image as cover.jpg for the album containing this song.
|
|
|
|
|
func uploadCoverArt(songId: String, image: UIImage) async throws {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
let url = base.appendingPathComponent("library/cover-art/\(songId)")
|
|
|
|
|
let boundary = "Boundary-\(UUID().uuidString)"
|
|
|
|
|
var req = URLRequest(url: url)
|
|
|
|
|
req.httpMethod = "POST"
|
|
|
|
|
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
|
|
|
|
|
|
|
|
guard let jpeg = image.jpegData(compressionQuality: 0.92) else {
|
|
|
|
|
throw CompanionError.invalidResponse
|
|
|
|
|
}
|
|
|
|
|
let crlf = "\r\n"
|
|
|
|
|
var body = Data()
|
|
|
|
|
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
|
|
|
|
|
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"cover.jpg\"\(crlf)".data(using: .utf8)!)
|
|
|
|
|
body.append("Content-Type: image/jpeg\(crlf)\(crlf)".data(using: .utf8)!)
|
|
|
|
|
body.append(jpeg)
|
|
|
|
|
body.append("\(crlf)--\(boundary)--\(crlf)".data(using: .utf8)!)
|
|
|
|
|
req.httpBody = body
|
|
|
|
|
|
|
|
|
|
let (_, response) = try await session.data(for: req)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Delete cover.jpg from the album directory for the song.
|
|
|
|
|
func deleteCoverArt(songId: String) async throws {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
let url = base.appendingPathComponent("library/cover-art/\(songId)")
|
|
|
|
|
var req = URLRequest(url: url)
|
|
|
|
|
req.httpMethod = "DELETE"
|
|
|
|
|
let (_, response) = try await session.data(for: req)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 18:07:12 -07:00
|
|
|
/// Upload cover art using a relative file path (fallback when no companion DB ID).
|
|
|
|
|
/// Uses POST /library/cover-art-by-path with multipart form containing
|
|
|
|
|
/// the relative_path as a text field and the image as a file field.
|
|
|
|
|
func uploadCoverArtByPath(relativePath: String, image: UIImage) async throws {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
let url = base.appendingPathComponent("library/cover-art-by-path")
|
|
|
|
|
let boundary = "Boundary-\(UUID().uuidString)"
|
|
|
|
|
var req = URLRequest(url: url)
|
|
|
|
|
req.httpMethod = "POST"
|
|
|
|
|
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
|
|
|
|
|
|
|
|
guard let jpeg = image.jpegData(compressionQuality: 0.92) else {
|
|
|
|
|
throw CompanionError.invalidResponse
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let crlf = "\r\n"
|
|
|
|
|
var body = Data()
|
|
|
|
|
|
|
|
|
|
// Field: relative_path
|
|
|
|
|
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
|
|
|
|
|
body.append("Content-Disposition: form-data; name=\"relative_path\"\(crlf)\(crlf)".data(using: .utf8)!)
|
|
|
|
|
body.append(relativePath.data(using: .utf8)!)
|
|
|
|
|
body.append(crlf.data(using: .utf8)!)
|
|
|
|
|
|
|
|
|
|
// Field: file (image)
|
|
|
|
|
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
|
|
|
|
|
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"cover.jpg\"\(crlf)".data(using: .utf8)!)
|
|
|
|
|
body.append("Content-Type: image/jpeg\(crlf)\(crlf)".data(using: .utf8)!)
|
|
|
|
|
body.append(jpeg)
|
|
|
|
|
body.append("\(crlf)--\(boundary)--\(crlf)".data(using: .utf8)!)
|
|
|
|
|
|
|
|
|
|
req.httpBody = body
|
|
|
|
|
|
|
|
|
|
DebugLogger.shared.log(
|
|
|
|
|
"uploadCoverArtByPath: \(relativePath) (\(jpeg.count) bytes JPEG)",
|
|
|
|
|
category: "Companion"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
let (_, response) = try await session.data(for: req)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
}
|
|
|
|
|
|
updates to companion from ios to server
Models.swift — CompanionSong, CompanionAlbum, CompanionArtist,
CompanionLibraryResponse. Each has a toSong()/toAlbum() converter.
Song.id = navidrome_id (streaming still works), Song.coverArt =
"companion:{id}" (routes cover art to Companion), Album.id =
"companion:{name}|{artist}" (detected by AlbumDetailView).
CompanionAPIService.swift — fetchAllSongs, fetchAlbumSongs,
fetchAllAlbums, fetchAllArtists, searchLibrary are all nonisolated
where needed. coverArtURL/artistPhotoURL are nonisolated so views can
call them synchronously. Push handler now fires
companionCoverArtUpdated and companionArtistPhotoUpdated events and
clears ImageCache immediately on receipt.
LibraryCache.swift — cacheCompanionAlbums/loadCompanionAlbums,
cacheCompanionAlbumSongs/loadCompanionAlbumSongs for local persistence
of companion data between launches.
SyncEngine.swift — syncCompanion() runs after every Navidrome sync
(bootstrap and delta) when Companion is enabled. It fetches albums
from /library/albums, converts them to Album objects, and overwrites
"all_albums" cache — so MyMusicView’s album grid immediately shows
Companion-sorted data. companionLibraryChanged notification triggers
reactive UI updates.
AsyncCoverArt.swift — Detects "companion:{id}" prefix and routes to
CompanionAPIService().coverArtURL(companionId:) instead of Navidrome.
All existing non-companion cover art paths are unchanged.
AlbumDetailView.swift — Detects albumId.hasPrefix("companion:"),
parses "companion:{name}|{artist}", calls
/library/songs?album=...&album_artist=... on Companion, and builds a
synthetic AlbumWithSongs locally. Caches results so the next tap is
instant. Falls back to the existing Navidrome path for all
non-companion album IDs.
2026-04-10 11:14:41 -07:00
|
|
|
/// Build an artist photo URL for an artist name.
|
|
|
|
|
nonisolated func artistPhotoURL(artistName: String) -> URL? {
|
|
|
|
|
guard let encoded = artistName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)
|
|
|
|
|
else { return nil }
|
|
|
|
|
return CompanionSettings.shared.baseURL?
|
|
|
|
|
.appendingPathComponent("library/artist-photo/\(encoded)")
|
|
|
|
|
}
|
2026-04-11 02:23:03 -07:00
|
|
|
|
|
|
|
|
/// Fetch all library conflicts and issues.
|
|
|
|
|
func fetchConflicts() async throws -> ConflictsResponse {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
let url = base.appendingPathComponent("library/conflicts")
|
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
return try JSONDecoder().decode(ConflictsResponse.self, from: data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fix a specific conflict by action type.
|
|
|
|
|
func fixConflict(action: String, fixData: [String: AnyCodable]?) async throws {
|
|
|
|
|
let base = try baseURL()
|
|
|
|
|
let url = base.appendingPathComponent("library/fix-conflict")
|
|
|
|
|
var req = URLRequest(url: url)
|
|
|
|
|
req.httpMethod = "POST"
|
|
|
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
|
|
|
|
|
|
// Build body — AnyCodable.value can be String, Int, or [String]
|
|
|
|
|
var bodyFix: [String: Any] = [:]
|
|
|
|
|
if let fd = fixData {
|
|
|
|
|
for (k, v) in fd {
|
|
|
|
|
bodyFix[k] = v.value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let body: [String: Any] = ["action": action, "fix_data": bodyFix]
|
|
|
|
|
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
|
|
|
|
|
|
|
|
let (_, response) = try await session.data(for: req)
|
|
|
|
|
try validateResponse(response)
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Errors
|
|
|
|
|
|
|
|
|
|
enum CompanionError: LocalizedError {
|
|
|
|
|
case notConfigured, invalidURL, invalidResponse
|
|
|
|
|
case serverError(Int)
|
|
|
|
|
case serverErrorDetail(Int, String)
|
|
|
|
|
case extractionFailed(String)
|
|
|
|
|
case noAudioFiles
|
|
|
|
|
|
|
|
|
|
var errorDescription: String? {
|
|
|
|
|
switch self {
|
|
|
|
|
case .notConfigured: return "Companion API not configured"
|
|
|
|
|
case .invalidURL: return "Invalid Companion API URL"
|
|
|
|
|
case .invalidResponse: return "Invalid response from Companion API"
|
|
|
|
|
case .serverError(let code): return "Companion API error (HTTP \(code))"
|
|
|
|
|
case .serverErrorDetail(let code, let detail): return "HTTP \(code): \(detail)"
|
|
|
|
|
case .extractionFailed(let msg): return "Zip extraction failed: \(msg)"
|
|
|
|
|
case .noAudioFiles: return "No audio files found in archive"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|