import { jaroWinkler } from '@skyra/jaro-winkler'; import { config } from '../renderer/renderer'; import { LRC } from '../parsers/lrc'; import type { LyricProvider, LyricResult, SearchSongInfo } from '../types'; export class LRCLib implements LyricProvider { name = 'LRCLib'; baseUrl = 'https://lrclib.net'; async search({ title, alternativeTitle, artist, album, songDuration, tags, }: SearchSongInfo): Promise { let query = new URLSearchParams({ artist_name: artist, track_name: title, }); query.set('album_name', album!); if (query.get('album_name') === 'undefined') { query.delete('album_name'); } let url = `${this.baseUrl}/api/search?${query.toString()}`; let response = await fetch(url); if (!response.ok) { throw new Error(`bad HTTPStatus(${response.statusText})`); } let data = (await response.json()) as LRCLIBSearchResponse; if (!data || !Array.isArray(data)) { throw new Error(`Expected an array, instead got ${typeof data}`); } if (data.length === 0) { if (!config()?.showLyricsEvenIfInexact) { return null; } // Try to search with the alternative title (original language) const trackName = alternativeTitle || title; query = new URLSearchParams({ q: `${trackName}` }); url = `${this.baseUrl}/api/search?${query.toString()}`; response = await fetch(url); if (!response.ok) { throw new Error(`bad HTTPStatus(${response.statusText})`); } data = (await response.json()) as LRCLIBSearchResponse; if (!Array.isArray(data)) { throw new Error(`Expected an array, instead got ${typeof data}`); } // If still no results, try with the original title as fallback if (data.length === 0 && alternativeTitle) { query = new URLSearchParams({ q: title }); url = `${this.baseUrl}/api/search?${query.toString()}`; response = await fetch(url); if (!response.ok) { throw new Error(`bad HTTPStatus(${response.statusText})`); } data = (await response.json()) as LRCLIBSearchResponse; if (!Array.isArray(data)) { throw new Error(`Expected an array, instead got ${typeof data}`); } } } const filteredResults = []; for (const item of data) { const { artistName } = item; const artists = artist.split(/[&,]/g).map((i) => i.trim()); const itemArtists = artistName.split(/[&,]/g).map((i) => i.trim()); // Try to match using artist name first const permutations = []; for (const artistA of artists) { for (const artistB of itemArtists) { permutations.push([artistA.toLowerCase(), artistB.toLowerCase()]); } } for (const artistA of itemArtists) { for (const artistB of artists) { permutations.push([artistA.toLowerCase(), artistB.toLowerCase()]); } } let ratio = Math.max( ...permutations.map(([x, y]) => jaroWinkler(x, y)), ); // If direct artist match is below threshold and we have tags, try matching with tags if (ratio <= 0.9 && tags && tags.length > 0) { // Filter out the artist from tags to avoid duplicate comparisons const filteredTags = tags.filter(tag => tag.toLowerCase() !== artist.toLowerCase()); const tagPermutations = []; // Compare each tag with each item artist for (const tag of filteredTags) { for (const itemArtist of itemArtists) { tagPermutations.push([tag.toLowerCase(), itemArtist.toLowerCase()]); } } // Compare each item artist with each tag for (const itemArtist of itemArtists) { for (const tag of filteredTags) { tagPermutations.push([itemArtist.toLowerCase(), tag.toLowerCase()]); } } if (tagPermutations.length > 0) { const tagRatio = Math.max( ...tagPermutations.map(([x, y]) => jaroWinkler(x, y)), ); // Use the best match ratio between direct artist match and tag match ratio = Math.max(ratio, tagRatio); } } if (ratio <= 0.9) continue; filteredResults.push(item); } filteredResults.sort(({ duration: durationA }, { duration: durationB }) => { const left = Math.abs(durationA - songDuration); const right = Math.abs(durationB - songDuration); return left - right; }); const closestResult = filteredResults[0]; if (!closestResult) { return null; } if (Math.abs(closestResult.duration - songDuration) > 15) { return null; } if (closestResult.instrumental) { return null; } const raw = closestResult.syncedLyrics; const plain = closestResult.plainLyrics; if (!raw && !plain) { return null; } return { title: closestResult.trackName, artists: closestResult.artistName.split(/[&,]/g), lines: raw ? LRC.parse(raw).lines.map((l) => ({ ...l, status: 'upcoming' as const, })) : undefined, lyrics: plain, }; } } type LRCLIBSearchResponse = { id: number; name: string; trackName: string; artistName: string; albumName: string; duration: number; instrumental: boolean; plainLyrics: string; syncedLyrics: string; }[];