import Foundation /// Fetches lyrics from LRCLIB (https://lrclib.net) — a free, open-source, community-driven lyrics API. /// No API key required. Returns both synced (LRC timestamped) and plain lyrics. actor LRCLIBService { static let shared = LRCLIBService() private let baseURL = "https://lrclib.net/api" private let session: URLSession /// Cached results keyed by "artist - title". private var cache: [String: LyricsResult] = [:] init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 10 config.httpAdditionalHeaders = ["User-Agent": "MixBoard/1.0 (https://github.com/mixboard)"] session = URLSession(configuration: config) } // MARK: - Public API /// Search for lyrics by artist and track title. /// Optionally provide album name and duration (in seconds) for better matching. func fetchLyrics( artist: String, title: String, album: String? = nil, duration: TimeInterval? = nil ) async throws -> LyricsResult { // Check cache let cacheKey = "\(artist.lowercased()) - \(title.lowercased())" if let cached = cache[cacheKey] { return cached } // Build the request URL // First try the exact "get" endpoint (best match with duration) if let result = try? await fetchExact(artist: artist, title: title, album: album, duration: duration) { cache[cacheKey] = result return result } // Fallback to search endpoint let result = try await fetchSearch(artist: artist, title: title) cache[cacheKey] = result return result } /// Clear the lyrics cache. func clearCache() { cache.removeAll() } // MARK: - Private /// Use the /api/get endpoint (exact match with optional duration). private func fetchExact( artist: String, title: String, album: String?, duration: TimeInterval? ) async throws -> LyricsResult { var components = URLComponents(string: "\(baseURL)/get")! var queryItems = [ URLQueryItem(name: "artist_name", value: artist), URLQueryItem(name: "track_name", value: title) ] if let album, !album.isEmpty { queryItems.append(URLQueryItem(name: "album_name", value: album)) } if let duration, duration > 0 { queryItems.append(URLQueryItem(name: "duration", value: String(Int(duration)))) } components.queryItems = queryItems guard let url = components.url else { throw LyricsError.invalidURL } let (data, response) = try await session.data(from: url) guard let httpResponse = response as? HTTPURLResponse else { throw LyricsError.networkError } if httpResponse.statusCode == 404 { throw LyricsError.notFound } guard httpResponse.statusCode == 200 else { throw LyricsError.httpError(httpResponse.statusCode) } let decoded = try JSONDecoder().decode(LRCLIBResponse.self, from: data) return LyricsResult(from: decoded) } /// Use the /api/search endpoint (fuzzy search, returns array). private func fetchSearch(artist: String, title: String) async throws -> LyricsResult { var components = URLComponents(string: "\(baseURL)/search")! components.queryItems = [ URLQueryItem(name: "q", value: "\(artist) \(title)") ] guard let url = components.url else { throw LyricsError.invalidURL } let (data, response) = try await session.data(from: url) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw LyricsError.networkError } let results = try JSONDecoder().decode([LRCLIBResponse].self, from: data) guard let best = results.first else { throw LyricsError.notFound } return LyricsResult(from: best) } } // MARK: - Models /// Response from the LRCLIB API. private struct LRCLIBResponse: Decodable { let id: Int let trackName: String? let artistName: String? let albumName: String? let duration: Double? let instrumental: Bool? let plainLyrics: String? let syncedLyrics: String? } /// Parsed lyrics result. struct LyricsResult { let trackName: String let artistName: String let albumName: String let isInstrumental: Bool let plainLyrics: String? let syncedLyrics: String? /// Whether synced (timestamped) lyrics are available. var hasSyncedLyrics: Bool { syncedLyrics != nil && !(syncedLyrics?.isEmpty ?? true) } /// Whether any lyrics are available. var hasLyrics: Bool { hasSyncedLyrics || (plainLyrics != nil && !(plainLyrics?.isEmpty ?? true)) } fileprivate init(from response: LRCLIBResponse) { trackName = response.trackName ?? "" artistName = response.artistName ?? "" albumName = response.albumName ?? "" isInstrumental = response.instrumental ?? false plainLyrics = response.plainLyrics syncedLyrics = response.syncedLyrics } init( trackName: String = "", artistName: String = "", albumName: String = "", isInstrumental: Bool = false, plainLyrics: String? = nil, syncedLyrics: String? = nil ) { self.trackName = trackName self.artistName = artistName self.albumName = albumName self.isInstrumental = isInstrumental self.plainLyrics = plainLyrics self.syncedLyrics = syncedLyrics } } // MARK: - Errors enum LyricsError: LocalizedError { case invalidURL case notFound case networkError case httpError(Int) var errorDescription: String? { switch self { case .invalidURL: return "Invalid lyrics search URL" case .notFound: return "No lyrics found" case .networkError: return "Network error fetching lyrics" case .httpError(let code): return "HTTP error \(code)" } } }