本文へ移動
cccskills
無料GitHub で公開

lyrics

Work on lyrics — the web fallback (LRCLIB, Musixmatch, NetEase, Genius), the in-memory cache, verification, and embedding lyrics into audio file tags. Use this whenever a task touches `services/lyrics.py`, `services/tags.py`, the `/lyrics.json` route, or `scripts/embed_lyrics.py` / `embed_lyrics_cron.sh` — adding or fixing a provider, changing caching/verification, or the batch tagger. Covers the provider contract, the synced-vs-plain rule, the read-only cache, and why writes only ever store plain text.

インストール方法を見る

含まれるファイル(1)

  • SKILL.md14.2 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

Lyrics: web fallback + tag embedding

Two cooperating pieces:

  • services/lyrics.py — fetch lyrics from the web (used by the /lyrics.json route and the CLI). Read-only with respect to Lyrion.
  • services/tags.py — write lyrics into an audio file's tags via mutagen (used by scripts/embed_lyrics.py). Framework-free, no Flask/Lyrion import.

Lyrion's library.db is read-only, so web lyrics can never be stored back there — they live in a process-local cache (web app) or get written straight into the files' tags (CLI), which Lyrion then re-scans.

Provider contract

Each provider is a function (artist, title, album, duration) -> result | None. A hit is a dict:

{"lyrics": str | None,   # plain text
 "synced": str | None,   # LRC with timestamps, when the provider offers it
 "meta":   {"artist", "title", "album", "duration"} | None,  # for verification
 "versions": [{"lyrics", "synced", "album", "duration"}]}    # optional, winner first

versions is how a provider that found the song several times offers the others: the page cycles through them so a listener can weigh a take against the one it picked. Only LRCLIB fills it — Musixmatch answers with one subtitle, NetEase with one LRC and Genius with one page — and the winner is always its first entry, so lyrics/synced stay the answer for any caller that ignores the list (the batch CLI does). It is capped at MAX_VERSIONS, because every entry carries a full lyrics body into a cache entry that lives for TTL_HIT.

The order is the priority, because it is also the order a cap eats from the back. The synced uploads come first, then the plain ones of that same length: a plain upload is still a second reading of the words, worth reaching when the tags are wrong, but it is what a full budget can afford to lose. MAX_VERSIONS counts the library's own text too, even though no provider can see it — the page holds that one, so a response may legitimately fill the whole budget and the page trims its tail when it has a local text to put first.

Providers live in services/lyrics.py: _provider_lrclib, _provider_musixmatch, _provider_netease, _provider_genius, registered in the PROVIDERS map. Order comes from the LYRICS_PROVIDERS env var (_enabled_providers); synced-capable providers first (LRCLIB, Musixmatch, NetEase) because display always prefers synced (karaoke) over plain — Genius is plain-only, so it comes last. Unknown names in the env list are silently ignored, so an operator can disable a flaky provider by dropping it.

A provider that can return either form must prefer the synced one, inside itself. LRCLIB stores lyrics per upload: the record /get matches on the exact artist/title/album/duration signature may be plain-only while another upload of the same track carries an LRC, so _provider_lrclib keeps a /get hit with no synced record of this length — plain-only, or an LRC whose length _duration_close refuses — as a fallback and still runs /search, scanning the candidates for one with syncedLyrics instead of taking results[0]. The chain above it only settles across providers: _search_providers holds the first plain-only answer and keeps asking the later providers for a synced one, skipping those in PLAIN_ONLY_PROVIDERS (Genius), whose answer could only be plain again; the held answer stands when none syncs, and a later outage does not cost it (tests/test_lyrics_lrclib.py, tests/test_lyrics_chain.py). With verify the first plain answer ends the chain, since the CLI stores plain text only.

_lrclib_search returns that winner and the set it came from, ranked by _by_length: the length-matching synced uploads, then the length-matching plain ones behind them (or those alone when nothing is synced). The /get hit joins the tail when the search that found the LRC never returned it — it matched the exact artist/title/album/duration signature, so it is the one plain reading that is certainly this recording's. The get hit normally comes back from /search too, under the very signature that found it, so it is dropped from the others by id — the one duplicate with an exact criterion. Nothing is de-duplicated on the text: two uploads of a song differ by a mistranscribed line or a missing verse, which is precisely what someone comparing them is looking for, so any similarity threshold would remove the signal along with the noise.

A name the two sides spell differently finds nothing. LRCLIB matches names as it stores them, so _lrclib_attempts builds the /search queries in two passes: each name album-filtered first for precision then without it, then the whole set again with the leading articles dropped — the one word a library and a catalogue routinely disagree on (Les Fatals Picards tagged as Fatals Picards).

But a synced record only counts if it is the same recording. An LRC's timestamps belong to the upload they were made for, so a live or extended version scrolls against the wrong timeline — the karaoke sits idle, then jumps. _duration_close (the same tolerance _matches_request verifies with) filters the /search candidates before the synced preference applies, and a record of another length still serves its plainLyrics: right words with no karaoke beat a karaoke that's wrong. Several uploads routinely fit that tolerance, so _closest_in_length takes the one nearest this track's length rather than the first LRCLIB listed. Both lengths keep their fraction (_float_duration, where /get's signature parameter and the logs still want whole seconds): a dozen uploads sit on the same second, and the one matching to the millisecond is the same rip, hence the same timeline. min is stable, so candidates the length still cannot separate keep the catalogue's own order.

A fuzzy matcher's own answer has to be checked inside the provider. Musixmatch's matcher.track.get always returns its nearest hit, so a track absent from its catalogue comes back as a different song — often in another language, since that is what "nearest" reaches for once the title stops matching. _provider_musixmatch runs the matched track through _matches_request and returns None when it doesn't line up, whatever the caller's verify setting: unlike LRCLIB's signature /get, there is no upstream "no match" to fall back on. It also picks the subtitle by language (_mxm_subtitle) rather than taking subtitle_list[0], since a translation among the subtitles would swap out the words; when only translations are on offer the plain lyrics stand alone.

NetEase Cloud Music's search is free text too, so _provider_netease holds its hits to the same rule: _netease_pick keeps those that pass _matches_request against any of their credited artists (a duet lists both) and takes the nearest in length. Its LRC opens with the song credits as timed lines in Chinese (作词 : …), which _NETEASE_CREDIT_RE drops so they don't scroll as lyrics; a text with no timestamp at all comes back as plain lyrics. Only /cloudsearch/pc still answers anonymously: /search/get/web encrypts its result and /search/pc demands a phone-verified account, both behind an HTTP 200, so a result that is not an object is logged at INFO as a blocked search. Its hits carry ar/al/dt, durations in milliseconds, and it wants a browser UA with a music.163.com referer.

The search's INFO line counts one set narrowing three times — candidates returned, those of this length, those of them synced — so the last number is what the page can actually offer. Counting synced records over the whole result set instead, as it once did, reports versions that the length filter has already discarded.

Adding a provider: write _provider_<name>(artist, title, album, duration) returning the dict above (set the meta fields you can, leave the rest None), add it to PROVIDERS, and — since a provider must never break the chain — catch its own network/parse exceptions and return None on failure. fetch_lyrics already wraps each call in a try/except, but keep provider-internal failures from raising too. Use a browser-like UA where a service blocks default agents (see BROWSER_UA).

fetch_lyrics: the orchestrator

fetch_lyrics(track_id, artist, title, album, duration, force, verify):

  • Tries each enabled provider in order, keeps the first synced result, else the first plain one (see above).
  • Returns {"lyrics", "synced", "source"}, plus versions when the provider offered several. source is the winning provider name (preserved across cache hits so the UI can show the origin), "none" when nothing was found, or "rejected" when a candidate came back but failed verification.
  • Caching: an OrderedDict LRU behind a lock, bounded to CACHE_MAX_ENTRIES, with hits kept TTL_HIT (24h) and misses TTL_MISS (1h) — a track with no lyrics online isn't re-queried on every click, but a transient failure recovers sooner. The cache key is track_id|artist|title|verify, not track_id alone: streamed "flow"/mix sources reuse one playlist track_id while the song changes underneath, which would otherwise serve the first song's lyrics for all of them.

Diagnosing a track without lyrics

The chain logs its own reasoning (see logsetup.py), so docker logs answers "why no lyrics" without a debugger:

  • INFO — the whole story of one track: the library miss from get_track_lyrics (which separates "the library has no lyrics" from "the web fallback found nothing"), each provider's verdict (has no match, unreachable, rejected-with-the-candidate), the closing lyrics: <title> by <artist> -> <source> (synced=…, plain=…) in N ms, a cache hit served instead of a search, and throttling. A provider whose own shortlist explains the verdict says so here too: each LRCLIB /search attempt reports the artist name and album filter it used, how many candidates came back, how many carry an LRC and how many are of this track's length — which separates "nothing on LRCLIB" from "a synced upload exists but for another recording". Two LRCLIB lines carry a browsable URL so the catalogue can be checked by hand: a lookup that found no record at all logs the site's search page for the same artist and title (_lrclib_search_url), and a synced record dropped for its length logs lrclib.net/tracks/<id> with both durations. That search does not filter on duration the way _duration_close does, so it lists records the app refuses — the gap is the diagnosis.
  • DEBUG — the HTTP detail behind those verdicts, the lookups that succeeded, the search inputs (album, duration, verify), and the LRCLIB record finally chosen, which opens at lrclib.net/tracks/<id>.

A new provider raises ProviderUnavailable for unreachability — _search_providers logs the warning with the cause, so don't log it twice — and keeps its own dead ends at DEBUG, except a non-200 that is not part of a normal fallback (LRCLIB's /get 404s routinely before /search, so that one stays DEBUG).

Verification (verify=True)

The batch CLI writes lyrics permanently into tags, so it opts into verification: _matches_request requires the candidate's normalized title and artist to equal the request's, and — when both durations are known — to fall within VERIFY_DURATION_TOLERANCE seconds (the surest way to tell the real recording from a live/remix/cover). _fold_name does the comparing: _normalize (accents, case, parenthetical qualifiers, "feat." credits; letters of every script survive, so a Chinese or Cyrillic name is compared rather than folded to nothing), then the leading article dropped and plural words trimmed, so a library tagged "Fatal Picards" still matches the catalogue's "Les Fatals Picards". It folds both sides, so distinct acts stay apart, and the duration is what tells two recordings of one song apart. The web route does not verify (lenient recall); the CLI does (precision — a wrong tag is worse than none). The flag governs the chain in _search_providers; _provider_musixmatch applies the same rule to itself unconditionally (see above), so the lenient path still never shows another song's lyrics.

Writing tags (services/tags.py)

  • Handles mp3/aiff/wav (USLT), mp4/m4a (\xa9lyr), flac/ogg/opus (LYRICS).
  • Only plain text is ever stored — lrc_to_plain strips LRC line/word timestamps and metadata lines first, for maximum player compatibility. Even when a synced result is fetched, the embedded tag is plain.
  • read_metadata, has_lyrics, write_lyrics, clear_lyrics; failures raise LyricsTagError.

The batch CLI (scripts/embed_lyrics.py)

Runs outside the web app with requirements-cli.txt (no Flask/Lyrion). Walks files/dirs, fetches with verify=True, writes tags; Lyrion picks changes up on its next scan. Auto-loads the repo-root .env (via python-dotenv, before importing services.lyrics since its timeout is read at import). Flags: --force, --clear, --no-verify, --dry-run, --delay, --verbose. embed_lyrics_cron.sh wraps it to only re-tag files whose ctime changed (marker + find -cnewer), advancing the marker only on success.

Checklist

  1. New provider → (artist, title, album, duration) → result dict; register in PROVIDERS; swallow its own errors; put synced-capable ones before plain-only, and list a plain-only one in PLAIN_ONLY_PROVIDERS.
  2. Preserve the cache-key shape and TTL split; don't key on track_id alone.
  3. Tag writes store plain text only (run through lrc_to_plain).
  4. Keep services/tags.py free of Flask/Lyrion imports (the CLI reuses it).
  5. Add/extend tests: test_lyrics_cache.py, test_lyrics_verify.py, test_lyrics_musixmatch.py, test_lyrics_netease.py, test_lyrics_route.py, test_get_track_lyrics.py. See the testing skill.

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

add-route

無料

Add or modify a Flask endpoint in the Lyrion Dashboard. Use this whenever a task adds a new URL/route, a JSON API endpoint, or a page — anything that registers a handler in `routes/`. Covers the repo's route → service → test layering, input validation, caching headers, security headers, i18n wiring, and the exact test pattern, so a new endpoint matches how `routes/` already works instead of reinventing the structure.

日本語の概要は準備中です。原文の説明を表示しています。

werdeil/lyrion-dashboard32026年10月15日 更新

android

無料

Work on the Android app — the thin WebView wrapper in `android/`. Use this whenever a task touches the Kotlin sources, the Gradle build, the WebView shell, the native settings screen, LMS server auto-discovery, the JS↔native bridge, app strings/i18n, or the Android CI/lint. Covers the "shell only, UI lives on the web" principle, the debug/release signing split, the static versioning F-Droid needs, and the min/target SDKs, so changes fit the wrapper.

日本語の概要は準備中です。原文の説明を表示しています。

werdeil/lyrion-dashboard32026年10月15日 更新

covers

無料

Work on album artwork — reading an image's dimensions, and embedding a folder's cover file into the tags of its tracks. Use this whenever a task touches `services/artwork.py`, the cover half of `services/tags.py`, or `scripts/embed_covers.py` / `embed_covers_cron.sh`. Covers why Lyrion shows the embedded artwork and never the folder file, how covers are compared, why images are never re-encoded, and what embedding costs in rewritten files.

日本語の概要は準備中です。原文の説明を表示しています。

werdeil/lyrion-dashboard32026年10月15日 更新

database

無料

Read data from Lyrion's SQLite databases — library stats, stored lyrics, album covers, recent plays. Use this whenever a task queries `library.db` or `persist.db`, adds or changes a statistic, or touches `services/database.py`. Covers the read-only connection model (RO open + `ATTACH persist.db`), the key tables, the `play_counts` view over the Alternative Play Count plugin, the single-flight stats cache, and why the app must never write to these DBs. Reach for it before writing any SQL against the Lyrion databases.

日本語の概要は準備中です。原文の説明を表示しています。

werdeil/lyrion-dashboard32026年10月15日 更新

frontend

無料

Work on the dashboard's frontend — the vanilla JS/CSS page with no framework or bundler. Use this whenever a task touches `static/nowplaying.js`, `static/style.css`, the Jinja templates (`templates/nowplaying.html`, `_icons.html`), the accent-color tinting, the karaoke lyrics sync, the now-playing poll, or the empty-state mosaic / recent-plays pile. Covers how the page is wired, how it reads i18n and data from the server, the ESLint gate, and the Android bridge, so changes match the existing patterns.

日本語の概要は準備中です。原文の説明を表示しています。

werdeil/lyrion-dashboard32026年10月15日 更新

i18n

無料

Add, change, or translate user-facing text in the Lyrion Dashboard, which ships bilingual FR/EN. Use this whenever a task introduces a new visible string (a label, tooltip, aria-label, empty state, stat name), changes wording, or touches the README or a `docs/` page — so every string lands in both languages and the EN/FR documentation pairs stay in sync. Covers `i18n.py`, how templates and JS consume translations, and the parity rule.

日本語の概要は準備中です。原文の説明を表示しています。

werdeil/lyrion-dashboard32026年10月15日 更新

werdeil のスキルをすべて見る

このスキルの問題を報告する