NavidromeApp/iOS/Views/Companion/CompanionAPIService.swift
Dallas Groot c0a073bf3f 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

1082 lines
42 KiB
Swift

import Foundation
import UIKit
// 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 {
didSet {
UserDefaults.standard.set(isEnabled, forKey: "companion_enabled")
if !isEnabled {
CompanionPushClient.shared.disconnect()
}
}
}
@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
private let bulkCacheURL: URL
private var memoryCache: [String: SmartDJProfile] = [:]
private init() {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
cacheDir = caches.appendingPathComponent("SmartDJProfiles", isDirectory: true)
bulkCacheURL = caches.appendingPathComponent("dj_profiles_bulk.json")
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)
}
}
// 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"
)
}
func clearAll() {
memoryCache.removeAll()
try? fileManager.removeItem(at: bulkCacheURL)
if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
for file in files { try? fileManager.removeItem(at: file) }
}
}
var cachedCount: Int { memoryCache.count }
}
// 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
var preserveComposer: Bool = false
var preserveLyrics: Bool = false
}
// MARK: - Companion API Service
actor CompanionAPIService {
/// 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()
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
}
/// 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()
let url = base.appendingPathComponent("smart-dj/profiles/export")
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
}
/// 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()
var components = URLComponents(url: base.appendingPathComponent("smart-dj/bulk-profiles"), resolvingAgainstBaseURL: false)!
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
}
// 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()
var components = URLComponents(url: base.appendingPathComponent("lyrics/fetch"), resolvingAgainstBaseURL: false)!
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()
var components = URLComponents(url: base.appendingPathComponent("lyrics/search"), resolvingAgainstBaseURL: false)!
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()
let url = base.appendingPathComponent("lyrics/embed")
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)
}
// 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) }
field("preserve_composer", metadata.preserveComposer ? "true" : "false")
field("preserve_lyrics", metadata.preserveLyrics ? "true" : "false")
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>?
private var reconnectDelay: TimeInterval = 5 // starts at 5s
private let maxReconnectDelay: TimeInterval = 120 // caps at 2 min
private var reconnectAttempts = 0
private init() {}
func connect() {
guard CompanionSettings.shared.isEnabled,
CompanionSettings.shared.baseURL != nil else { return }
let wasReconnecting = reconnectAttempts > 0
disconnect()
let wsURL = URL(string: "ws://\(CompanionSettings.shared.host):\(CompanionSettings.shared.port)/ws/push")!
webSocketTask = URLSession.shared.webSocketTask(with: wsURL)
webSocketTask?.resume()
isConnected = true
if wasReconnecting {
DebugLogger.shared.log(
"Push: reconnected after \(reconnectAttempts) attempts",
category: "Companion"
)
}
// Reset backoff on fresh connect
reconnectDelay = 5
reconnectAttempts = 0
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):
// 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")
}
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
// Suppress noisy pong events from log
if msg.event != "pong" {
DebugLogger.shared.log("Push event: \(msg.event)", category: "Companion")
}
switch msg.event {
case "metadata_updated":
NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data)
case "track_uploaded", "tracks_uploaded":
NotificationCenter.default.post(name: .companionTrackUploaded, object: nil, userInfo: msg.data)
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)
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)
case "tags_cleaned":
NotificationCenter.default.post(name: .companionTagsCleaned, object: nil, userInfo: msg.data)
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() {
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)
// 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"
)
}
reconnectTask = Task {
try? await Task.sleep(for: .seconds(delay))
await MainActor.run { self.connect() }
}
}
}
struct PushMessage: Codable {
let event: String
let data: [String: String]?
}
extension Notification.Name {
static let companionMetadataUpdated = Notification.Name("companionMetadataUpdated")
static let companionTrackUploaded = Notification.Name("companionTrackUploaded")
static let companionCoverArtUpdated = Notification.Name("companionCoverArtUpdated")
static let companionArtistPhotoUpdated = Notification.Name("companionArtistPhotoUpdated")
static let companionLibraryChanged = Notification.Name("companionLibraryChanged")
static let companionConflictsUpdated = Notification.Name("companionConflictsUpdated")
static let companionTagsCleaned = Notification.Name("companionTagsCleaned")
}
// 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
private let api = CompanionAPIService.shared
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
}
}
// 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)")
}
/// 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)
}
/// 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)
}
/// 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)")
}
/// 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)
}
}
// 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"
}
}
}