| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199 |
- 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)"
- }
- }
- }
|