NavidromeApp/iOS/Views/Companion/CompanionAPIService.swift

578 lines
21 KiB
Swift
Raw Normal View History

import Foundation
// 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") }
}
@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 var memoryCache: [String: SmartDJProfile] = [:]
private init() {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
cacheDir = caches.appendingPathComponent("SmartDJProfiles", isDirectory: true)
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)
}
}
func clearAll() {
memoryCache.removeAll()
if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
for file in files { try? fileManager.removeItem(at: file) }
}
}
var cachedCount: Int {
(try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil))?.count ?? 0
}
}
// 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
}
// MARK: - Companion API Service
actor 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
}
/// 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: - 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) }
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 init() {}
func connect() {
guard CompanionSettings.shared.isEnabled,
CompanionSettings.shared.baseURL != nil else { return }
disconnect()
let wsURL = URL(string: "ws://\(CompanionSettings.shared.host):\(CompanionSettings.shared.port)/ws/push")!
webSocketTask = URLSession.shared.webSocketTask(with: wsURL)
webSocketTask?.resume()
isConnected = true
DebugLogger.shared.log("Push: connecting to \(wsURL)", category: "Companion")
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):
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
DebugLogger.shared.log("Push event: \(msg.event)", category: "Companion")
switch msg.event {
case "metadata_updated":
// Notify the app to refresh library
NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data)
case "track_uploaded":
NotificationCenter.default.post(name: .companionTrackUploaded, object: nil, userInfo: msg.data)
case "profile":
// Cache the profile locally
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() {
reconnectTask = Task {
try? await Task.sleep(for: .seconds(5))
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")
}
// 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"
}
}
}