import Foundation import AVFoundation // ────────────────────────────────────────────────────────────────────── // LyricsService.swift // iOS-native lyrics fetching — no Companion API dependency. // // Priority: // 1. Local cache (previously fetched) // 2. Embedded lyrics in downloaded file (AVAsset metadata) // 3. LRCLIB direct (free public API, no auth) // 4. Companion API fallback (for server-side embedded tags) // // LRCLIB API: https://lrclib.net/api // GET /search?q=... → [LRCLIBResult] // GET /get?artist_name=...&track_name=...&duration=... → LRCLIBResult // ────────────────────────────────────────────────────────────────────── final class LyricsService { static let shared = LyricsService() private let lrclibBase = "https://lrclib.net/api" private let session: URLSession private init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 10 config.timeoutIntervalForResource = 15 session = URLSession(configuration: config) } // MARK: - Search LRCLIB (direct) /// Search LRCLIB for lyrics matching a free-text query. /// Returns an array of matches with synced/plain lyrics included. func search(query: String) async throws -> [LRCLIBResult] { guard var components = URLComponents(string: "\(lrclibBase)/search") else { throw LyricsServiceError.invalidURL } components.queryItems = [URLQueryItem(name: "q", value: query)] guard let url = components.url else { throw LyricsServiceError.invalidURL } let (data, response) = try await session.data(from: url) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw LyricsServiceError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0) } // LRCLIB returns slightly different field names than our model let raw = try JSONDecoder().decode([LRCLIBRawResult].self, from: data) return raw.map { $0.toLRCLIBResult() } } // MARK: - Fetch from LRCLIB (exact match) /// Exact-match fetch from LRCLIB by artist + title + duration. func fetch(artist: String, title: String, duration: Double) async throws -> LyricsResponse { guard var components = URLComponents(string: "\(lrclibBase)/get") else { throw LyricsServiceError.invalidURL } var items = [ URLQueryItem(name: "artist_name", value: artist), URLQueryItem(name: "track_name", value: title), ] if duration > 0 { items.append(URLQueryItem(name: "duration", value: String(Int(duration)))) } components.queryItems = items guard let url = components.url else { throw LyricsServiceError.invalidURL } let (data, response) = try await session.data(from: url) guard let http = response as? HTTPURLResponse else { throw LyricsServiceError.httpError(0) } if http.statusCode == 404 { return LyricsResponse(syncedLyrics: nil, plainLyrics: nil, source: "lrclib", cached: false, found: false) } guard http.statusCode == 200 else { throw LyricsServiceError.httpError(http.statusCode) } let raw = try JSONDecoder().decode(LRCLIBRawResult.self, from: data) return LyricsResponse( syncedLyrics: raw.syncedLyrics, plainLyrics: raw.plainLyrics, source: "lrclib", cached: false, found: raw.syncedLyrics != nil || raw.plainLyrics != nil ) } // MARK: - Read Embedded Lyrics (on-device) /// Read lyrics embedded in a local audio file's metadata tags. /// Works for downloaded songs — checks USLT (MP3), LYRICS (FLAC), ©lyr (M4A). func readEmbedded(songId: String) async -> LyricsResponse? { guard let localURL = OfflineManager.shared.localURL(for: songId) else { return nil } let asset = AVURLAsset(url: localURL) var lyricsText: String? do { // Modern async API (iOS 16+) let commonItems = try await asset.load(.commonMetadata) for item in commonItems { if item.commonKey == .commonKeyTitle { continue } if let key = item.commonKey?.rawValue, key.lowercased().contains("lyric") { lyricsText = try await item.load(.stringValue) } } if lyricsText == nil { let allItems = try await asset.load(.metadata) for item in allItems { if let key = item.identifier?.rawValue.lowercased(), (key.contains("lyric") || key.contains("uslt") || key.contains("©lyr")) { lyricsText = try await item.load(.stringValue) } } } } catch { return nil } guard let text = lyricsText, !text.isEmpty else { return nil } let hasTimes = text.contains("[") && text.range(of: #"\[\d{1,2}:\d{2}"#, options: .regularExpression) != nil return LyricsResponse( syncedLyrics: hasTimes ? text : nil, plainLyrics: hasTimes ? nil : text, source: "embedded", cached: false, found: true ) } } // MARK: - Errors enum LyricsServiceError: LocalizedError { case invalidURL case httpError(Int) var errorDescription: String? { switch self { case .invalidURL: return "Invalid lyrics URL" case .httpError(let code): return "Lyrics request failed (HTTP \(code))" } } } // MARK: - LRCLIB Raw Response (matches their actual API format) /// LRCLIB's native response format — field names differ from our app model. private struct LRCLIBRawResult: Codable { let id: Int? let trackName: String? let artistName: String? let albumName: String? let duration: Double? let syncedLyrics: String? let plainLyrics: String? func toLRCLIBResult() -> LRCLIBResult { LRCLIBResult( id: id, trackName: trackName ?? "", artistName: artistName ?? "", albumName: albumName, duration: duration ?? 0, hasSynced: syncedLyrics != nil, hasPlain: plainLyrics != nil, syncedLyrics: syncedLyrics, plainLyrics: plainLyrics ) } }