diff --git a/CustomApps/lyrics-plus/ProviderLRCLIB.js b/CustomApps/lyrics-plus/ProviderLRCLIB.js index c72a9c2217..78292b4481 100644 --- a/CustomApps/lyrics-plus/ProviderLRCLIB.js +++ b/CustomApps/lyrics-plus/ProviderLRCLIB.js @@ -9,11 +9,27 @@ const ProviderLRCLIB = (() => { duration: durr, }; - const finalURL = `${baseURL}?${Object.keys(params) - .map((key) => `${key}=${encodeURIComponent(params[key])}`) + const getURL = (queryParams) => `${baseURL}?${Object.keys(queryParams) + .map((key) => `${key}=${encodeURIComponent(queryParams[key])}`) .join("&")}`; + const headers = { + "x-user-agent": `spicetify v${Spicetify.Config.version} (https://github.com/spicetify/cli)`, + }; + + let body; + try { + body = await fetch(getURL(params), { headers }); + } catch { + body = null; + } + + if (body?.status === 200) { + return await body.json(); + } - const body = await fetch(finalURL, { + const fallbackParams = { ...params }; + delete fallbackParams.duration; + body = await fetch(getURL(fallbackParams), { headers: { "x-user-agent": `spicetify v${Spicetify.Config.version} (https://github.com/spicetify/cli)`, }, @@ -39,6 +55,67 @@ const ProviderLRCLIB = (() => { return Utils.parseLocalLyrics(unsyncedLyrics).unsynced; } + function getKaraoke(body) { + const lyricsFile = body?.lyricsfile; + if (typeof lyricsFile !== "string") return null; + + const result = []; + let line; + let word; + + function parseValue(value) { + const trimmed = value.trim(); + if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) { + return trimmed.slice(1, -1).replaceAll("''", "'"); + } + return trimmed; + } + + function finishWord() { + if (!line || !word || word.text === undefined || word.start_ms === undefined) return; + line.words.push({ word: word.text, start_ms: word.start_ms, end_ms: word.end_ms }); + word = null; + } + + function finishLine() { + finishWord(); + if (line && line.start_ms !== undefined && line.words.length) { + const words = line.words.map((currentWord, index) => { + const nextWord = line.words[index + 1]; + const end = currentWord.end_ms ?? nextWord?.start_ms ?? line.end_ms ?? currentWord.start_ms; + return { word: currentWord.word, time: Math.max(end - currentWord.start_ms, 0) }; + }); + result.push({ startTime: line.start_ms, text: words }); + } + line = null; + } + + for (const rawLine of lyricsFile.split(/\r?\n/)) { + const indentation = rawLine.match(/^\s*/)[0].length; + const content = rawLine.trim(); + if (indentation === 2 && content.startsWith("- text:")) { + finishLine(); + line = { start_ms: undefined, words: [] }; + continue; + } + if (indentation === 6 && content.startsWith("- text:")) { + finishWord(); + word = { text: parseValue(content.slice("- text:".length)) }; + continue; + } + const timestamp = content.match(/^(start_ms|end_ms):\s*(\d+(?:\.\d+)?)/); + if (timestamp) { + const key = timestamp[1]; + const value = Number(timestamp[2]); + if (indentation >= 6 && word) word[key] = value; + else if (line) line[key] = value; + } + } + finishLine(); + + return result.length ? result : null; + } + function getSynced(body) { const syncedLyrics = body?.syncedLyrics; const isInstrumental = body.instrumental; @@ -49,5 +126,5 @@ const ProviderLRCLIB = (() => { return Utils.parseLocalLyrics(syncedLyrics).synced; } - return { findLyrics, getSynced, getUnsynced }; + return { findLyrics, getKaraoke, getSynced, getUnsynced }; })(); diff --git a/CustomApps/lyrics-plus/Providers.js b/CustomApps/lyrics-plus/Providers.js index 5f83bd1a28..ebc6250837 100644 --- a/CustomApps/lyrics-plus/Providers.js +++ b/CustomApps/lyrics-plus/Providers.js @@ -183,6 +183,11 @@ const Providers = { return result; } + const karaoke = ProviderLRCLIB.getKaraoke(list); + if (karaoke) { + result.karaoke = karaoke; + } + const synced = ProviderLRCLIB.getSynced(list); if (synced) { result.synced = synced; diff --git a/CustomApps/lyrics-plus/index.js b/CustomApps/lyrics-plus/index.js index 1e139bb45e..8116af6e21 100644 --- a/CustomApps/lyrics-plus/index.js +++ b/CustomApps/lyrics-plus/index.js @@ -72,8 +72,8 @@ const CONFIG = { providers: { lrclib: { on: getConfig("lyrics-plus:provider:lrclib:on"), - desc: "Lyrics sourced from lrclib.net. Supports both synced and unsynced lyrics. LRCLIB is a free and open-source lyrics provider.", - modes: [SYNCED, UNSYNCED], + desc: "Lyrics sourced from lrclib.net. Supports karaoke, synced and unsynced lyrics. LRCLIB is a free and open-source lyrics provider.", + modes: [KARAOKE, SYNCED, UNSYNCED], }, musixmatch: { on: getConfig("lyrics-plus:provider:musixmatch:on"),