diff --git a/src/datasource/youtube/YouTubeMusicDataSource.ts b/src/datasource/youtube/YouTubeMusicDataSource.ts index 357311d8..d9a51f46 100644 --- a/src/datasource/youtube/YouTubeMusicDataSource.ts +++ b/src/datasource/youtube/YouTubeMusicDataSource.ts @@ -203,6 +203,10 @@ type BetterLyricsResponse = { was really decided by the order of an array literal. It is now the LYRICS_SOURCES table. */ type LyricsProviderResult = Lyrics; +type SimpMusicResponse = { + data?: Array<{ syncedLyrics?: string | null; vote?: number }>; +}; + type RawLikeEndpoint = { status?: string; target?: string | { @@ -4658,6 +4662,7 @@ export class YouTubeMusicDataSource extends DataSource { "lrclib-exact": () => this.fetchLrcLibExactLyrics(track), betterlyrics: () => this.fetchBetterLyrics(track), "lrclib-search": () => this.fetchLrcLibSearchLyrics(track), + simpmusic: () => this.fetchSimpMusicLyrics(track), "youtube-transcript": () => this.fetchYouTubeTranscriptLyrics(track), "youtube-music": () => this.fetchYouTubeMusicLyrics(track), }; @@ -4929,6 +4934,28 @@ export class YouTubeMusicDataSource extends DataSource { return null; } + // Keyed by video ID, so never another recording; ranked below LRCLIB because entries can be translations (#129). + private async fetchSimpMusicLyrics(track: Track): Promise { + const response = await tauriFetch( + `https://api-lyrics.simpmusic.org/v1/${encodeURIComponent(track.id)}`, + { headers: this.getLyricsRequestHeaders(), timeoutMs: 3_500 }, + ); + if (!response.ok) return null; + + const body = await response.json() as SimpMusicResponse; + const best = (body.data ?? []) + .filter((entry) => entry.syncedLyrics) + .sort((left, right) => (right.vote ?? 0) - (left.vote ?? 0))[0]; + const lines = best?.syncedLyrics ? this.parseSyncedLyrics(best.syncedLyrics) : []; + if (lines.length === 0) return null; + + logInternalInfo("YouTubeMusicDataSource.getLyrics SimpMusic success", { + trackId: track.id, + lineCount: lines.length, + }); + return { lines, timing: "synced", sourceLabel: "SimpMusic" }; + } + private toLrcLibLyrics( track: Track, match: LrcLibTrack, diff --git a/src/datasource/youtube/lyricsSources.check.ts b/src/datasource/youtube/lyricsSources.check.ts index 5cd5297f..3facf6ba 100644 --- a/src/datasource/youtube/lyricsSources.check.ts +++ b/src/datasource/youtube/lyricsSources.check.ts @@ -39,7 +39,7 @@ const synced = (count: number) => ({ timing: "synced" as const, }); -equal(LYRICS_SOURCES.length, 5, "every source is in the table"); +equal(LYRICS_SOURCES.length, 6, "every source is in the table"); check( rankOfSource("lrclib-exact") < rankOfSource("lrclib-search"), "a duration-exact match outranks a text search of the same corpus", @@ -139,7 +139,7 @@ equal( const defaultWaves = planLyricsWaves(); equal(defaultWaves.length, 2, "by default the expensive sources are held back to a second wave"); -equal(defaultWaves[0].length, 3, "three cheap sources race first"); +equal(defaultWaves[0].length, 4, "four cheap sources race first"); check( defaultWaves[1].every((source) => source.wave === 2), "and only wave two sources are held back", @@ -161,7 +161,7 @@ equal( ); equal( planLyricsWaves("lrclib-exact")[0].length, - 3, + 4, "preferring a source already in wave one does not duplicate it", ); diff --git a/src/datasource/youtube/lyricsSources.ts b/src/datasource/youtube/lyricsSources.ts index 81886958..3aa7f7ff 100644 --- a/src/datasource/youtube/lyricsSources.ts +++ b/src/datasource/youtube/lyricsSources.ts @@ -15,7 +15,7 @@ export interface LyricsSource { * * Declared here rather than left as an early `return null` inside the fetcher, because a * fetcher that quietly returns nothing is indistinguishable from one that searched and - * found nothing — which is how two of five sources went missing on every track without a + * found nothing — which is how two of the sources went missing on every track without a * duration while the UI cheerfully reported "No match". */ requiresDuration?: boolean; @@ -55,6 +55,13 @@ export const LYRICS_SOURCES: LyricsSource[] = [ requiresDuration: true, note: "Same corpus, matched by text within two seconds of duration — can land on a different master.", }, + { + id: "simpmusic", + label: "SimpMusic", + timeoutMs: 3_500, + wave: 1, + note: "Line-synced and keyed to this exact video, but community entries are sometimes translations.", + }, { id: "youtube-transcript", label: "YouTube transcript", diff --git a/src/ui/pages/LyricsView.tsx b/src/ui/pages/LyricsView.tsx index a6ee6846..c06868eb 100644 --- a/src/ui/pages/LyricsView.tsx +++ b/src/ui/pages/LyricsView.tsx @@ -778,7 +778,7 @@ const SyncedLine = memo(function SyncedLine({ * * "No lyrics available" is the least useful sentence a music app can show — it gives the * listener nothing to act on and gives a bug report nothing to go on. This turns it into a - * fact: which of the five ranked sources was asked, how long it took, and why it lost. + * fact: which of the ranked sources was asked, how long it took, and why it lost. */ function LyricsSourcePanel({ attempts,