Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ The startup order matters and is easy to get backwards: `app.db` lives at the *d
- `profile` — profile list (one row per profile).
- `app_setting` — typed key/value: `app.last_profile_id`, `lastfm_api_key`, `lastfm_session_key`, `app.theme`, `integrations.discord_rpc`, …
- `deezer_artist` / `deezer_album` — shared metadata cache (Deezer enrichment + Last.fm bios), 30-day TTL via `expires_at`.
- `lyrics` — shared LRCLIB cache (no TTL).
- `lyrics` — shared lyrics cache, kept for good except a partial miss, which carries a `retry_after` date ([integrations](../features/integrations.md#which-providers-are-asked-for-which-tracks)).

Migrations: [`src-tauri/migrations/app/`](../../src-tauri/migrations/app).

Expand Down
21 changes: 16 additions & 5 deletions docs/features/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Triggered from [`emit_track_changed`](../../src-tauri/crates/app/src/commands/pl

[`lrclib.rs`](../../src-tauri/crates/core/src/metadata/lrclib.rs) still handles the exact [LRCLIB](https://lrclib.net) lookup by `artist_name + track_name + album_name + duration`. Query-based providers live in the Tauri-free [`waveflow-syncedlyrics`](../../src-tauri/crates/syncedlyrics/src/lib.rs) crate and are called from [`commands/lyrics.rs`](../../src-tauri/crates/app/src/commands/lyrics.rs). `fetch_lyrics` and the library-wide `prefetch_library_lyrics` walk the same waterfall:

1. **Cache** — `app.lyrics` row keyed by `track.file_hash` (BLAKE3). No TTL, shared across profiles.
1. **Cache** — `app.lyrics` row keyed by `track.file_hash` (BLAKE3). Shared across profiles, kept for good — except a partial miss, which carries a `retry_after` date ([below](#which-providers-are-asked-for-which-tracks)).
2. **Embedded** — `LYRICS` / `USLT` / `©lyr` tag in the file (lofty), incl. synced `LRC` blocks. Lookup tries `ItemKey::UnsyncLyrics` first (the only key that maps to ID3v2's `USLT` in lofty 0.24), then `ItemKey::Lyrics` for Vorbis / MP4. For MP3s tagged with Mp3tag / foobar2000 / lame `--tg`, lyrics often live in a TXXX user-defined frame named `LYRICS` or `UNSYNCEDLYRICS` (common on K-Pop / J-Pop rips); these are invisible to the generic `Tag` interface so [`commands/lyrics.rs::read_id3v2_txxx_lyrics`](../../src-tauri/crates/app/src/commands/lyrics.rs) re-opens the file as `MpegFile`, downcasts to `Id3v2Tag`, and scans the TXXX descriptions explicitly.
3. **Sidecar file** — `{stem}.lrc` / `{stem}.txt` next to the audio file (e.g. `01 Song.mp3` + `01 Song.lrc`), or inside a sibling `Lyrics/` folder (case-insensitive, so `lyrics/` is also matched — common Linux convention). Stem matching is also case-insensitive so `Song.MP3` finds `song.lrc` on case-sensitive filesystems. `.lrc` wins over `.txt` at every probed directory because it carries timing info; same-folder hits beat `Lyrics/` hits. Format is auto-detected via `detect_format` and the row is cached with `source = lrc_file`. Whitespace-only files are treated as misses so the waterfall keeps falling through. Writing one is a choice: the destination below decides whether an edit — or a fetch — lands in the tag, in a sidecar or in the database alone.

Expand Down Expand Up @@ -166,10 +166,20 @@ Triggered from [`emit_track_changed`](../../src-tauri/crates/app/src/commands/pl

5. **Musixmatch Enhanced** — asks for word-level karaoke first. It only wins early when the result is actually Enhanced LRC; regular line-level LRC from Musixmatch falls through so LRCLIB's stricter metadata match can still win.
6. **LRCLIB** — synced lyrics first, falls back to plain text. Result cached as a new row.
7. **Query-based fallback providers** — LRCLIB (again), then NetEase, Megalobiz, then Genius. This broader scan only runs after tier 6 returns 404 or an empty payload, and prefers synced content over plain text. Musixmatch is deliberately absent: tier 5 owns it, and listing it here would re-issue an identical request.
7. **Query-based fallback providers** — LRCLIB (again), then NetEase, Megalobiz, then Genius, minus the ones switched off (below). This broader scan only runs after tier 6 returns 404 or an empty payload, and prefers synced content over plain text. Musixmatch is deliberately absent: tier 5 owns it, and listing it here would re-issue an identical request.

LRCLIB leads the chain even though tier 6 just asked it, because **the two ask differently**: tier 6 uses `/api/get`, which matches on artist + track + album + duration and 404s when any of them disagrees with the file's tags — a remaster, a `Deluxe` album name, a rip a few seconds off. This tier goes through the provider's `/api/search`, which is fuzzy. Without it, that 404 fell straight through to providers that answer for almost any query, so a track LRCLIB _does_ carry came back from Genius while picking LRCLIB by hand in the panel found it instantly (issue #463).

### Which providers are asked, for which tracks

All three rules live in [`lyrics_providers.rs`](../../src-tauri/crates/app/src/commands/lyrics_providers.rs), and none of them touches the local tiers — the tag, the sidecar, the description cost nothing and belong to the file.

**Provider switches (#722).** `profile_setting['lyrics.disabled_providers']`, a JSON array of provider ids, Settings → Images and lyrics → Lyrics. A switched-off provider is never asked by an automatic lookup — panel open, prefetch, radio, remote tracks — though it can still be picked by name in the panel's provider picker, which is a request about that one provider. NetEase, Megalobiz and Genius can be switched; LRCLIB cannot (it is also tier 6, and a chain without it is #463 by choice), and Musixmatch keeps its own opt-in. **Genius is off by default**: its search endpoint answers a normal install with a 403, since its cookie comes from an environment variable nothing sets, and when it does answer it has no timestamps and keeps its `[Chorus]` markers. Switching a provider off leaves the lyrics it already supplied in the cache; Refetch replaces them.

**Excluded genres (#721).** `profile_setting['lyrics.excluded_genres']`, default `Instrumental` and `Lo-fi`. A track with any matching genre skips every network tier, plugins included, after the local tiers have run. Each entry covers its variants by whole words: a run of consecutive words in the genre, glued together, must spell the entry with its separators removed, or that plus an `s` — so `Lo-fi` catches `Lofi`, `Lo Fi` and `lo-fi hip hop`, `Instrumental` catches `Instrumentals` and `Instrumental Hip-Hop`, and a user's `rap` does not catch `Trap`. **Nothing is cached for a skipped track**: it is not known to be lyric-less, only not worth asking about, so taking the genre off the list brings the lookup back — an empty row would outlive the setting. Refetch ignores the list; the prefetch drops those tracks before counting them.

**Provider cooldown (#720).** A provider that fails at the transport level — connect timeout, refused request, 4xx/5xx — sits out automatic lookups for ten minutes; the first answer from it ends that early. Otherwise a host that is down costs its 5 s connect timeout on every track: seconds per panel open, hours over a library prefetch. The first failure of each provider in a session is logged at `WARN`, naming it, and the rest at `debug`.

### Prefer-LRCLIB toggle (online-first order)

`profile_setting['lyrics.prefer_lrclib']`, default off, Settings → Images and lyrics → Lyrics (issue #378). When on, the on-demand [`fetch_lyrics`](../../src-tauri/crates/app/src/commands/lyrics.rs) flips the waterfall so the online providers (tiers 5–7) run **before** the local ones (embedded, sidecar, description), which become the fallback used only when the network has nothing — so a track LRCLIB doesn't carry still shows its own embedded lyrics. The cache tier stays first either way.
Expand All @@ -178,7 +188,7 @@ The bulk `run_prefetch` gap-filler stays local-first regardless of the toggle: i

### Web Radio now-playing lyrics

[`fetch_radio_lyrics`](../../src-tauri/crates/app/src/commands/lyrics.rs) keys a dedicated `radio_lyrics` table in `app.db` by `blake3(artist + title)` parsed from the ICY stream title. A radio session has no library row and no `file_hash`, so it can't use the normal `lyrics` cache. It queries LRCLIB plus the query fallback chain and caches misses as empty rows, same as the library path.
[`fetch_radio_lyrics`](../../src-tauri/crates/app/src/commands/lyrics.rs) keys a dedicated `radio_lyrics` table in `app.db` by `blake3(artist + title)` parsed from the ICY stream title. A radio session has no library row and no `file_hash`, so it can't use the normal `lyrics` cache. It queries the query fallback chain, LRCLIB first, and caches a complete miss as an empty row, same as the library path. A partial miss is not cached — `radio_lyrics` has no expiry to put on it — and costs little on the next spin, since the providers that failed are cooling down.

The panel re-fetches per song — the sentinel track id stays constant for the whole session, so the effect keys on title + artist instead — and renders **statically**: synced LRC is timestamp-stripped, because the live stream position can't be aligned to a song that may have been joined mid-play. Library-row mutation actions (edit / import / refetch / clear) are hidden for radio.

Expand Down Expand Up @@ -207,11 +217,12 @@ It hangs off `upsert_lyrics`, the funnel every primary write already passes thro
- Hit (embedded, sidecar, LRCLIB, or query provider) → row written.
- Instrumental flag from LRCLIB → empty row written (suppresses retries).
- LRCLIB 404 / empty payload and every query provider misses → empty row written. Without this, lo-fi / ambient libraries would re-hit the network on every panel open since many of their tracks are genuinely missing from public lyric providers. The lyrics panel renders "no lyrics found" against an empty cached row, and the "Refetch" button (`clearLyrics + fetchLyrics`) is the manual escape hatch for the user to retry once they think providers might have added the track.
- Network error → **not cached** and bubbled up to the UI as `Err` so the panel can show "retry" instead of a misleading "no lyrics" state.
- **Partial miss** — some query providers answered "nothing", others failed or sat out their cooldown → empty row written with `retry_after` a week ahead (#720). It is served as a miss until then; after it, the cache read treats it as absent and the prefetch selects it again. Every other write resets `retry_after` to `NULL`, so lyrics found later do not inherit the expiry. Before this, one failing provider turned the whole search into an error: the waterfall is cache-first, a failure must never be cached, and with a provider down for good (Megalobiz unreachable, Genius refusing) no track outside LRCLIB could ever be concluded — every open replayed the chain, silently, because the veto was logged at `debug`.
- Nobody in the chain answered → **not cached**. A network error on LRCLIB's tier 6 is bubbled up to the UI as `Err` so the panel can show "retry" instead of a misleading "no lyrics" state.

**Network defaults.** 15 s overall timeout + 5 s connect-timeout in both `LrclibClient` and `SyncedLyricsClient` so a slow provider still gets a chance to respond while a truly unreachable host fails fast. Genius and NetEase can receive cookies through `SYNCEDLYRICS_GENIUS_COOKIE` and `SYNCEDLYRICS_NETEASE_COOKIE` for deployments that need them; secrets stay in the environment, never in the database.

**Library-wide prefetch.** `prefetch_library_lyrics` walks every available track without a cached row (deduped by `file_hash`), runs the same waterfall, and persists each hit. Network calls are throttled at 500 ms (~2 req/s) to be a polite guest; embedded and sidecar hits skip the throttle. Progress streams over `lyrics:prefetch-progress`. A single global run is enforced via an `AtomicBool`; `cancel_lyrics_prefetch` flips a second `AtomicBool` the worker checks per iteration. Resumable — a partial cancel just leaves uncached rows for the next run.
**Library-wide prefetch.** `prefetch_library_lyrics` walks every available track without a cached row, or with a partial miss past its date (deduped by `file_hash`), minus the excluded genres, runs the same waterfall, and persists each hit. Network calls are throttled at 500 ms (~2 req/s) to be a polite guest; embedded and sidecar hits skip the throttle. Progress streams over `lyrics:prefetch-progress`. A single global run is enforced via an `AtomicBool`; `cancel_lyrics_prefetch` flips a second `AtomicBool` the worker checks per iteration. Resumable — a partial cancel just leaves uncached rows for the next run.

The lyrics panel renders synced lines with auto-scroll and a 200 ms transition; un-synced lyrics fall back to a static block.

Expand Down
Loading
Loading