Skip to content

Latest commit

Β 

History

History
157 lines (96 loc) Β· 20.1 KB

File metadata and controls

157 lines (96 loc) Β· 20.1 KB

Integrations

External services WaveFlow talks to. All clients use reqwest 0.12 with rustls-tls so there's no system OpenSSL dependency.

Offline mode

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 (metadata)

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.

Last.fm

lastfm.rs β€” split into two flows:

Read-only (artist bios)

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.

Read-only (similar artists)

commands/similar.rs::get_similar_artists drives the "Similar artists" carousel on ArtistDetailView. Cascade:

  1. Last.fm artist.getSimilar when an API key is configured β€” returns up to 12 hits with a real 0-1 affinity score.
  2. Deezer /artist/{id}/related as 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.

Authenticated (scrobbling)

scrobbler.rs is the worker thread that drives Last.fm scrobbles:

  • Login β€” signed auth.getMobileSession (md-5 of params + secret). Session key persisted in app_setting['lastfm_session_key'].
  • Now Playing β€” track.updateNowPlaying fires on every player:track-changed event after the 240 s threshold. Best-effort; failures are logged but never block playback.
  • Scrobble queue β€” track.scrobble is queued in the per-profile scrobble_queue table and drained with exponential backoff (10 s β†’ 5 min). Survives app restarts.
  • Re-auth β€” on 9 (Invalid session key) or 4 (Authentication failed), the session is wiped and a lastfm:reauth event is emitted. The frontend surfaces a banner (LastfmReauthBanner) with a one-click "Re-authenticate" button.

Discord Rich Presence

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.

Activity layout

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.

Cover URL resolution

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:

  1. Cache hit β€” JOIN track β†’ album β†’ deezer_album in the per-profile pool. Cheap, no network.
  2. Cache miss β€” call commands::deezer::enrich_album_inner which 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.

Lifecycle

  • Default ON β€” read_enabled returns true when app_setting['integrations.discord_rpc'] is missing. Only an explicit toggle-off (writes the literal "false") disables RPC. The UI toggle lives in SettingsView under "IntΓ©grations".
  • Boot β€” lib.rs::setup reads the persisted flag and spawns the worker. Discord IPC is not connected yet β€” the first connection attempt happens on the first Msg::Metadata after 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) or PlayerState::Ended (queue exhausted), the worker calls clear_activity so the card disappears from the user's profile. Spotify-style: nothing playing β†’ no presence.
  • Pause β€” the activity stays on screen with the pause badge and timestamps removed; same UX as Spotify pausing in the middle of a track.
  • Discord restart β€” set_activity failures drop the client back to None; the next push re-runs the handshake. No reconnect daemon needed β€” the next track-changed event triggers it organically.

Assets

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_image while playing.
  • pause β€” small_image while 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 (synchronized lyrics)

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:

  1. Cache β€” app.lyrics row keyed by track.file_hash (BLAKE3). No TTL, shared across profiles.
  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 re-opens the file as MpegFile, downcasts to Id3v2Tag, and scans the TXXX descriptions explicitly.
  3. 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 current positionMs. Play / Pause / Β±2 s controls pilot the existing PlayerContext so the user can scrub the file while writing the lines, and the rows are serialised back to LRC via serializeLrc.

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 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 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.

Word-level lyrics (Enhanced LRC + TTML)

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 through USLT so 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 / .xml files 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 timeMs if 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, Β©lyr for 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_lyrics returns tag_write_skipped: true. The editor surfaces this as a lyrics.toast.tagWriteSkipped warning so the user knows the file itself wasn't touched.