New feature in the Practice tab that lets users search for Spanish songs by artist + title, fetch lyrics from LRCLIB (free, no API key), pull album art from iTunes Search API, auto-translate to English via Apple's on-device Translation framework, and save for offline reading. Components: - SavedSong SwiftData model (local container, no CloudKit sync) - LyricsSearchService actor (LRCLIB + iTunes Search, concurrent) - LyricsSearchView (artist/song fields, result list with album art) - LyricsConfirmationView (lyrics preview, auto-translation, save) - LyricsLibraryView (saved songs list, swipe to delete) - LyricsReaderView (Spanish lines with English subtitles) - Practice tab integration (Lyrics button with NavigationLink) - localStoreResetVersion bumped to 3 for schema migration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
122 lines
4.2 KiB
Swift
122 lines
4.2 KiB
Swift
import Foundation
|
|
|
|
struct LyricsSearchResult: Sendable {
|
|
let title: String
|
|
let artist: String
|
|
let lyricsES: String
|
|
let albumArtURL: String?
|
|
let appleMusicURL: String?
|
|
}
|
|
|
|
actor LyricsSearchService {
|
|
|
|
// MARK: - Public
|
|
|
|
func searchLyrics(artist: String, title: String) async throws -> [LyricsSearchResult] {
|
|
async let lrcResults = searchLRCLIB(artist: artist, title: title)
|
|
async let itunesResults = searchITunes(artist: artist, title: title)
|
|
|
|
let lyrics = try await lrcResults
|
|
let metadata = try? await itunesResults
|
|
|
|
return lyrics.map { lrc in
|
|
let match = metadata?.bestMatch(artist: lrc.artistName, title: lrc.trackName)
|
|
return LyricsSearchResult(
|
|
title: lrc.trackName,
|
|
artist: lrc.artistName,
|
|
lyricsES: lrc.plainLyrics,
|
|
albumArtURL: match?.artworkURL600,
|
|
appleMusicURL: match?.trackViewURL
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - LRCLIB
|
|
|
|
private struct LRCLIBResult: Decodable, Sendable {
|
|
let trackName: String
|
|
let artistName: String
|
|
let plainLyrics: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case trackName, artistName, plainLyrics
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
trackName = try c.decode(String.self, forKey: .trackName)
|
|
artistName = try c.decode(String.self, forKey: .artistName)
|
|
plainLyrics = (try? c.decode(String.self, forKey: .plainLyrics)) ?? ""
|
|
}
|
|
}
|
|
|
|
private func searchLRCLIB(artist: String, title: String) async throws -> [LRCLIBResult] {
|
|
var components = URLComponents(string: "https://lrclib.net/api/search")!
|
|
components.queryItems = [
|
|
URLQueryItem(name: "track_name", value: title),
|
|
URLQueryItem(name: "artist_name", value: artist),
|
|
]
|
|
guard let url = components.url else { return [] }
|
|
|
|
var request = URLRequest(url: url)
|
|
request.setValue("Conjuga/1.0", forHTTPHeaderField: "User-Agent")
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return [] }
|
|
|
|
let results = try JSONDecoder().decode([LRCLIBResult].self, from: data)
|
|
return results.filter { !$0.plainLyrics.isEmpty }
|
|
}
|
|
|
|
// MARK: - iTunes Search
|
|
|
|
private struct ITunesResponse: Decodable {
|
|
let results: [ITunesTrack]
|
|
}
|
|
|
|
private struct ITunesTrack: Decodable {
|
|
let trackName: String?
|
|
let artistName: String?
|
|
let artworkUrl100: String?
|
|
let trackViewUrl: String?
|
|
|
|
var artworkURL600: String? {
|
|
artworkUrl100?.replacingOccurrences(of: "100x100", with: "600x600")
|
|
}
|
|
|
|
var trackViewURL: String? { trackViewUrl }
|
|
}
|
|
|
|
private struct ITunesMetadata: Sendable {
|
|
let tracks: [ITunesTrack]
|
|
|
|
func bestMatch(artist: String, title: String) -> ITunesTrack? {
|
|
let normalizedArtist = artist.lowercased()
|
|
let normalizedTitle = title.lowercased()
|
|
|
|
// Prefer exact title+artist match, then just title
|
|
return tracks.first {
|
|
($0.trackName ?? "").lowercased().contains(normalizedTitle) &&
|
|
($0.artistName ?? "").lowercased().contains(normalizedArtist)
|
|
} ?? tracks.first {
|
|
($0.trackName ?? "").lowercased().contains(normalizedTitle)
|
|
} ?? tracks.first
|
|
}
|
|
}
|
|
|
|
private func searchITunes(artist: String, title: String) async throws -> ITunesMetadata {
|
|
let query = "\(artist) \(title)"
|
|
var components = URLComponents(string: "https://itunes.apple.com/search")!
|
|
components.queryItems = [
|
|
URLQueryItem(name: "term", value: query),
|
|
URLQueryItem(name: "media", value: "music"),
|
|
URLQueryItem(name: "limit", value: "5"),
|
|
]
|
|
guard let url = components.url else { return ITunesMetadata(tracks: []) }
|
|
|
|
let (data, _) = try await URLSession.shared.data(from: url)
|
|
let response = try JSONDecoder().decode(ITunesResponse.self, from: data)
|
|
return ITunesMetadata(tracks: response.results)
|
|
}
|
|
}
|