Files
Spanish/Conjuga/Conjuga/Services/LyricsSearchService.swift
Trey t faef20e5b8 Add Lyrics practice: search, translate, and read Spanish song lyrics
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>
2026-04-11 22:44:40 -05:00

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