feat(lyrics): add selectable LRCLIB results (#102)

This commit is contained in:
rrzt
2026-07-25 20:04:21 -04:00
committed by GitHub
parent 0c971b1121
commit 4c113f7370
10 changed files with 552 additions and 31 deletions
+14 -3
View File
@@ -1,4 +1,4 @@
# Noctalia Lyrics 1.4.3
# Noctalia Lyrics 1.4.4
Synchronized lyrics for the Noctalia bar, with multiple MPRIS players,
translation and romanization layers, configurable sources, karaoke highlighting,
@@ -9,7 +9,7 @@ album artwork, and layout controls.
| Field | Value |
| --- | --- |
| ID | `h465855hgg/lyrics` |
| Entries | Bar widget: `lyrics`; service: `service` |
| Entries | Bar widget: `lyrics`; panel: `selector`; shortcut: `open_selector`; service: `service` |
## Requirements
@@ -26,6 +26,14 @@ Install these commands on `PATH`:
Enable `h465855hgg/lyrics`, start its `service` entry, and add the `lyrics` bar
widget. Left-click switches between lyrics and track information when
`display_mode` is `toggle`; right-click pauses or resumes the selected player.
When LRCLIB returns multiple matches, hover over the lyrics widget briefly to
open the `selector` panel and apply another result.
Bind the `open_selector` shortcut to a key combination in Noctalia's shortcuts
settings, or open the selector directly through IPC:
```sh
noctalia msg panel-toggle h465855hgg/lyrics:selector
```
## Screenshots
@@ -83,7 +91,8 @@ romanization, and character-timing fields:
`auto` tries the IDs listed in `lyrics_sources` from top to bottom. Supported
IDs are:
- `lrclib`: public LRCLIB search.
- `lrclib`: public LRCLIB search with automatic synchronized-result ranking and
manual result selection.
- `netease`: public NetEase search, synchronized lyrics, translations, and
romanization when returned.
- `splayer`: SPlayer's complete current lyric data, including line and word
@@ -167,6 +176,8 @@ polling, marquee metrics, player filters, and fine padding.
none.
14. Layout: test `padding_left`, `padding_right`, and `line_gap` on horizontal and
vertical bars.
15. LRCLIB selection: play a track with multiple results, briefly hover over the widget,
and switch between synchronized and plain entries without changing tracks.
## External protocol
+91 -3
View File
@@ -461,6 +461,85 @@ def best_match(items, track, title_key, artist_key, album_key=None):
return best if best_score >= 3 else None
def lrclib_candidates(items, track):
wanted_title, wanted_artist, wanted_album = map(normalize, (
track.get("title"), track.get("artist"), track.get("album")
))
wanted_duration = duration_ms(track.get("duration")) / 1000
ranked = []
seen_ids = set()
for index, item in enumerate(items if isinstance(items, list) else []):
if not isinstance(item, dict):
continue
candidate_id = clean_text(item.get("id"))
if not candidate_id or candidate_id in seen_ids:
continue
synced = bool(clean_text(item.get("syncedLyrics")))
plain = bool(clean_text(item.get("plainLyrics")))
if not synced and not plain:
continue
title = normalize(item.get("trackName"))
artist = normalize(item.get("artistName"))
album = normalize(item.get("albumName"))
if wanted_title and not title:
continue
identity_score = 0
if wanted_title and title:
title_score = 6 if title == wanted_title else 3 if wanted_title in title or title in wanted_title else 0
if title_score == 0:
continue
identity_score += title_score
if wanted_artist and artist:
artist_score = 4 if artist == wanted_artist else 2 if wanted_artist in artist or artist in wanted_artist else 0
if artist_score == 0:
continue
identity_score += artist_score
if wanted_album and album:
identity_score += 2 if album == wanted_album else 1 if wanted_album in album or album in wanted_album else 0
candidate_duration = number(item.get("duration"), 0)
duration_bucket = 5
duration_difference = float("inf")
if wanted_duration > 0 and candidate_duration > 0:
duration_difference = abs(wanted_duration - candidate_duration)
if duration_difference <= 2:
duration_bucket = 0
elif duration_difference <= 5:
duration_bucket = 1
elif duration_difference <= 10:
duration_bucket = 2
elif duration_difference <= 20:
duration_bucket = 3
else:
duration_bucket = 4
if identity_score >= 3:
seen_ids.add(candidate_id)
ranked.append((
-identity_score,
duration_bucket,
-int(synced),
duration_difference,
index,
item,
))
ranked.sort(key=lambda entry: entry[:-1])
return [entry[-1] for entry in ranked]
def lrclib_candidate_metadata(item):
return {
"id": clean_text(item.get("id")),
"track_name": clean_text(item.get("trackName")),
"artist_name": clean_text(item.get("artistName")),
"album_name": clean_text(item.get("albumName")),
"duration": max(0, number(item.get("duration"), 0)),
"synced": bool(clean_text(item.get("syncedLyrics"))),
}
def first_cover(*values):
for value in values:
if isinstance(value, dict):
@@ -529,15 +608,24 @@ def adapter_lrclib(track, credentials, options):
if track.get("album"):
params["album_name"] = track["album"]
data = request_json(query_url("https://lrclib.net/api/search", params))
best = best_match(data, track, lambda x: x.get("trackName", ""), lambda x: x.get("artistName", ""),
lambda x: x.get("albumName", ""))
matches = lrclib_candidates(data, track)
requested_id = clean_text(options.get("lyrics_candidate_id"))
best = next((item for item in matches if clean_text(item.get("id")) == requested_id), None)
if requested_id and best is None:
return empty(source, "lrclib: requested match unavailable")
if best is None and matches:
best = matches[0]
if not best:
return empty(source, "lrclib: no match")
lyrics = best.get("syncedLyrics") or best.get("plainLyrics") or ""
return success(
response = success(
source, parse_lrc(lyrics) or parse_plain(lyrics), ["lrclib: match"],
duration_ms(track.get("duration")), itunes_cover(track),
)
if response.get("type") == "lyrics":
response["candidates"] = [lrclib_candidate_metadata(item) for item in matches]
response["selected_candidate_id"] = clean_text(best.get("id"))
return response
def adapter_netease(track, credentials, options):
+34 -1
View File
@@ -60,6 +60,18 @@ local showMode = displayMode == "track" and "track" or "auto"
local rendered = false
local playerInstance = ""
local sourceUsed = ""
local lyricsCandidates = {}
local hovering = false
local hoverElapsed = 0
local selectorOpenRequested = false
local function trackKey(value)
if type(value) ~= "table" then return "" end
local durationSeconds = math.floor((tonumber(value.duration) or 0) / 1000000)
return (value.playerInstance or "") .. "|" .. (value.trackId or "") .. "|"
.. (value.title or "") .. "|" .. (value.artist or "") .. "|" .. (value.album or "")
.. "|" .. tostring(durationSeconds)
end
local baselinePosition = 0
local baselineClock = os.clock()
@@ -454,7 +466,6 @@ local function render()
color = playing and activeColor or inactiveColor,
})
end
local lineNodes = {}
local renderLineGap = lineGap
local renderTextHeight = 0
@@ -568,6 +579,14 @@ function onClick()
if rendered then render() end
end
function onHover(value)
hovering = value == true
if not hovering then
hoverElapsed = 0
selectorOpenRequested = false
end
end
function onRightClick()
if playerInstance == "" then return end
noctalia.runAsync("playerctl --player " .. shellQuote(playerInstance) .. " play-pause", function(result)
@@ -601,6 +620,20 @@ function update()
playing = noctalia.state.get("playing") == true
playerInstance = noctalia.state.get("player_instance") or ""
sourceUsed = noctalia.state.get("lyrics_source_used") or ""
local candidateState = noctalia.state.get("lyrics_candidate_state")
lyricsCandidates = type(candidateState) == "table" and candidateState.track_key == trackKey(track)
and candidateState.candidates or {}
local selectorOpen = noctalia.state.get("lyrics_selector_open") == true
if hovering and #lyricsCandidates > 1 and not selectorOpen and not selectorOpenRequested then
hoverElapsed = hoverElapsed + delta
if hoverElapsed >= 0.4 then
selectorOpenRequested = true
hoverElapsed = 0
noctalia.togglePanel("h465855hgg/lyrics:selector")
end
else
hoverElapsed = 0
end
local info = getLineInfo()
local nextKey = info and info.key or "fallback"
+146
View File
@@ -0,0 +1,146 @@
--!nonstrict
local candidateState = noctalia.state.get("lyrics_candidate_state") or {}
local track = noctalia.state.get("track")
local requestCounter = 0
local dirty = true
local function trackKey(value)
if type(value) ~= "table" then return "" end
local durationSeconds = math.floor((tonumber(value.duration) or 0) / 1000000)
return (value.playerInstance or "") .. "|" .. (value.trackId or "") .. "|"
.. (value.title or "") .. "|" .. (value.artist or "") .. "|" .. (value.album or "")
.. "|" .. tostring(durationSeconds)
end
local function stateForTrack()
if type(candidateState) ~= "table" or candidateState.track_key ~= trackKey(track) then return {} end
return candidateState
end
local function durationLabel(value)
local seconds = math.max(0, math.floor(tonumber(value) or 0))
return string.format("%d:%02d", math.floor(seconds / 60), seconds % 60)
end
local function candidateLabel(candidate)
local parts = {}
local title = tostring(candidate.track_name or "")
local artist = tostring(candidate.artist_name or "")
if title ~= "" then parts[#parts + 1] = title end
if artist ~= "" then parts[#parts + 1] = artist end
if tonumber(candidate.duration) and tonumber(candidate.duration) > 0 then
parts[#parts + 1] = durationLabel(candidate.duration)
end
parts[#parts + 1] = noctalia.tr(candidate.synced == true and "panel.synced" or "panel.unsynced")
return table.concat(parts, " · ")
end
local function selectedIndex()
local state = stateForTrack()
local candidates = type(state.candidates) == "table" and state.candidates or {}
local selectedCandidateId = tostring(state.selected_id or "")
for index, candidate in ipairs(candidates) do
if tostring(candidate.id or "") == selectedCandidateId then return index - 1 end
end
return 0
end
local function render()
dirty = false
local state = stateForTrack()
local candidates = type(state.candidates) == "table" and state.candidates or {}
local selectedCandidateId = tostring(state.selected_id or "")
local loading = state.loading == true
local selectionError = tostring(state.error or "")
local body = {}
if not track then
body[#body + 1] = ui.column({ align = "center", justify = "center", gap = 8, flexGrow = 1 }, {
ui.glyph({ name = "music-off", size = 28, color = "on_surface_variant" }),
ui.label({ text = noctalia.tr("panel.no_track"), color = "on_surface_variant" }),
})
elseif #candidates == 0 then
body[#body + 1] = ui.column({ align = "center", justify = "center", gap = 8, flexGrow = 1 }, {
ui.glyph({ name = "list-search", size = 28, color = "on_surface_variant" }),
ui.label({ text = noctalia.tr("panel.no_candidates"), color = "on_surface_variant" }),
})
else
local options = {}
for _, candidate in ipairs(candidates) do options[#options + 1] = candidateLabel(candidate) end
body[#body + 1] = ui.label({ text = noctalia.tr("panel.result"), color = "on_surface_variant", fontSize = 11 })
body[#body + 1] = ui.select({
key = "lyrics-candidate-" .. selectedCandidateId,
options = options,
selectedIndex = selectedIndex(),
enabled = not loading,
onChange = "onCandidateChange",
})
end
if loading then
body[#body + 1] = ui.label({ text = noctalia.tr("panel.loading"), color = "primary", fontSize = 11 })
elseif selectionError ~= "" then
body[#body + 1] = ui.label({ text = noctalia.tr("panel.selection_failed"), color = "error", fontSize = 11 })
end
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, {
ui.row({ align = "center", gap = 8 }, {
ui.glyph({ name = "list-search", size = 22, color = "primary" }),
ui.column({ flexGrow = 1, gap = 2 }, {
ui.label({ text = noctalia.tr("panel.title"), fontSize = 16, fontWeight = "bold" }),
ui.label({
text = track and ((track.artist and track.artist ~= "" and track.artist .. " - " or "") .. tostring(track.title or "")) or "",
color = "on_surface_variant",
fontSize = 11,
maxLines = 1,
}),
}),
ui.button({ glyph = "close", variant = "ghost", tooltip = noctalia.tr("panel.close"), onClick = "onCloseClicked" }),
}),
ui.column({ flexGrow = 1, gap = 8, align = "stretch" }, body),
}))
end
local function watch(key, transform)
noctalia.state.watch(key, function(value)
transform(value)
dirty = true
end)
end
watch("lyrics_candidate_state", function(value) candidateState = type(value) == "table" and value or {} end)
watch("track", function(value) track = value end)
function onOpen(_context)
noctalia.state.set("lyrics_selector_open", true)
dirty = true
render()
end
function onClose()
noctalia.state.set("lyrics_selector_open", false)
end
function update()
if dirty then render() end
end
function onCloseClicked()
panel.close()
end
function onCandidateChange(index, _label)
local state = stateForTrack()
local candidates = type(state.candidates) == "table" and state.candidates or {}
local selectedCandidateId = tostring(state.selected_id or "")
local candidate = candidates[(math.floor(tonumber(index) or 0)) + 1]
if not candidate or tostring(candidate.id or "") == selectedCandidateId or not track then return end
requestCounter = requestCounter + 1
noctalia.state.set("lyrics_candidate_request", {
request_id = tostring(os.clock()) .. "-" .. tostring(requestCounter),
candidate_id = tostring(candidate.id or ""),
track_key = trackKey(track),
})
end
render()
+104 -22
View File
@@ -9,6 +9,8 @@ noctalia.setUpdateInterval(updateIntervalMs)
local cache = {}
local coverCache = {}
local sourceCoverCache = {}
local candidateCache = {}
local selectedCandidateCache = {}
local lastTrackKey = ""
local inFlight = nil
local fetchGeneration = 0
@@ -62,8 +64,21 @@ local playerAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist")
local playerBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
local function trackKey(track)
local durationSeconds = math.floor((tonumber(track.duration) or 0) / 1000000)
return (track.playerInstance or "") .. "|" .. (track.trackId or "") .. "|"
.. track.title .. "|" .. track.artist .. "|" .. track.album
.. (track.title or "") .. "|" .. (track.artist or "") .. "|" .. (track.album or "")
.. "|" .. tostring(durationSeconds)
end
local function publishCandidates(tk, loading, selectionError, pendingCandidateId)
noctalia.state.set("lyrics_candidate_state", {
track_key = tk or "",
candidates = candidateCache[tk] or {},
selected_id = selectedCandidateCache[tk],
loading = loading == true,
error = selectionError,
pending_id = pendingCandidateId,
})
end
local function patternMatches(value, pattern)
@@ -115,6 +130,7 @@ local function clearPlayerState()
noctalia.state.set("player_name", nil)
noctalia.state.set("track", nil)
noctalia.state.set("lyrics", nil)
publishCandidates("")
noctalia.state.set("cover", nil)
noctalia.state.set("playing", false)
end
@@ -306,15 +322,20 @@ local function evictCache()
for i = 1, #keys - 30 do
cache[keys[i]] = nil
sourceCoverCache[keys[i]] = nil
candidateCache[keys[i]] = nil
selectedCandidateCache[keys[i]] = nil
end
end
end
local function fetchLyricsNetEase(track, embeddedLyrics)
local function fetchLyricsNetEase(track, embeddedLyrics, requestedCandidateId)
local tk = trackKey(track)
requestedCandidateId = tostring(requestedCandidateId or "")
local manualSelection = requestedCandidateId ~= ""
fetchGeneration = fetchGeneration + 1
local flight = tk .. "|" .. tostring(fetchGeneration)
inFlight = flight
publishCandidates(tk, manualSelection, nil, manualSelection and requestedCandidateId or nil)
local function tryFetch(query, fallback)
local searchUrl = "https://music.163.com/api/search/get?type=1&s=" .. noctalia.string.urlEncode(query) .. "&limit=5"
@@ -529,24 +550,27 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
end)
end
if lyricsSource == "external" then
inFlight = nil
return
elseif lyricsSource == "mpris" then
if applyText(embeddedLyrics) then noctalia.state.set("lyrics_source_used", "mpris") end
inFlight = nil
return
elseif lyricsSource == "custom" then
fetchCustom(nil)
return
if not manualSelection then
if lyricsSource == "external" then
inFlight = nil
return
elseif lyricsSource == "mpris" then
if applyText(embeddedLyrics) then noctalia.state.set("lyrics_source_used", "mpris") end
inFlight = nil
return
elseif lyricsSource == "custom" then
fetchCustom(nil)
return
end
end
local requestPath = requestDir .. "/source_request_" .. tostring(fetchGeneration) .. ".json"
local sources = normalizedSources()
local sources = manualSelection and { "lrclib" } or normalizedSources()
local request = {
track = track,
options = {
translation_language = noctalia.getConfig("translation_language") or "zh-Hans",
lyrics_candidate_id = manualSelection and requestedCandidateId or nil,
},
}
@@ -555,8 +579,15 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
local source = sources[index]
if not source then
inFlight = nil
noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil)
if manualSelection then
publishCandidates(tk, false, "selection_failed")
else
noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil)
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
end
return
end
if source == "mpris" then
@@ -596,10 +627,18 @@ local function fetchLyricsNetEase(track, embeddedLyrics)
local parsed = noctalia.json.decode(result.stdout or "")
if type(parsed) == "table" and parsed.type == "lyrics" and type(parsed.lines) == "table" and #parsed.lines > 0 then
cache[tk] = parsed.lines
if (parsed.source or source) == "lrclib" then
candidateCache[tk] = type(parsed.candidates) == "table" and parsed.candidates or {}
selectedCandidateCache[tk] = tostring(parsed.selected_candidate_id or "")
else
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
end
evictCache()
inFlight = nil
noctalia.state.set("lyrics", parsed.lines)
noctalia.state.set("lyrics_source_used", parsed.source or source)
publishCandidates(tk)
if type(parsed.cover) == "string" and parsed.cover ~= "" then
sourceCoverCache[tk] = parsed.cover
maybeApplyCover(track, parsed.cover)
@@ -793,8 +832,15 @@ local function poll()
noctalia.state.set("player_name", selected.name)
if selected.title == "" and selected.artist == "" then
fetchGeneration = fetchGeneration + 1
inFlight = nil
currentTrack = nil
currentEmbeddedLyrics = ""
lastTrackKey = ""
noctalia.state.set("track", nil)
noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil)
publishCandidates("")
noctalia.state.set("playing", selected.status == "playing")
return
end
@@ -833,11 +879,16 @@ local function poll()
if displayMode == "track" then
noctalia.state.set("lyrics", nil)
noctalia.state.set("lyrics_source_used", nil)
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
elseif cached then
noctalia.state.set("lyrics", cached)
noctalia.state.set("lyrics_source_used", "cache")
publishCandidates(tk)
else
noctalia.state.set("lyrics", nil)
publishCandidates(tk)
fetchLyricsNetEase(t, selected.embeddedLyrics)
end
@@ -921,25 +972,48 @@ function onConfigChanged()
inFlight = nil
cache = {}
sourceCoverCache = {}
candidateCache = {}
selectedCandidateCache = {}
noctalia.state.set("lyrics", nil)
publishCandidates(trackKey(currentTrack))
if displayMode ~= "track" then fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics) end
end
end
local lastCandidateRequest = ""
noctalia.state.watch("lyrics_candidate_request", function(request)
if type(request) ~= "table" or not currentTrack then return end
local requestId = tostring(request.request_id or "")
if requestId == "" or requestId == lastCandidateRequest then return end
lastCandidateRequest = requestId
local candidateId = tostring(request.candidate_id or "")
if candidateId == "" then return end
local tk = trackKey(currentTrack)
if tostring(request.track_key or "") ~= tk then return end
local known = false
for _, candidate in ipairs(candidateCache[tk] or {}) do
if tostring(candidate.id or "") == candidateId then
known = true
break
end
end
if not known or candidateId == tostring(selectedCandidateCache[tk] or "") then return end
inFlight = nil
fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics, candidateId)
end)
local function applyPushedLyrics(payload)
local decoded = noctalia.json.decode(payload or "")
if type(decoded) == "table" then
if decoded.track then
currentTrack = decoded.track
lastTrackKey = trackKey({
playerInstance = decoded.track.playerInstance or "",
trackId = decoded.track.trackId or "",
title = decoded.track.title or "",
artist = decoded.track.artist or "",
album = decoded.track.album or "",
})
lastTrackKey = trackKey(decoded.track)
noctalia.state.set("track", decoded.track)
end
local tk = currentTrack and trackKey(currentTrack) or ""
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
if decoded.lines then noctalia.state.set("lyrics", decoded.lines) end
if decoded.lyrics then
local parsed = parseLRC(decoded.lyrics) or parsePlain(decoded.lyrics)
@@ -962,10 +1036,18 @@ end
function onIpc(event, payload)
if event == "push-lrc" then
local tk = currentTrack and trackKey(currentTrack) or ""
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
noctalia.state.set("lyrics", parseLRC(payload or "") or parsePlain(payload or ""))
elseif event == "push-json" or event == "push-state" then
applyPushedLyrics(payload)
elseif event == "clear" then
local tk = currentTrack and trackKey(currentTrack) or ""
candidateCache[tk] = nil
selectedCandidateCache[tk] = nil
publishCandidates(tk)
noctalia.state.set("lyrics", nil)
end
end
+5
View File
@@ -0,0 +1,5 @@
--!nonstrict
function onActivate()
noctalia.togglePanel("h465855hgg/lyrics:selector")
end
+16 -2
View File
@@ -1,11 +1,11 @@
id = "h465855hgg/lyrics"
name = "Lyrics"
version = "1.4.3"
version = "1.4.4"
plugin_api = 3
author = "h465855hgg"
license = "MIT"
dependencies = ["playerctl", "python3", "cp", "chmod"]
tags = ["bar", "service", "music", "media", "animation"]
tags = ["bar", "panel", "service", "music", "media", "animation"]
icon = "music"
description = "Synchronized lyrics with karaoke highlighting, animated transitions, and flexible lyric sources."
@@ -451,11 +451,25 @@ min = 0
max = 64
advanced = true
# Keyboard shortcut for opening the lyrics selector panel.
[[shortcut]]
id = "open_selector"
entry = "lyrics_shortcut.luau"
# Headless background service: polls MPRIS metadata, fetches lyrics, publishes state.
[[service]]
id = "service"
entry = "lyrics_service.luau"
# Attached selector for choosing among LRCLIB matches for the current track.
[[panel]]
id = "selector"
entry = "lyrics_selector.luau"
width = 520
height = 210
placement = "attached"
open_near_click = true
# Bar widget: thin presentation that mirrors the published lyrics state.
[[widget]]
id = "lyrics"
+112
View File
@@ -7,6 +7,118 @@ import urllib.error
import lyric_sources
class LrclibAdapterTest(unittest.TestCase):
def setUp(self):
self.track = {
"title": "Can't Stop",
"artist": "Red Hot Chili Peppers",
"album": "By the Way",
"duration": 269_000_000,
}
self.results = [
{
"id": 10,
"trackName": "Can't Stop",
"artistName": "Red Hot Chili Peppers",
"albumName": "By the Way",
"duration": 269,
"plainLyrics": "Plain line",
"syncedLyrics": "",
},
{
"id": 20,
"trackName": "Can't Stop",
"artistName": "Red Hot Chili Peppers",
"albumName": "By the Way",
"duration": 269,
"plainLyrics": "Synced line",
"syncedLyrics": "[00:01.00]Synced line",
},
]
@mock.patch("lyric_sources.itunes_cover", return_value="")
@mock.patch("lyric_sources.request_json")
def test_prefers_synced_candidate_and_returns_metadata(self, request_json, _itunes_cover):
request_json.return_value = self.results
result = lyric_sources.adapter_lrclib(self.track, {}, {})
self.assertEqual(result["selected_candidate_id"], "20")
self.assertEqual(result["lines"][0]["time"], 1000)
self.assertEqual([item["id"] for item in result["candidates"]], ["20", "10"])
self.assertNotIn("plainLyrics", result["candidates"][0])
self.assertTrue(result["candidates"][0]["synced"])
@mock.patch("lyric_sources.itunes_cover", return_value="")
@mock.patch("lyric_sources.request_json")
def test_honors_requested_candidate(self, request_json, _itunes_cover):
request_json.return_value = self.results
result = lyric_sources.adapter_lrclib(
self.track, {}, {"lyrics_candidate_id": "10"}
)
self.assertEqual(result["selected_candidate_id"], "10")
self.assertEqual(result["lines"][0]["time"], -1)
self.assertEqual(result["lines"][0]["text"], "Plain line")
@mock.patch("lyric_sources.request_json")
def test_rejects_stale_requested_candidate(self, request_json):
request_json.return_value = self.results
result = lyric_sources.adapter_lrclib(
self.track, {}, {"lyrics_candidate_id": "missing"}
)
self.assertEqual(result["type"], "none")
self.assertEqual(result["diag"], ["lrclib: requested match unavailable"])
def test_rejects_wrong_artist_even_when_synced(self):
results = [
{
"id": 30,
"trackName": "Can't Stop",
"artistName": "Unrelated Artist",
"duration": 269,
"syncedLyrics": "[00:01.00]Wrong",
},
self.results[0],
]
ranked = lyric_sources.lrclib_candidates(results, self.track)
self.assertEqual([item["id"] for item in ranked], [10])
def test_prefers_duration_bucket_before_sync_status(self):
results = [
{
"id": 30,
"trackName": "Can't Stop",
"artistName": "Red Hot Chili Peppers",
"albumName": "By the Way",
"duration": 400,
"syncedLyrics": "[00:01.00]Wrong version",
},
self.results[0],
]
ranked = lyric_sources.lrclib_candidates(results, self.track)
self.assertEqual([item["id"] for item in ranked], [10, 30])
def test_filters_missing_and_duplicate_candidate_ids(self):
duplicate = dict(self.results[1])
duplicate["plainLyrics"] = "Duplicate"
missing = dict(self.results[0])
missing.pop("id")
ranked = lyric_sources.lrclib_candidates(
[missing, self.results[1], duplicate], self.track
)
self.assertEqual([item["id"] for item in ranked], [20])
class SPlayerLinesTest(unittest.TestCase):
def test_preserves_timing_layers_and_markers(self):
lines = lyric_sources.splayer_transmitted_lines({
+15
View File
@@ -2,6 +2,21 @@
"instrumental": "Instrumental",
"intro": "Intro",
"no_lyrics": "♪ No lyrics available",
"panel": {
"close": "Close",
"loading": "Loading lyrics…",
"no_candidates": "No alternate LRCLIB results",
"no_track": "No track is playing",
"result": "Lyrics result",
"selection_failed": "Could not load the selected lyrics",
"synced": "Synced",
"title": "Choose lyrics",
"unsynced": "Unsynced"
},
"select_lyrics": "Choose lyrics",
"shortcut": {
"label": "Open lyrics selector"
},
"settings": {
"active_color": {
"description": "Color used for the current and already-sung lyric characters. Defaults to the interface text color.",
+15
View File
@@ -2,6 +2,21 @@
"instrumental": "间奏",
"intro": "前奏",
"no_lyrics": "暂无可用歌词",
"panel": {
"close": "关闭",
"loading": "正在加载歌词……",
"no_candidates": "没有其他 LRCLIB 结果",
"no_track": "当前没有正在播放的歌曲",
"result": "歌词结果",
"selection_failed": "无法加载所选歌词",
"synced": "同步歌词",
"title": "选择歌词",
"unsynced": "非同步歌词"
},
"select_lyrics": "选择歌词",
"shortcut": {
"label": "打开歌词选择器"
},
"settings": {
"active_color": {
"description": "设置当前歌词和已经唱过字符的颜色,默认使用界面文字颜色。",