External services WaveFlow talks to. All clients use reqwest 0.12 with rustls-tls so there's no system OpenSSL dependency.
A single global toggle β Settings β Connections β Connectivity β "Offline mode" β short-circuits every outbound call described below. The flag is a static AtomicBool in offline.rs; it is consulted by Deezer enrichment, Last.fm now-playing + the scrobble worker tick, similar-artist lookups, and lyrics fetch + library prefetch providers. Each gated path returns an empty payload (or whatever the local cache holds), nothing throws, so the UI keeps rendering with whatever metadata is already on disk. Persisted in app_setting['network.offline_mode'] because the flag is process-wide β switching profiles must not silently re-enable network calls.
deezer.rs β public Deezer API, no auth. Used for:
- Artist pictures (
enrich_artist_deezer,batch_fetch_missing_artist_pictures) - Album covers (
enrich_album_deezer,search_albums_deezer,set_album_artwork_from_deezer,batch_fetch_missing_album_covers) - Label / fan-count metadata
- Fetching an album's tags for review (#599, below)
Deezer refuses with HTTP 200. A rate-limited or rejected call answers {"error":{"type":"Exception","message":"Quota limit exceeded","code":4}} with a 200 status, so the response type has a DeezerError::Api arm that reads the object instead of letting the missing data field surface as a decode failure. Before that, a throttled client and an artist Deezer had never heard of produced the same log line and the same empty result β which is the fog #406 was diagnosed through; the reason now reaches the log the enrichment paths already write. A 429 from the edge in front of the API carries no JSON to read at all and gets its own RateLimited arm.
Every enrichment path still degrades to an empty result, by design β a missing picture must not fail an album page. DeezerError::is_quota_exceeded covers both arms and exists for a caller that wants to back off and retry rather than give up; none does yet.
Album cover disk-caching is gated on the album lacking local art (issue #493).
enrich_album_innerfires automatically on every album-page open (which only readslabel+release_date) and from the Discord presence (which reads the remotecover_url); neither displays the downloaded file, and the album grid / detail header render the local artwork. So the cover image is written to the sharedmetadata_artworkcache only whenalbum.artwork_id IS NULLβ otherwise the cache filled with never-shown Deezer covers for albums the user already had covers for. The remotecover_urlstill rides through for Discord + the cache row, and the deliberatebatch_fetch_missing_album_covers(which iterates onlyartwork_id IS NULLalbums) is unaffected.
Web Radio now-playing artwork is the one Deezer path that is never disk-cached.
fetch_radio_artworkresolves an album cover URL from the ICYArtist - Titleviasearch_trackand returns the remote CDN link directly β a radio now-playing line is ephemeral, so writing it into the shared cache would only bloat it.PlayerContextswaps the URL intocurrentTrack.artwork_pathover the station favicon, guarded by a request token +isRadioTrackso a stale fetch (or a library track that started meanwhile) never clobbers the displayed cover.
Results are cached in the deezer_artist / deezer_album tables of the shared app.db (one cache across every profile) with a 30-day expires_at TTL. Cache-first: zero network round-trips when the row is fresh. Failures are non-fatal β the UI degrades to local-only artwork and an empty enrichment payload.
Auto-enrichment on play. PlayerProvider fires enrich_artist_deezer(currentTrack.artist_id) (fire-and-forget) on every track-change. Cache hits are ~10 ms so the duplicate call done by NowPlayingPanel when it renders is harmless; the point is to populate the cache for views the user isn't looking at right now (e.g. the artist grid in LibraryView) so a tile gets its picture as soon as the user plays one of that artist's tracks, regardless of whether the Now Playing panel is open.
Batch fill-in. batch_fetch_missing_artist_pictures walks every artist with no cached row (or an expired one), runs the standard enrichment per artist, and emits artist-fetch-progress so a Settings progress bar can drive the UI. Throttled at 200 ms (~5 req/s) to stay well below Deezer's anonymous rate limit. Same idempotent semantics as batch_fetch_missing_album_covers: re-running just resumes on whatever's still missing.
Downloaded images go through metadata_artwork::download_and_cache: Blake3-hashed bytes β <root>/metadata_artwork/<hash>.jpg. The hash is persisted in deezer_artist.picture_hash / deezer_album.cover_hash so a cache hit on the metadata table avoids re-downloading. Thumbnails (1Γ, 2Γ) are generated asynchronously by thumbnails.rs.
The frontend helper lib/tauri/artwork.ts::resolveRemoteImage prefers the local file via convertFileSrc so artist imagery renders offline. The metadata_artwork/** scope must stay listed in tauri.conf.json assetProtocol.
lastfm.rs β split into two flows:
artist.getInfo for biographies, called from enrich_artist_deezer after the Deezer pass. Cached in the same metadata_artist row with the same 30-day TTL. Optional: requires a user-supplied API key in app_setting['lastfm_api_key']. Without it, bios are skipped silently and the UI shows local data.
Collapse toggle (issue #422). A long bio pushes the discography off the artist page, so the bio block carries an inline Show/Hide chevron (ArtistDetailView + useArtistBioCollapsed). The preference is global per-profile (profile_setting['artist.bio_collapsed'], default shown), not per-artist and not in Settings β toggling on any artist page is remembered across every artist. Distinct from the existing "read more/less" which only swaps the short/full bio text; collapse hides the whole block so the tracklist moves up.
Bio source selector (issue #295). The bio provider is switchable in Settings β Images and lyrics β Artist biographies between Last.fm (default β English, needs the key) and TheAudioDB (theaudiodb.rs β community DB, multi-language, no key; free shared API key 123, 30 req/min). The choice lives in app_setting['metadata.bio_source'] and, for TheAudioDB, a language in app_setting['metadata.bio_language'] (the client maps strBiography{LANG} and falls back to English). enrich_artist_deezer branches on the active source and stores bio_source / bio_language alongside the bio in metadata_artist; the cache check treats the bio as stale (re-fetches) when either differs from the active setting, so switching source or language refreshes on the next view. Like Last.fm, the bio still attaches to the Deezer-keyed cache row, so it only resolves for artists that match on Deezer.
commands/similar.rs::get_similar_artists drives the "Similar artists" carousel on ArtistDetailView. Cascade:
- Last.fm
artist.getSimilarwhen an API key is configured β returns up to 12 hits with a real 0-1 affinity score. - Deezer
/artist/{id}/relatedas a fallback when Last.fm has no key, errors out, or returns an empty list. Score is synthesised from the Deezer ranking (1.0 - i / N) so the UI can sort uniformly across providers.
Results are cached in app.lastfm_similar (30-day TTL, keyed by the source artist's canonical name β same canonical_name() routine as the scanner). Only a provider that actually answered β even with a genuine empty list β is cached; a fetch failure (network error, or Deezer's search_artist resolving no candidate for the artist) is not cached, so the next request retries instead of being stuck on an empty result for the full TTL (issue #406). The Deezer name resolution in step 2 uses the shared fuzzy matcher (name_match::select_by_name + normalize_name, same as enrich_artist_deezer) rather than an exact canonical-name comparison, so accented or &-joined names still resolve. Each suggestion is augmented at query time with a library_artist_id when its canonical name matches a row in the active profile, so the UI can badge it as "in your library" and route the click back to the local artist page. Suggestions outside the library are rendered greyed out and non-interactive β no in-app destination exists for them yet.
Picture enrichment. Last.fm's artist.getSimilar returns the same generic star placeholder URL for every result (their artist-image endpoint was retired in 2019). To avoid a sea of grey stars when the cascade picks the Last.fm branch, get_similar_artists runs the raw list through enrich_with_deezer_pictures before responding: it pulls every non-expired row from the cross-profile app.metadata_artist cache in a single SELECT, then filters in Rust against canonical_name(&row.name) so artists with punctuation (e.g. AC/DC, P!nk) match correctly β SQLite's standard build has no REGEXP function so a LOWER(TRIM(name)) predicate would mismatch the scanner's alphanumeric canonicalisation. Cache misses fan out to Deezer search_artist through a futures::stream::buffer_unordered(CONCURRENCY_LIMIT) bounded at 12, and the miss set itself is trimmed to RESULT_LIMIT so we never burn network on entries the caller's .take(RESULT_LIMIT) will drop. New rows are upserted back so the picture survives for 30 days. The DTO's picture_url is rewritten to the Deezer URL whenever one is available; picture_path prefers the in-library hash (set by the existing profile-DB join) before falling back to the freshly cached metadata_artist.picture_hash. When offline mode is on the function reads the cache without the expires_at > now predicate (we have no way to refresh anyway, and the metadata_artwork/<hash>.jpg blob never expires β serving a stale picture beats showing a grey star) and then short-circuits before the network refresh. DB errors on both the cache read and the upsert are logged via tracing::warn! and degrade to "no enrichment" β never block the response.
Both the bio and the similar list can be manually overridden per-artist, mirroring the existing local artist.jpg picture sidecar β for long-tail repertoires where Deezer/Last.fm metadata is sparse, and for offline-mode users who otherwise get nothing. Edited from Artist Detail β "Edit info" (ArtistMetadataEditorModal), persisted per-profile in the profile DB (migration 20260628000000_artist_metadata_overrides):
- Bio β free text in
artist.custom_bio.enrich_artist_deezerreads it up-front and swaps it onto whatever the enrichment path returns (cache hit, offline short-circuit, or fresh fetch). The inner path still fetches + caches the online bio so the shared cross-profilemetadata_artistcache stays correct for profiles without an override β only the returned value is swapped. Clearing the field (blank β storedNULL) drops the override. - Similar β library-scoped, user-curated rows in
artist_similar_custom (artist_id, similar_artist_id, position).get_similar_artistsshort-circuits to this list (source"custom") before any cache/network, so it works fully offline; every entry is in the library by construction. Picked via the topbarsearch_artistsautocomplete. An empty list drops the override and the online cascade takes back over.
Write commands: set_artist_bio_override + set_artist_similar_override + get_artist_overrides (pre-fills the editor). Overrides survive enrichment passes because the pull paths write the shared app.metadata_artist cache, never the per-profile artist row / override table.
scrobbler.rs is the worker thread that drives Last.fm scrobbles:
- Login β signed
auth.getMobileSession(md-5 of params + secret). Session key persisted inapp_setting['lastfm_session_key']. - Now Playing β
track.updateNowPlayingfires on everyplayer:track-changedevent after the 240 s threshold. Best-effort; failures are logged but never block playback. - Scrobble queue β
track.scrobbleis queued in the per-profilescrobble_queuetable and drained with exponential backoff (10 s β 5 min). Survives app restarts. - Re-auth β on
9(Invalid session key) or4(Authentication failed), the session is wiped and alastfm:reauthevent is emitted. The frontend surfaces a banner (LastfmReauthBanner) with a one-click "Re-authenticate" button.
discord_presence.rs β speaks Discord's local IPC named pipe via the discord-rich-presence crate (no network, no auth, no token). Architecture mirrors media_controls.rs: a dedicated thread owns the DiscordIpcClient (which is !Send on Windows because it wraps a Win32 pipe handle), with a crossbeam-channel carrying update messages from the player code.
Connecting. On by default, but it never sits waiting for Discord: the thread connects when it has something to show β a track change, play, pause or seek β and a failed connect leaves it disconnected so the next of those retries, since Discord may have been opened in the meantime. Discord not running is the ordinary case, so only the first failure of a run is logged as a warning and the rest go to debug; a warning on every track read as a fault to anyone going through their logs (#750). A successful connect resets that, so Discord closing later is reported again, once.
Spotify-style card under "Listening to WaveFlow":
| Discord field | Source |
|---|---|
name |
Hard-coded "WaveFlow" (required by Discord β without it the IPC accepts the payload silently and nothing renders). |
activity_type |
Listening (= 2) so the header reads "Listening to WaveFlow" instead of "Playing WaveFlow". |
details (line 1) |
Track title. |
state (line 2) |
Artist (album-only fallback when the artist tag is missing β Discord requires state β₯ 2 chars). |
large_image |
Deezer cover URL when available, otherwise the waveflow_logo asset key. |
large_text |
Album title (rendered inline by Discord as line 3). |
small_image / small_text |
play + "En lecture" while playing, pause + "En pause" while paused. |
timestamps.start / .end |
Computed from track duration + current position so Discord renders the 00:42 βββ 04:30 progress bar. Only set while Playing β leaving them on while paused makes Discord keep ticking the bar from the wall clock, which lies. Re-anchored on every play / seek / pause-resume. |
buttons |
One button β "Voir sur GitHub" β https://github.com/InstaZDLL/WaveFlow. Clickable by anyone viewing the presence card; lets users discover the project directly from Discord. |
Discord propagates large_image URLs to other users' clients, so local files / our 127.0.0.1 artwork shim are off-limits β only public HTTPS works. resolve_cover_url is two-stage:
- Cache hit β
JOIN track β album β deezer_albumin the per-profile pool. Cheap, no network. - Cache miss β call
commands::deezer::enrich_album_innerwhich searches Deezer by title+artist and persists the result. Subsequent plays of the same album hit stage 1.
The first track of an unenriched album takes ~1 s for the Deezer round-trip; following tracks are instant. Empty Deezer results fall back to the waveflow_logo asset key.
- Default ON β
read_enabledreturnstruewhenapp_setting['integrations.discord_rpc']is missing. Only an explicit toggle-off (writes the literal"false") disables RPC. The UI toggle lives inSettingsViewunder "IntΓ©grations". - Boot β
lib.rs::setupreads the persisted flag and spawns the worker. Discord IPC is not connected yet β the first connection attempt happens on the firstMsg::Metadataafter a track plays. Keeps the named pipe free when the user never plays anything. - Idle / Ended β when the decoder transitions to
PlayerState::Idle(Stop button) orPlayerState::Ended(queue exhausted), the worker callsclear_activityso the card disappears from the user's profile. Spotify-style: nothing playing β no presence. - Pause β the activity stays on screen with the
pausebadge and timestamps removed; same UX as Spotify pausing in the middle of a track. - Discord restart β
set_activityfailures drop the client back toNone; the next push re-runs the handshake. No reconnect daemon needed β the next track-changed event triggers it organically.
The Discord application (ID 1502611865698570291) hosts three asset keys uploaded under "Rich Presence Art Assets":
waveflow_logoβ fallback for tracks with no Deezer cover.playβsmall_imagewhile playing.pauseβsmall_imagewhile paused.
PNG sources live in assets/discord/png/, generated from the SVG sources in assets/discord/ via bun scripts/build-discord-assets.mjs (uses sharp for SVG β 1024Γ1024 PNG conversion). Re-running the script after editing an SVG re-emits the PNGs ready to drop on the developer portal β Discord's CDN takes ~10 min to propagate updated assets.
notifications.rs β fires a single track-change toast via tauri-plugin-notification. Different axis from media_controls.rs: SMTC / MPRIS / MediaRemote drive the OS media overlay (lock screen, volume flyout, Now Playing widget), while a notification is a transient toast. Both can coexist and most desktop players ship both.
Triggered from emit_track_changed in a tokio task so the SQLite opt-in lookup doesn't sit on the path that flips the player-bar metadata. The notification carries only the track title + artist (no cover image) β Windows Action Center, KDE / GNOME notification daemons, and macOS Notification Center all support an icon slot but they expect a URL or file path the OS can read, and we'd need a fourth path next to SMTC's 127.0.0.1 shim + Discord's public Deezer URL to feed it cleanly. Title + artist is the format every system handles uniformly.
Off by default β opposite default from Discord RPC because toasts are intrusive and trigger Focus Assist (Windows), Do Not Disturb (macOS), and org.freedesktop.Notifications filters (Linux) on every platform. Opt-in via Settings β General β Startup and notifications β "Track-change notifications". Stored in app_setting['notifications.track_change'] (typed bool, shared across profiles like Discord RPC since toasts are an OS-level user preference, not per-listener). Toggling on doesn't fire a toast for the current track β first toast lands on the next track change.
lrclib.rs still handles the exact LRCLIB lookup by artist_name + track_name + album_name + duration. Query-based providers live in the Tauri-free waveflow-syncedlyrics crate and are called from commands/lyrics.rs. fetch_lyrics and the library-wide prefetch_library_lyrics walk the same waterfall:
-
Cache β
app.lyricsrow keyed bytrack.file_hash(BLAKE3). Shared across profiles, kept for good β except a partial miss, which carries aretry_afterdate (below). -
Embedded β
LYRICS/USLT/Β©lyrtag in the file (lofty), incl. syncedLRCblocks. Lookup triesItemKey::UnsyncLyricsfirst (the only key that maps to ID3v2'sUSLTin lofty 0.24), thenItemKey::Lyricsfor Vorbis / MP4. For MP3s tagged with Mp3tag / foobar2000 / lame--tg, lyrics often live in a TXXX user-defined frame namedLYRICSorUNSYNCEDLYRICS(common on K-Pop / J-Pop rips); these are invisible to the genericTaginterface socommands/lyrics.rs::read_id3v2_txxx_lyricsre-opens the file asMpegFile, downcasts toId3v2Tag, and scans the TXXX descriptions explicitly. -
Sidecar file β
{stem}.lrc/{stem}.txtnext to the audio file (e.g.01 Song.mp3+01 Song.lrc), or inside a siblingLyrics/folder (case-insensitive, solyrics/is also matched β common Linux convention). Stem matching is also case-insensitive soSong.MP3findssong.lrcon case-sensitive filesystems..lrcwins over.txtat every probed directory because it carries timing info; same-folder hits beatLyrics/hits. Format is auto-detected viadetect_formatand the row is cached withsource = 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. -
Generic
descriptionfield β last of the local tiers, and the only one that is a guess about a field meant for something else.It used to sit inside the embedded tier, ahead of the sidecar, and that cost a real user their lyrics (reported on discussion #519): a
.m4apulled withyt-dlpcarries the auto-generated "Provided to YouTube byβ¦" credit indescription, which is comfortably more than the three lines that were the whole test β so it won, and the.lrcthe user had placed next to the file was never read at all.Two things were wrong at once, and both are fixed. A guess no longer outranks an explicit statement: a file the user put there beats a field we are interpreting. And the blurb itself is refused, by a recogniser that stays deliberately narrow and only looks near the top of the text, where the credit always sits β a false positive here costs someone their real lyrics, which is worse than the bug.
The wrong text was also cached, and the waterfall never refetches once a row exists β so fixing the reader alone would have reached only tracks nobody had opened yet. A cached
embeddedrow that the same recogniser identifies as a service credit is therefore dropped on read and re-resolved: the nextfetch_lyricsrepairs it and falls through to the sidecar, with nothing for the listener to do.
Plugins (waveflow:metadata/v2 and /v3) β between tiers 4 and 5, ahead of every network provider: a plugin is there because the user installed it, and it is the only source that returns translations and a pronunciation with the lyrics. A v2 plugin is asked about the artist and the title; a v3 one about the track β the same two, plus the album, the file's length and the ISRC it is tagged with (read from track_tag only when a v3 plugin is enabled) β so a provider can tell a single from an album cut instead of matching on a name. All enabled lyrics plugins are asked at once, and the first answer that validates is cached as a bundle (plugins.md). Only a synced answer ends the waterfall here (#668). An unsynced one β Apple serves lyrics it has no timing for as TTML with itunes:timing="None" and no begin on its lines β is held while tiers 5 to 7 look for synced lyrics; if they find none (plain text, an instrumental verdict, a miss, a network failure), the plugin's answer is cached again and returned, since those tiers write their own result over it. So the plugin still wins a tie, and karaoke beats static text. "Synced" follows the renderer: LRC, Enhanced LRC, or a well-formed TTML document with at least one <p begin> (lyrics_are_synced). Picking the plugin by name in the provider picker (refetch_lyrics with its id) returns its answer as it is: the panel lists every enabled v2 and v3 plugin below the built-in providers (useLyricsPlugins), and opens on any library track β embedded, sidecar or no lyrics at all included β so a plugin can be asked about one track and seen to answer or not.
-
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.
-
LRCLIB β synced lyrics first, falls back to plain text. Result cached as a new row.
-
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, aDeluxealbum 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).
All three rules live in 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. The panels say why there is nothing. fetch_lyrics answers None both for "searched, not found" and for "not searched", and the panel said "No lyrics found" for a track nobody had searched. After an empty answer for a library track, useTrackLyrics asks lyrics_excluded_genre, which returns the genre that matched, as the track spells it (first_excluded_genre). The side panel and the immersive column then name that genre and where the setting lives, and the immersive column's Search again button reads Search anyway, since a refetch ignores the list. A refetch clears the genre, and invalidates a lookup still in flight so it cannot put it back: an empty answer after it really is a miss. For the same reason a track with a cache row answers no genre β a Search anyway stores its miss, and the next play must not call that track unsearched.
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.
profile_setting['lyrics.prefer_lrclib'], default off, Settings β Images and lyrics β Lyrics (issue #378). When on, the on-demand fetch_lyrics 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.
The bulk run_prefetch gap-filler stays local-first regardless of the toggle: it walks the whole library, and paying a network round-trip per track that already ships its lyrics would be both slow and impolite to the providers.
fetch_radio_lyrics 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.
import_lrc_file is still available for the explicit "pick this file" flow (e.g. when the sidecar lives in a non-conventional location like ~/Documents/lyrics/); it overwrites the cached row regardless of which tier filled it.
In-app editor. save_lyrics(track_id, { content, format, write_to_file }) upserts the cache row with source = manual and, when write_to_file is true, also writes the content into the file's USLT (ID3v2) / UNSYNCEDLYRICS (Vorbis) / Β©lyr (MP4) frame via lofty. Same Windows file-lock dance as the tag editor β pause if the engine has the file open, then re-hash with blake3 and update track.file_hash so the cache row stays addressable after the write. Emits a typed lyrics:updated event the panel listens to.
UI is LyricsEditorModal opened via the pencil button in the lyrics panel header. Two tabs:
- Texte β free-form
<textarea>for unsynced lyrics. - SynchronisΓ© (Musicolet-style) β each row is a
(timestamp, text)pair. A "Capturer" button (or Space keyboard shortcut) snaps the active row's timestamp to the player's currentpositionMs. Play / Pause / Β±2 s controls pilot the existingPlayerContextso the user can scrub the file while writing the lines, and the rows are serialised back to LRC viaserializeLrc.
Where saved lyrics land β app_setting['lyrics.default_destination'], chosen during onboarding and in Settings β Images and lyrics: the embedded tag (default), a sidecar next to the audio file, or the database alone. App-wide rather than per-profile because it drives filesystem state two profiles scanning the same folder share.
Fetched lyrics follow it too (#695). Until then the destination governed only what the user typed; anything LRCLIB, Musixmatch or a plugin returned stayed in app.lyrics, so a library deliberately set to "sidecar file" still kept everything fetched locked inside WaveFlow's database. export_fetched_sidecar now writes it next to the music, so it survives a reinstall and any other player reading the same folder can see it. Four refusals shape it:
- Only what came off the network (
source = api, which includes plugins).embeddedandlrc_fileare already on disk;manualgoes throughsave_lyrics, which asks per edit. tagis deliberately not honoured here. Writing a tag rewrites the audio file, and the file a fetch is about is usually the one playing β the reason the tag editor pauses playback and re-hashes (invariants). A fetch that happened on its own must not do that behind the user's back, so the cache row carries it and the editor stays the way lyrics reach a tag.- Never overwrites. A sidecar already there is the user's. In the usual order it cannot even arise β the local tier would have found it and the source would be
lrc_fileβ but underlyrics.prefer_lrclibthe network runs first. The target path comes from the samesidecar_pathhelper the editor writes through, so the check cannot drift from the write. - A failure is not an error. A read-only library folder, a NAS that went away, a filename the filesystem refuses: the row is the source of truth and the lyrics still show. Logged at debug, swallowed.
It hangs off upsert_lyrics, the funnel every primary write already passes through, plus the plugin bundle path which writes its own transaction β and runs after the commit, so a sidecar that could not be written never costs the user the lyrics. TTML is skipped: its XML rides neither extension the reader accepts, the same reason save_lyrics reports a skip for it. An instrumental verdict is an empty row, not lyrics, and writes nothing.
Cache discipline. clear_lyrics flushes the row keyed by the track's hash so the next fetch re-runs the waterfall. Cached outcomes:
- 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. - Partial miss β some query providers answered "nothing", others failed or sat out their cooldown β empty row written with
retry_aftera 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 resetsretry_aftertoNULL, 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 atdebug. - Nobody in the chain answered β not cached. A network error on LRCLIB's tier 6 is bubbled up to the UI as
Errso 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, 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.
WaveFlow recognises two word-timed formats in addition to plain LRC:
- Enhanced LRC β
[mm:ss.xx]La <mm:ss.xx>nuit <mm:ss.xx>tombe. Plain-text extension of the LRC ecosystem; round-trips cleanly throughUSLTso other players see it as regular synced LRC if they don't parse the inline word stamps. - TTML (Apple Music) β XML envelope with
<p begin="β¦" end="β¦"><span begin="β¦" end="β¦">word</span></p>. Imported from.ttml/.xmlfiles exported by tools like LyricsX. Char-level spans nested inside word spans are folded into their parent β v1 ships with word-level animation only.
Detection β commands/lyrics.rs::detect_format sniffs the cached content. TTML matches first on <?xml, <tt, or the http://www.w3.org/ns/ttml namespace. Enhanced LRC requires both a [mm:ssβ¦] line stamp and at least one <mm:ssβ¦> word stamp inside the line body; falling back to plain LRC otherwise. The same heuristic runs on the editor's save path so user-typed content gets re-classified if they switch between modes.
Storage β app.lyrics.format accepts the new 'ttml' value via migration 20260516120000_lyrics_ttml_format.sql (CHECK rebuild β SQLite has no ALTER CONSTRAINT). The content column stays raw text β there's no separate words column; parsing is done at render time on the frontend. This keeps the cache byte-for-byte identical to what would be written into the tag and avoids a hot migration over user data.
Parsing β src/lib/tauri/lyrics.ts exposes parseLrc, parseEnhancedLrc, parseTtml, and a unifying parseLyrics(content, format) dispatcher. All three return the same LyricsLine shape (timeMs, endMs, text, optional words[]). The TTML parser uses the webview's built-in DOMParser β no XML dependency. findActiveWordIndex mirrors findActiveLineIndex (linear scan from hint, O(1) amortised).
Untimed TTML. Apple serves lyrics it has no timing for as TTML too (itunes:timing="None", <p> with no begin). parseTtml drops untimed lines, so such a document parses to nothing β and every surface falls back to the raw content for unsynced lyrics, which showed the XML. Every payload wrapper in lyrics.ts (getLyrics, fetchLyrics, refetchLyrics, radio, remote, import, save) therefore passes through showableLyrics: a TTML payload with no timed line is handed to the UI as plain, one line per <p> and a blank line between <div> stanzas. The cached document is not rewritten.
Rendering β LyricsPanel, ImmersiveLyricsColumn and the mini-player's lyrics overlay all animate the active word: 150 ms transitions on color / opacity / transform, scale(1.04) on the active word, and an opacity ramp for future / past words. The panel adds an accent-color tint that the fullscreen view leaves out (the white-on-dark contrast is enough there). Lines without words keep the existing line-level highlight.
Background vocals, duets and interludes β what an Apple-style TTML carries beyond the words, read by parseTtml and shown by the panel, the immersive column and the mini-player through the shared LyricsVoiceParts:
- Background vocals. A
<span ttm:role="x-bg">inside a line becomes the line'sbackground(text + timed words), never one of its words. It has nobeginof its own, only its words do, so before this it was silently dropped. It is drawn smaller under the lead. Its words keep their own clock β they often start before the lead, and the line starts with whichever comes first β souseTrackLyricsfinds the active one from the position (activeBackgroundWordIndex) instead of borrowing the lead's index. No progressive fill: that stays on the lead. - Duets. When the head declares at least two
<ttm:agent type="person">and both sing, the lines of every singer but the first heard getside: "end"and align right. The first voice,groupagents (lines sung together) and unattributed lines keep the usual side. The mini-player stays centred (no room for two sides at 280 px), and the immersive view's "centre lyrics" choice wins over the duet. - Interludes.
findInterludesmarks every silence of 4 s or more: the intro before the first line, and a gap between lines when the document states where the earlier line ends (some TTML documents do; plain LRC runs each line until the next once parsed, so it only gets its intro). Duet lines overlap, so a gap is measured from the latest end so far.useTrackLyricsexposes the one being played through (activeInterlude, with a 0β1 progress); the surfaces then dim the line before it and show three dots that fill across the stretch and breathe while it lasts (no breathing underprefers-reduced-motion). - Line keys in any namespace. Localizations attach to their line through a key that sits in a private namespace, which depends on who wrote the document:
itunes:keyfrom Apple,lrc:keyfrom relayed documents. The parser reads attributes and element names by local name, whatever the prefix; readingitunes:keyonly would have lost every romanization of the second kind without an error.
The desktop lyrics window does not show them yet: it renders the lead line only, as before.
Progressive fill (issue #491) β the immersive column, the mini-player overlay and the desktop lyrics window go further and sweep inside the word being sung, instead of switching states at the word boundary. Two stacked copies of the word: the base layer carries the accessible text at unsung opacity, and an aria-hidden overlay (the sung state) is clipped to a --kw-fill percentage. Without the aria-hidden a screen reader would announce every active word twice.
The percentage comes from useKaraokeWordFill, and two constraints shape it:
player:positiononly fires at 4 Hz (POSITION_EMIT_INTERVALthrottles it to 250 ms). Painting straight frompositionMswould advance the fill in 250 ms steps β visibly worse than the discrete highlight it replaces. Each event is instead treated as an anchor (position + theperformance.now()when it arrived) and every frame extrapolates from it, scaled byplaybackSpeedso the sweep still tracks at 0.5Γ / 2Γ, and frozen while paused so it doesn't drift to the end of the word.- No React state per frame.
useTrackLyricsis shared by every lyrics surface, and each renders the whole line list, so asetStateper frame would re-render those trees ~60Γ/s. The loop writes the CSS variable straight onto the one element it owns; React never re-renders for the animation. The ref is attached to the active word only β moving it is what tells the hook to sweep the next one.
Fallbacks, all landing on the plain discrete highlight: prefers-reduced-motion, a word with no forward-going endMs (the last word of a track keeps -1, and sloppy sources can stamp two words at the same millisecond), and the side panel, which deliberately keeps the cheap version β it's a far smaller surface (the mini-player takes the fill despite being smaller still: it is a dedicated reading surface, not a strip beside one). The clip reveals left-to-right, so right-to-left lyrics fill from the wrong edge; the word-level highlight already had that limitation, so it's tracked separately rather than half-fixed here.
Estimated word timing (#716). Line-synced lyrics carry no word stamps, so none of the above ran on them. With profile_setting['lyrics.estimate_words'] on (Settings β Lyrics, default off), useTrackLyrics gives the active line only words from estimateLineWords, and every view then draws them as it would real ones. It is a display estimate and is treated as one: re-derived each time a line becomes active, never written to the cache, a sidecar or a tag, and a line that already has real word timing is left untouched. Better than dividing the line equally, which is the obvious version:
- Weighted by syllables β vowel groups for alphabetic scripts, one per block for Hangul, one per character for Han and kana.
- Pauses after punctuation β a comma is half a syllable of silence, a full stop nearly one, spent between the two words rather than held by either.
- A capped span β at most 550 ms a syllable, so a line followed by an instrumental break is not stretched across the whole gap; 300 ms a syllable when the end is unknown (the last line).
- The words finish early, and the last one is held β within a known span the words are delivered in the first 80 % (
DELIVERY_SHARE) and the last word stays active to the end, so the line still hands over on time β but its fill finishes with the delivery (LyricsWord.fillEndMs), so a singer who stops to breathe is not trailed by a sweep crawling through the rest. A singer is through a line before the next one starts, then holds the last vowel or breathes; spreading the words over the whole gap put each one a little further behind the voice, 0.2 to 0.4 s by mid-line on the tracks reported (#750). A guessed span is left as it is, since it is already paced per syllable. - Chinese and Japanese step by phrase, not by character. Every view puts a space between two words, so a per-character split would write spaces into the line.
Romanization and translation (issue #584). An Apple TTML document can carry two further readings of every line, tucked in <head> rather than beside the lines: <translations><translation xml:lang="β¦"><text for="β¦"> and <transliterations><transliteration xml:lang="β¦"><text for="β¦"><span begin="β¦" end="β¦">. Each entry points back at its line through the line's itunes:key, and Apple returns both from a single localized request β asking for a translation language is what brings the transliteration with it.
Measured on a full document (54 lines): one entry per line for each reading, and the transliteration carries one span per original span with identical begin / end bounds. That is the fact the rendering rests on β a romanized word is driven by the clock of the word it reads out, so activeWordIndex addresses both rows and the progressive fill needs no second timing pass.
Four rules are written into parseTtml, each guarding a way this silently goes wrong:
- Join by key, never by index. A localized document may omit a line; matching by position would shift every following entry onto the wrong line with nothing looking broken.
- A word split that does not match the line costs the split, not the reading. Pairing words up as far as they go would put the highlight on the wrong one, which reads as a broken transliteration rather than as a missing feature β so the words are discarded and the text is kept, unsplit, under the line. The same fallback a line-timed document lands on.
- Either reading is dropped when it says what the line already says, ignoring spacing and case. Apple localizes every line, so a line already in the target script or language comes back as itself β printed under itself, that is a duplicate, and a near-duplicate whenever the syllable split differs from the word split, where the eye reads a discrepancy that is not there.
- Only the first
<translation>/<transliteration>is read. Apple returns one of each because the language is chosen in the request; a document carrying several would need the user to pick, which a parser cannot ask.
Preference β lyrics.localization_mode (per profile, via useLyricsLocalization): off / romanization / translation, one at a time as Apple Music presents it. The toggle sits in the lyrics panel header and appears only when the current document carries something to show; lib/lyricsLocalization.ts resolves the stored mode against what the document has, rather than writing it back β so the choice survives a track that has neither and applies again on the next one that does.
Limitation β the immersive column gives the progressive fill to the original line only. useKaraokeWordFill returns one ref callback for one element, and the sweep belongs on the line the eye follows; the romanization takes the discrete highlight, which still marks the word being sung.
Editor β word mode. LyricsEditorModal adds a granularity toggle inside the synchronized tab. In word mode:
- Space β stamps the next un-captured word in the active line. First press also stamps the line's own
timeMsif it's not yet captured. - Enter β advances to the next line (appending a fresh empty one at the end, like line mode).
- Backspace β undoes the last word capture on the active line.
The row UI shows each word as a chip β pink for captured, green-ringed for the next word to capture, grey for future words. Editing a line's text invalidates its word tokenisation, so the user has to re-capture cleanly. The save path serialises back to Enhanced LRC via serializeEnhancedLrc regardless of the originally-imported format (TTML round-trip isn't part of v1).
TTML β USLT. The audio file's USLT frame is plain-text by spec, so writing TTML into it would corrupt other players. write_lyrics_to_file therefore:
- Plain / LRC / Enhanced LRC β
ItemKey::UnsyncLyrics(USLT for ID3v2, UNSYNCEDLYRICS for Vorbis,Β©lyrfor MP4) β unchanged. - TTML on Vorbis / MP4 / FLAC β
ItemKey::Lyrics(the XML-friendly key). - TTML on MP3 β skipped. lofty has no clean ID3v2 mapping for arbitrary XML lyrics, so the file is left untouched, the DB cache still gets the TTML content, and
save_lyricsreturnstag_write_skipped: true. The editor surfaces this as alyrics.toast.tagWriteSkippedwarning so the user knows the file itself wasn't touched.
"These tags are wrong, fetch them and let me approve the result" β
commands/tag_fetch.rs
with the matching in
waveflow_core::metadata::album_match,
reviewed in
TagFetchModal.
Two steps, deliberately. search_album_tag_sources offers the
catalogue releases that might be this record and the user picks one,
because a title and an artist match several releases of the same album
β an original, a remaster, a deluxe edition with four more tracks β and
they carry different track lists. fetch_album_tag_proposals then
pairs the chosen release's tracks with the local files.
Matching the album is the easy half. Inside it, three weighted signals: title 0.60, duration 0.25, track number 0.15. Unequal on purpose β the title carries most of the identity, the duration confirms it, and the track number is corroboration from a field that is wrong often enough to trust least.
- Missing data scores 0.5, not 0. A track with no number is not evidence against a match; scoring it zero would push every untagged file below the threshold and make the feature useless on exactly the libraries that need it.
- Assignment is global and greedy, each side consumed once. Asking "what is the best remote track for this local one" lets a generic title β "Intro", "Interlude" β win against several local files at once and capture one that belonged to another.
- Two thresholds, both inclusive: confident at or above 0.85, doubtful at or above 0.55, nothing below. The middle band is the point β it is what the review screen exists to resolve, and it is why confident matches arrive pre-accepted and doubtful ones do not.
Titles are compared over
name_match::normalize_name,
the normaliser the metadata providers already share: it folds NFD
combining marks, so a library tagged Bjo\u{308}rk matches a
catalogue's BjΓΆrk. A transliteration table written for this feature
would not, and accented titles are not an edge case in a music library.
No composer and no track-level genre β Deezer does not carry them, and a review screen listing a field the source cannot fill invites accepting a blank over something the user typed. No disc number either: the API returns one, but it is unreliable on box sets, which is precisely where the local value is usually right.
No command in the module touches a file or a row. What the review
screen accepts is applied through update_track_tags, one track at a
time β the path that pauses playback before opening the file, writes
through the concrete tag so non-standard frames survive, re-hashes into
track.file_hash and relinks the album and artist rows. Writing across
a whole album is exactly where a second, simpler write path would turn
one bad moment into a folder in an unknown state, which is why #599
waited on #598.
Only the accepted fields are sent: update_track_tags leaves an
omitted field alone, which is what makes "accept this one value" mean
that and nothing more. A track whose write fails is counted and the
rest still run β stopping halfway through an album leaves a folder
nobody can describe.