LRCLIBService.swift 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import Foundation
  2. /// Fetches lyrics from LRCLIB (https://lrclib.net) — a free, open-source, community-driven lyrics API.
  3. /// No API key required. Returns both synced (LRC timestamped) and plain lyrics.
  4. actor LRCLIBService {
  5. static let shared = LRCLIBService()
  6. private let baseURL = "https://lrclib.net/api"
  7. private let session: URLSession
  8. /// Cached results keyed by "artist - title".
  9. private var cache: [String: LyricsResult] = [:]
  10. init() {
  11. let config = URLSessionConfiguration.default
  12. config.timeoutIntervalForRequest = 10
  13. config.httpAdditionalHeaders = ["User-Agent": "MixBoard/1.0 (https://github.com/mixboard)"]
  14. session = URLSession(configuration: config)
  15. }
  16. // MARK: - Public API
  17. /// Search for lyrics by artist and track title.
  18. /// Optionally provide album name and duration (in seconds) for better matching.
  19. func fetchLyrics(
  20. artist: String,
  21. title: String,
  22. album: String? = nil,
  23. duration: TimeInterval? = nil
  24. ) async throws -> LyricsResult {
  25. // Check cache
  26. let cacheKey = "\(artist.lowercased()) - \(title.lowercased())"
  27. if let cached = cache[cacheKey] {
  28. return cached
  29. }
  30. // Build the request URL
  31. // First try the exact "get" endpoint (best match with duration)
  32. if let result = try? await fetchExact(artist: artist, title: title, album: album, duration: duration) {
  33. cache[cacheKey] = result
  34. return result
  35. }
  36. // Fallback to search endpoint
  37. let result = try await fetchSearch(artist: artist, title: title)
  38. cache[cacheKey] = result
  39. return result
  40. }
  41. /// Clear the lyrics cache.
  42. func clearCache() {
  43. cache.removeAll()
  44. }
  45. // MARK: - Private
  46. /// Use the /api/get endpoint (exact match with optional duration).
  47. private func fetchExact(
  48. artist: String,
  49. title: String,
  50. album: String?,
  51. duration: TimeInterval?
  52. ) async throws -> LyricsResult {
  53. var components = URLComponents(string: "\(baseURL)/get")!
  54. var queryItems = [
  55. URLQueryItem(name: "artist_name", value: artist),
  56. URLQueryItem(name: "track_name", value: title)
  57. ]
  58. if let album, !album.isEmpty {
  59. queryItems.append(URLQueryItem(name: "album_name", value: album))
  60. }
  61. if let duration, duration > 0 {
  62. queryItems.append(URLQueryItem(name: "duration", value: String(Int(duration))))
  63. }
  64. components.queryItems = queryItems
  65. guard let url = components.url else {
  66. throw LyricsError.invalidURL
  67. }
  68. let (data, response) = try await session.data(from: url)
  69. guard let httpResponse = response as? HTTPURLResponse else {
  70. throw LyricsError.networkError
  71. }
  72. if httpResponse.statusCode == 404 {
  73. throw LyricsError.notFound
  74. }
  75. guard httpResponse.statusCode == 200 else {
  76. throw LyricsError.httpError(httpResponse.statusCode)
  77. }
  78. let decoded = try JSONDecoder().decode(LRCLIBResponse.self, from: data)
  79. return LyricsResult(from: decoded)
  80. }
  81. /// Use the /api/search endpoint (fuzzy search, returns array).
  82. private func fetchSearch(artist: String, title: String) async throws -> LyricsResult {
  83. var components = URLComponents(string: "\(baseURL)/search")!
  84. components.queryItems = [
  85. URLQueryItem(name: "q", value: "\(artist) \(title)")
  86. ]
  87. guard let url = components.url else {
  88. throw LyricsError.invalidURL
  89. }
  90. let (data, response) = try await session.data(from: url)
  91. guard let httpResponse = response as? HTTPURLResponse,
  92. httpResponse.statusCode == 200 else {
  93. throw LyricsError.networkError
  94. }
  95. let results = try JSONDecoder().decode([LRCLIBResponse].self, from: data)
  96. guard let best = results.first else {
  97. throw LyricsError.notFound
  98. }
  99. return LyricsResult(from: best)
  100. }
  101. }
  102. // MARK: - Models
  103. /// Response from the LRCLIB API.
  104. private struct LRCLIBResponse: Decodable {
  105. let id: Int
  106. let trackName: String?
  107. let artistName: String?
  108. let albumName: String?
  109. let duration: Double?
  110. let instrumental: Bool?
  111. let plainLyrics: String?
  112. let syncedLyrics: String?
  113. }
  114. /// Parsed lyrics result.
  115. struct LyricsResult {
  116. let trackName: String
  117. let artistName: String
  118. let albumName: String
  119. let isInstrumental: Bool
  120. let plainLyrics: String?
  121. let syncedLyrics: String?
  122. /// Whether synced (timestamped) lyrics are available.
  123. var hasSyncedLyrics: Bool { syncedLyrics != nil && !(syncedLyrics?.isEmpty ?? true) }
  124. /// Whether any lyrics are available.
  125. var hasLyrics: Bool { hasSyncedLyrics || (plainLyrics != nil && !(plainLyrics?.isEmpty ?? true)) }
  126. fileprivate init(from response: LRCLIBResponse) {
  127. trackName = response.trackName ?? ""
  128. artistName = response.artistName ?? ""
  129. albumName = response.albumName ?? ""
  130. isInstrumental = response.instrumental ?? false
  131. plainLyrics = response.plainLyrics
  132. syncedLyrics = response.syncedLyrics
  133. }
  134. init(
  135. trackName: String = "",
  136. artistName: String = "",
  137. albumName: String = "",
  138. isInstrumental: Bool = false,
  139. plainLyrics: String? = nil,
  140. syncedLyrics: String? = nil
  141. ) {
  142. self.trackName = trackName
  143. self.artistName = artistName
  144. self.albumName = albumName
  145. self.isInstrumental = isInstrumental
  146. self.plainLyrics = plainLyrics
  147. self.syncedLyrics = syncedLyrics
  148. }
  149. }
  150. // MARK: - Errors
  151. enum LyricsError: LocalizedError {
  152. case invalidURL
  153. case notFound
  154. case networkError
  155. case httpError(Int)
  156. var errorDescription: String? {
  157. switch self {
  158. case .invalidURL: return "Invalid lyrics search URL"
  159. case .notFound: return "No lyrics found"
  160. case .networkError: return "Network error fetching lyrics"
  161. case .httpError(let code): return "HTTP error \(code)"
  162. }
  163. }
  164. }