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 β IntΓ©grations β "Mode hors-ligne" β 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 the LRCLIB lyrics fetch + library prefetch. 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
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 deezer_artist row (the table name is historical β it holds Last.fm bios too) 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.
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). 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.
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.
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.
lrclib.rs β public lookup by artist_name + track_name + album_name + duration against LRCLIB. Three-tier resolution in commands/lyrics.rs, driven on demand by fetch_lyrics:
- Cache β
app.lyricsrow keyed bytrack.file_hash(BLAKE3). No TTL, shared across profiles. - 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. - LRCLIB β synced lyrics first, falls back to plain text. Result cached as a new row.
In addition, import_lrc_file lets the user pick a .lrc file by hand and overwrite the cached entry β there is no automatic sidecar pickup.
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.
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 or LRCLIB) β row written.
- Instrumental flag from LRCLIB β empty row written (suppresses retries).
- LRCLIB 404 / empty payload β empty row written. Without this, lo-fi / ambient libraries would re-hit the network on every panel open since most of their tracks are genuinely missing from LRCLIB. 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 LRCLIB might have added the track. - Network error β not cached and 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 LrclibClient so a slow LRCLIB instance still gets a chance to respond while a truly unreachable host fails fast.
Library-wide prefetch. prefetch_library_lyrics walks every available track without a cached row (deduped by file_hash), runs the embedded β LRCLIB chain, and persists each hit. Network calls are throttled at 500 ms (~2 req/s) to be a polite guest; embedded 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).
Rendering β LyricsPanel and FullscreenLyrics share the same active-word animation: 150 ms transitions on color / opacity / transform, scale(1.04) on the active word, and a 0.45 β 0.8 β 1 opacity ramp for future / past / active 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.
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.