Metadata/art/colors delayed — currentSong, artwork, widget colors, and lyrics only updated after finalizeCrossfade. Now a songHandoff callback fires at the crossfade midpoint (50%) — updates everything mid-fade so the UI transitions with the audio. Scrobble of the outgoing song also fires here (before currentSong changes). Visualizer stale data — Old song’s vis frames stayed in the buffer during the first half of crossfade. Now visualizerHandoff zeros offlineVisBuffer + _audioLevels before loading the new song’s data. Clean slate → simulation fills the gap → real vis takes over. The needsNextTrack callback (post-finalize) now only handles player swap, observer re-registration, and queue persistence — no redundant metadata/scrobble work.
1082 lines
42 KiB
Swift
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 = URL(string: "\(base)/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(string: "\(base)/smart-dj/bulk-profiles")!
|
|
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(string: "\(base)/lyrics/fetch")!
|
|
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(string: "\(base)/lyrics/search")!
|
|
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 = URL(string: "\(base)/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"
|
|
}
|
|
}
|
|
}
|