Skip to content

Latest commit

Β 

History

History
414 lines (251 loc) Β· 107 KB

library.md

File metadata and controls

414 lines (251 loc) Β· 107 KB

Library

The library is a per-profile SQLite database (<root>/profiles/<id>/data.db) keyed by canonical file path. It survives moves and renames as much as possible β€” see the import matcher in playlists.

Scanning

  • What gets indexed β€” AUDIO_EXTENSIONS is limited to what the engine can actually play, so the library never shows a track that would error at play time. .opus joined it in #581, when the decoder landed. WMA stays out (proprietary, patent-encumbered β€” the only entry here that is a licence question) and so does .aifc, whose compressed forms the reader refuses. The extension is not the whole filter: .ogg and .oga name a container, so is_scannable_audio reads the stream before believing them β€” an Opus stream now passes that check, a Speex one still does not. A file that stops qualifying is not deleted from a library that already holds it; the disappearance sweep marks the row unavailable, keeping its likes, playlists and history, and a later decoder brings it back.
  • Tag extraction β€” lofty 0.25 reads ID3v2 / Vorbis Comments / MP4 atoms and surfaces title, artist(s), album, album artist, year, track / disc number, genres, embedded artwork, POPM ratings, and the tagged musical key (TKEY / INITIALKEY).
  • A file is read by what it contains, and kept if it plays (#873) β€” read_tagged_file sniffs the format instead of trusting the extension (lofty::read_from_path does trust it, so an AAC that a download site saved as .mp3 was parsed as MPEG and refused), and retries a failed parse once in lofty's Relaxed mode, which steps over a malformed tag frame. When lofty still refuses the file, probe_playable_stream opens it the way playback does β€” symphonia's content probe and the one codec registry β€” and, once a packet decodes, the track is listed with its file name as title and what the decoder reports (duration, rate, channels), with every tag field empty for a later rescan to fill. A file nothing can decode is still an error. Either way the log carries lofty's whole error chain: its FileParseError prints only "failed to parse Mpeg file", and the reason β€” which frame, which tag β€” is the cause underneath.
  • Several genres per track (#729) β€” every genre item is read (get_strings(ItemKey::Genre), never genre(), which is the first one): lofty already turns a multi-value ID3v2.4 TCON, repeated Vorbis GENRE comments and several MP4 Β©gen into one item each. split_genres then splits each value on ;, NUL and \ β€” taggers that write one field join genres with ;, and some write the \\ a tag editor shows between multiple values into the file as text ("Alternative\Rock") β€” trims them, and drops a name the library would file under one already listed (same canonical_name), keeping the tag's order. Not on / or ,: they occur inside genre names people use ("Hip-Hop/Rap", "Folk, World, & Country"). Each genre is one track_genre row. A library scanned before #729 holds one genre per track β€” the first value, or a "Rock; Jazz" kept whole β€” and the fast path never reopens a file whose mtime and size still match, so each folder gets one forced re-read, marked profile_setting['scan.genre_backfill_done.<folder>'] like the custom-tag pass: the unchanged-file branch compares the file's genres with the stored ones and relinks them when they differ. A stopped scan does not set the marker. A file that fails extraction holds it back, since a lock is usually transient, but for three scans at most (scan.genre_backfill_attempts.<folder>, as the custom-tag pass counts), so a permanently unreadable file does not re-read the folder forever. The genre row such a scan leaves without tracks stays in genre (a smart-playlist rule may name it) and drops out of every listing, which only ever counts genres with an available track.
  • Folder cover fallback β€” when a track carries no embedded picture, the scanner inspects its parent directory for a sidecar image with one of the canonical stems (cover, folder, front, albumart, album, artwork) and an extension the thumbnail pipeline can decode (jpg/jpeg, png, webp, bmp, gif, tiff). The first match β€” by stem priority, not alphabetical β€” is hash-addressed into the shared artwork/ dir like an embedded picture. The provenance is tagged source = 'folder' in the artwork table so a future cleanup job can distinguish it from 'embedded', 'deezer', or 'user' entries. Covers common CD-rip / lossless layouts where the artwork sits beside the audio files.
  • Audio quality β€” sample rate, bitrate, channel count, bit depth and codec are captured at scan time. Hi-Res badges (β‰₯ 24-bit, β‰₯ 44.1 kHz) light up automatically on covers and rows.
  • Watch folders β€” notify 8 drives a per-folder filesystem watcher with debounced rescans so files dropped into a watched directory appear without a manual refresh. Deletions flag rows is_available = 0 rather than purging them, so play history, ratings and playlist memberships survive a reorganisation. Three things the watcher does not let fall through: the backend's own "events were lost" signal (an inotify queue overflow, an FSEvents must-scan β€” EventKind::Other with the rescan flag, usually naming no path) asks for the same debounced scan of the folder as any change, since it is exactly what a large copy produces; errors the backend reports are logged, at most once a minute per folder with a count of the ones in between; and a watched folder that cannot be armed β€” a drive not plugged in at launch, a share that mounts late β€” is retried every 60 s, then scanned once when it is back, instead of staying unwatched until the next restart. The retry is a placeholder in the same map as the watchers, so removing the folder, turning watching off or switching profile stops it the same way.
  • The reserved directory β€” .waveflow/ inside a library folder is WaveFlow's own, and both walkers leave it alone (scanner::reserved, issue #695). It has to be said explicitly, for three reasons that each had to be checked rather than assumed: mp4 is in AUDIO_EXTENSIONS, legitimately, since an .mp4 usually holds AAC; is_scannable_audio takes it on sight, because only ogg / oga pay for a header read, so a Canvas clip with no audio stream at all would pass; and the walk had no directory filter whatsoever, so the leading dot would have bought nothing β€” that is a display convention, not something a file walker obeys. collect_scannable_files prunes the branch as filter_entry meets it, so the whole subtree is skipped rather than each file in it refused, and the fs watcher drops an event whose every path is inside it, so caching a clip does not cost a folder rescan (an event touching one of ours and one of theirs is a library change). The check is rooted, not run over every component of an absolute path: somebody whose music lives under a directory of that name would otherwise have an empty library and nothing to explain it β€” and for the same reason the walk never prunes its own root.
  • Live progress β€” scan_folder_inner emits a scan:progress event (throttled to every 25 files, plus one initial and one final tick) carrying current / total, the running added / updated / skipped / errors tallies, cancelled, and current_dir β€” the parent directory of the file just processed, None on the initial and final ticks. The moving bar itself belongs to the task status bar since #601, which lists every long operation in one place; ScanProgressToast (mounted once in AppLayout) shows the outcome, which is the part a status bar cannot β€” its row vanishes when the task ends, and β€œ412 added, 3 errors” is what is worth reading. The toast ignores non-terminal events, so a rescan starting underneath cannot blank a summary the user has not finished reading. A scan the user stopped reports as stopped rather than as a completed walk, and current then carries the files actually reached rather than the folder total (issue #430, #601). Listing the folder reports too: on a network share the walk alone can take minutes, and the task row used to sit at zero with no folder named and a stop button nobody looked at until the list was complete. collect_scannable_files_with now hands the row the count of audio files found so far (no total yet β€” the status bar shows the count alone) and the directory it is in, and checks the stop flag every 32 entries; a stop there returns no list at all, since the scan treats the list as the folder's contents and would sweep every track it had not reached yet.

Folder-cover reconciliation

The scan fast path keys on each audio file's (mtime, size). Replacing a sidecar cover.jpg next to those files changes nothing it looks at: the tracks are skipped, extract_folder_cover never runs, and the album keeps its old picture indefinitely. The file that changed simply isn't one the scanner watches (issue #366).

refresh_folder_covers closes that gap as a post-scan pass. It works per directory rather than per file β€” one read_dir + one hash for a whole album instead of one per track β€” and updates any album whose sidecar no longer matches. No extra bookkeeping is required: artwork.hash is already the blake3 of the picture bytes, so the stored row is its own baseline for comparison, and no migration was needed.

The embedded counterpart (#750) is the opposite case: the audio file itself changed, so the scanner does re-read it and does extract the new picture into the artwork dir β€” but the album only adopted an embedded picture while it had none (artwork_id IS NULL), so it kept pointing at the old one. A changed file's embedded picture now replaces an album cover that was itself embedded, from the album's first track only (lowest disc, then track number). The restriction is load-bearing: the tag editor stores a whole-file hash where the scanner keeps a head-and-tail one, so every rating or tag WaveFlow writes sends that file down the re-extraction path on the next scan, and without it, rating track 7 of an album whose tracks carry different pictures would hand the album track 7's. A picture changed across the whole album reaches it through the first track. A cover chosen by hand, fetched from Deezer or found in the folder is never replaced from there. The previous image stays in the artwork dir; nothing prunes profile artwork today.

Three deliberate restrictions:

  • Only sidecar-sourced artwork is replaced. The guard is an allowlist: album.artwork_source = 'folder', or no artwork at all. It reads the album's column, not artwork.source: one image can be shared by two albums, and the artwork row keeps the source of whichever wrote it first. Everything else was put there by something that outranks a sidecar β€” embedded (extraction treats the sidecar as a fallback for tracks whose tag carries no picture), manual (a manual upload, or a suggestion the user picked), deezer (an enrichment fetch) β€” and a source added later is preserved by default rather than silently clobbered.
  • A deleted sidecar does not blank the album. A vanished cover is far more likely to be a transient state (files being reorganised) than a request for a blank album.
  • A multi-directory album resolves against its first directory in sorted order, evaluated over all of the album's tracks rather than only those under the scanned folder. Scoping it to the scanned folder would make the winning directory depend on which folder triggered the scan β€” disc 1 winning one pass and disc 2 the next β€” which is exactly the non-determinism this rule exists to prevent.

Directory resolution (read_dir + read + blake3 per directory, potentially hundreds of megabytes) runs in a single spawn_blocking batch rather than on the async runtime, and the writes land in one transaction β€” the scanner is the single writer, so per-album autocommits would serialise N round-trips through WAL for no benefit.

Because that walk can take seconds, the candidate list is a snapshot that may be stale by the time it is written. The update is therefore a compare-and-swap that also re-asserts the source allowlist (link_folder_cover_if_eligible): an album whose cover changed mid-walk β€” the user uploaded one, or a concurrent scan resolved a fresher sidecar β€” is left alone, and the count of refreshed covers comes from rows_affected so a skipped album never inflates the scan summary.

A tag edit that rewrites the audio file is normally detected, since it moves that file's mtime β€” this pass is specifically about the sidecar case. But that is not guaranteed: taggers commonly offer to preserve the modification date (Mp3tag ships that behaviour), and an ID3 rewrite often fits in the existing padding, so size doesn't move either. The file then looks untouched to the fast path and its new tags are never read β€” reported as issue #457 for a batch of genre edits.

Deep rescan

A deep rescan bypasses (mtime, size) entirely and re-hashes + re-reads every file. It is the escape hatch for exactly the case above, and it is opt-in because it costs a full re-read of the library.

Two entry points, in different places:

  • per folder β€” the magnifier button on a folder row, under My music β†’ Folders (scan_folder with deep: true). Appears on row hover. scan_folder neither emits library:rescanned nor touches library.updated_at, so the view refetches its lists and library rows itself afterwards β€” without that the new tracks reached the database but not the Songs, Albums and Artists lists (#613);
  • whole library β€” the second button next to Rescan in the My music header, so it is reachable from any tab, not just Folders (rescan_library with deep: true). Added in issue #457: until then the bypass existed per folder only, so the library-wide button users actually reach for could never see mtime-preserving edits.

Both are mutually exclusive with each other and with a plain rescan β€” scan_folder_inner writes, and SQLite takes one writer at a time.

Note the interaction with Split this artist below: a deep rescan re-reads tags authoritatively and will undo an in-place split, since it no longer sees the split as deliberate.

Audio analysis

analysis.rs computes peak, integrated loudness, tempo with a confidence, energy, and the key with a confidence. Loudness is ITU-R BS.1770-4 β€” K-weighted and gated (analysis/loudness.rs) β€” so the number is real LUFS and lands on the same scale as the ReplayGain other taggers write; the earlier unweighted RMS over a mono sum was a fine relative yardstick inside one library but could not be compared with anything outside it. ReplayGain is -18 LUFS - loudness, and is NULL rather than a huge boost for a track with nothing above the absolute gate. Peak is taken across every channel, not over a mono downmix β€” an out-of-phase mix sums to near silence while its samples sit at full scale, and clipping prevention downstream depends on that number being true. Runs on demand (per track) or as a background sweep (whole library), gated by a Settings toggle. Results land in track_analysis and feed:

  • per-stream gain in the audio engine (replaygain_enabled) β€” as the fallback source, behind the file's own tags (see playback),
  • the BPM bucketing in smart playlists and the mood radios, which also read the confidence and the energy,
  • the per-track audio specs strip under the player.

Tempo and energy (analysis version 2)

analysis/rhythm.rs replaced version 1's tempo, an autocorrelation peak over an RMS envelope between 60 and 200 BPM. That one failed in two ways a user could hear (#848): it read fast tracks at half speed, and when nothing stood out it stuck to the edge of its range, so "60 BPM" meant "no idea" about as often as it meant 60 β€” which is how dance records ended up in Sleep.

  • Onsets are a spectral flux over the mono sum averaged down to ~11 kHz: frame by frame (1024 samples, hop 128, ~86 frames/s), how much each band up to 5 kHz rose, in dB. Working in dB makes it blind to the track's level, since a gain change cancels out of every difference.
  • Tempo is chosen over 40–240 BPM from an autocorrelation averaged over 8-second windows, each candidate scored with its first harmonic and a log-normal prior around 120 BPM. When a reading and its double or half score within 10% of each other β€” which the prior, symmetric in octaves, cannot settle β€” the energy does: an intense track takes the faster reading, a calm one the slower.
  • Confidence (bpm_confidence, 0–1) multiplies how clearly the winner beats any unrelated tempo, how far ahead of its own octave it is, and how many windows agree. A winner on the edge of the range gets 0.
  • Energy (energy, 0–1) blends attack strength (the flux itself), onsets per second, spectral centroid and level spread, each squashed around the median measured on a real library. None of them is a level, so a track normalised to βˆ’14 LUFS and the same track at βˆ’6 read the same.

Measured against Deezer's BPM on 209 tracks of a real library, version 1 was right on 49%, an octave out on 34% and wrong on 17%; version 2 is right on 86%, an octave out on 9% and wrong on 5%. Readings at confidence 0.6 or more are right 97% of the time, those under 0.2 half the time. Some of the remaining octave misses are the reference's (Deezer doubles a few slow songs). Widening the octave tie to 20% was tried and measured worse (83%, ten readings doubled instead of one). The analysis_bench example reruns the measurement on any folder, against the files' BPM tags; WaveFlow itself never reads those tags for its tempo.

Bumping ANALYSIS_VERSION makes the background sweep re-measure every row once, which is a full re-decode of the library. Versions 2 and 3 never shipped on their own: version 3 adds the key and version 4 the beats, so a library is re-measured once for all three. With automatic analysis on, the sweep starts when a profile opens β€” at startup (after the startup scan when there is one, since a sweep started during it would make the second one a no-op) and on a profile switch β€” because the other triggers only fire when a scan adds a file, and a library that gains none would otherwise stay on the old version until someone pressed Analyse. One sweep runs at a time, and an automatic request that arrives during one is no longer dropped: it is kept (Slot::rerun in commands/analysis.rs) and run once when the current sweep ends, against whichever profile is active then β€” which covers tracks a second scan adds after the sweep built its queue, and a profile opened while the previous one's sweep was still draining. A sweep the user stopped is not followed by one; the stop, the end of a run and the start of the next share one lock, so a click on Stop as a sweep ends cannot be lost.

Beats (analysis version 4)

analysis/beats.rs places every beat, so a later feature can know where one falls and not only how often: a crossfade landing on the beat, bar markers, loop points on the grid. It is Ellis's dynamic-programming tracker (2007) over the onset envelope the tempo already uses, at the tempo already chosen, so it costs no second decode. Each frame gets the best score a chain of beats ending on it can reach: the chain earns the onset strength under each beat and pays the squared log-ratio of every gap to the period, so a beat may drift a little β€” a drummer is not a click β€” while a gap of half or twice the period costs as much as missing a beat. The best chain, read back from the end, is the list; beats at either end that sit on next to nothing (a quiet intro, a fade) are trimmed.

Measured with analysis_bench --beats against hand-annotated beats, scored as mir_eval does (F-measure, Β±70 ms, the first 5 s left out):

Dataset Tracks F-measure Any metrical level
Ballroom 698 74.9% 88.5%
GTZAN 998 76.8% 86.4%

"Any metrical level" also accepts the annotation's double, halves and off-beats. The gap between the two columns is mostly the tempo's octave rather than the placement: Ballroom annotates the dancer's step, so a quickstep at 200 BPM that the tempo stage reads at 100 (as it does for 96% of them, by design β€” that stage is tuned on a real library against Deezer) still lands every other beat. Two settings were measured and changed: the gap penalty is 400 rather than the paper's 100 (best on both datasets; 800 loses ground again), and a beat is dated 33 ms after its frame's centre, where the flux peaks earlier than the attack β€” the same offset on click tracks and on both datasets. A period re-read every 2 s within Β±15–25% of the global one, meant to follow bands that speed up, measured slightly worse on both datasets, live genres included, and was dropped; a track that really changes tempo is the case it would have served, and the stored list leaves room for it.

The list is stored in track_analysis.beats by beats::encode: a format byte, the first beat in milliseconds, then each gap in milliseconds as a u16 β€” about 1 KB for four minutes. Nothing reads it yet.

Fake lossless files

analysis/bandwidth.rs tells a lossless file made from a lossy one. Every lossy encoder low-passes before it encodes β€” LAME around 16 kHz at 128 kbps and 20 kHz at 320, Opus at 20 kHz β€” and decoding the result into FLAC or WAV keeps the cut; a recording made lossless fades into the noise floor instead, or runs on to its converter's filter just under Nyquist. So the pass averages the power spectrum of the whole file at the native rate (the tempo and the key run on a decimated copy, which throws away exactly the highs this looks for) and channel by channel (a mono sum cancels highs in opposition between the channels and would draw a wall a genuine file does not have) and searches above 11 kHz for a wall: a fall of at least 20 dB across a few hundred hertz, above which nothing comes back to within 15 dB of the music. A slow fade, however low it ends, is not a wall β€” which is what keeps an old, dull recording from being taken for an MP3. A lossless file with a wall at or under 21 kHz is a probable fake β€” and at least 1 kHz under Nyquist, since a converter's own filter stands just under it: near 15.5 kHz for a 32 kHz file, which the fixed threshold alone would take for a 128 kbps MP3.

Measured on averaged spectra with analysis_bench plus a sweep of the three settings: 41 genuine WAVs and 40 full-band FLACs, 240 fakes made from those FLACs, and 600 real MP3 and M4A files.

Set Flagged
Genuine WAV / full-band FLAC 0% / 0%
Fakes through LAME 128 / 192 / 320 kbps 95% / 87.5% / 87.5%
Fakes through Opus 160 kbps 80%
Real MP3 / M4A (as a check of the wall itself) 98% / 94%

At a 15 dB wall one genuine WAV was flagged, hence 20. What it misses are dark sources β€” lo-fi, sparse acoustic β€” whose highs sit so near the floor that the encoder's cut is a fall of 12–20 dB. It cannot see an encoder that does not low-pass: ffmpeg's own AAC encoder at 256 kbps keeps the full band, and so do most Vorbis files at q6 (0 and 17.5% caught) β€” no measure of where the highs stop can tell those apart, and the honest answer for them is "no wall". The comparison above the wall is against the music, not the floor: an MP3 decoded at 48 kHz leaves faint tones near Nyquist, 60 dB down yet well over the floor, and measuring against the floor vetoed 362 real MP3s.

The cutoff is stored in track_analysis.cutoff_hz (NULL for no wall) with bandwidth_version beside it, NULL until measured β€” so "no wall" and "never measured" stay apart. A change to the detector bumps BANDWIDTH_VERSION, and the sweep then re-decodes the lossless files alone rather than the library. The verdict is computed where it is read, from one SQL definition (probable_fake_lossless_sql!): a lossless format (FLAC, WAV, AIFF, PCM β€” a WAV or AIFF listed from its stream when its tags were unreadable β€”, APE, WavPack, or ALAC β€” labelled AAC by the scanner and told apart by its bit depth, which no lossy codec reports) with a wall at or under 21 kHz and 1 kHz under its Nyquist. It shows in Track Properties (where the highs stop, and the verdict), as an inventory category, and as the fake_lossless rule. Not as a badge in the track lists: a dull recording flagged by mistake would carry the accusation on every row.

Key (analysis version 3)

analysis/key.rs reads the key from the same decimated mono signal as the tempo, with its own longer FFT (4096 samples, 2.7 Hz per bin: a semitone is 3 Hz wide in the bass). Each frame is whitened β€” every bin less the mean within about two semitones of it, which cancels broadband drums β€” and only the peaks that remain between 40 Hz and 1 kHz are folded onto the twelve pitch classes. The summed chromagram is correlated with Shaath's key profile (KeyFinder) rotated to all 24 keys; the best is the key, and its lead over the runner-up is the confidence.

Every choice was measured on the GiantSteps Key dataset (604 hand-corrected electronic previews), MIREX-weighted (exact 1, fifth 0.5, relative 0.3, parallel 0.2):

Variant weighted exact
plain chromagram, Temperley profile 49.4 36.9%
plain chromagram, Shaath profile 52.8 41.1%
whitened + peaks, Shaath, 60 Hz–2 kHz 58.4 46.5%
whitened + peaks, Shaath, 40 Hz–1 kHz 60.0 49.5%

Kept out, because they measured worse or no better: compressing the magnitudes (square root βˆ’19, log βˆ’40), magnitudes squared (βˆ’1.6), no per-frame normalisation (βˆ’0.8), a 100 Hz floor (βˆ’2.2), and folding each peak onto the fundamentals it could be a harmonic of (HPCP, βˆ’0.6). Shaath led Krumhansl, Temperley and Faraldo's EDMA on every variant. Trained detectors do better than 60 on this set; this is the level of a classic profile method, and the dataset is dance music only, so other genres are untested. The confidence separates only modestly (57% exact above 0.5, 32% under 0.1), so the UI marks a reading under 0.1 as uncertain and nothing filters on it.

A tagged key wins. The analysis writes track_analysis.musical_key; track.musical_key stays the tag's, because a rescan rewrites it from the file and would erase a value the tag never held. Every query that shows a key reads effective_musical_key_sql! β€” the tag when there is one, the analysis otherwise β€” so the list, the album, the detail pages, search and the properties all agree. Track Properties shows both: the effective key with the file's metadata, the analysed one with the analysis.

Keys are stored as a key signature spells them (Eb, F#m) and shown in the reader's language by formatMusicalKey β€” solfΓ¨ge where that is the convention (Miβ™­ majeur, Π΄ΠΎ ΠΌΠ°ΠΆΠΎΡ€), German names in German and Dutch (Es-Dur, fis-Moll), the traditional names in Japanese and Korean (倉ホ長θͺΏ, 가단쑰), 降Eε€§θ°ƒ in Chinese. It also reads what taggers write: Am, A minor, Bbm, Camelot 8A, Open Key 1m; anything else is shown as written. The analysis_bench example prints each file's key and chromagram, which is how the variants above were compared.

Rows analysed before the BS.1770 switch are left in place β€” deleting a user's analysis results to force a re-run is a worse trade than the residual mismatch, and that decision stands. They hold two superseded values, and the second is not the harmless one this paragraph used to claim it was:

  • the old unweighted RMS loudness, a few dB off the new scale on some material, so a track analysed back then and a track carrying a REPLAYGAIN_* tag can differ slightly in level;
  • a peak taken over a mono downmix. This one is not bounded by clipping prevention, because clipping prevention is computed from it: the cap is -20Β·log10(peak), an out-of-phase mix sums to near silence while its samples sit at full scale, so such a row under-reports its peak and the cap it yields is too generous. The boost it allows clips for real β€” audible distortion, not a few dB of level.

Which is why analysis_version exists (#586). Rows predating it read NULL, and that is the signal that was missing: comparing analyzed_at against the date the switch shipped was the only alternative, and it breaks on a restored backup, a profile carried between machines, a late upgrade or a wrong clock.

Two things follow from it. Until such a row is re-measured, the player treats its peak as a lower bound rather than a measurement β€” refusing the boost it appears to allow while still honouring any attenuation it asks for (see playback). And the background sweep now picks those rows up alongside the never-analysed ones β€” anything stamped older than the current version β€” so they heal by being re-measured rather than by being deleted. Re-analysing a track replaces the value with the real LUFS one and stamps the current version.

The background sweep (run_analyze_library) is deliberately yielded to the foreground scanner: a scan saturates every CPU core and the single SQLite writer, so the analyzer parks itself while scan_in_flight() reports an active walk (any of scan_folder / rescan_library / import_dropped / the fs-watcher / the startup rescan). It resumes once the scan drains. Decoded results are buffered and flushed to track_analysis in batches of 16 inside one transaction, and the flush retries the whole batch on SQLITE_BUSY / SQLITE_LOCKED with exponential backoff β€” before this, a per-row INSERT racing a concurrent scan would hit database is locked after the 5 s busy-timeout and silently drop the freshly-computed BPM / loudness.

Multi-artist

The scanner splits multi-artist tag values on "; " only β€” the convention used by MusicBrainz Picard, foobar2000, Beets and Mp3Tag for multi-value artist fields. "Artist A; Artist B" becomes two artist rows linked to the track via the track_artist many-to-many table with a position column for stable ordering. ", " is deliberately NOT a separator because a comma can be part of an artist name ("Tyler, The Creator", "Earth, Wind & Fire", "Crosby, Stills, Nash & Young"); the earlier comma-split silently fragmented those into multiple artists. Libraries that comma-joined their multi-artist fields will see those tracks under the combined-name phantom artist until re-tagged with "; ". Queries rebuild the display string with GROUP_CONCAT(...) ORDER BY position. The ArtistLink React component receives parallel artist_name + artist_ids strings so every contributor is individually clickable, matching Spotify's behaviour.

Split this artist (issue #396) is the escape hatch for a comma-joined library that can't be re-tagged. The phantom's Artist Detail β†’ Edit info modal shows a "Split" section (only when the name contains a comma β€” ASCII or one of the CJK ones, see Artists to split) previewing the comma fragments; confirming calls split_artist, which re-links every track that credits the phantom to the individual artists β€” reusing existing rows by canonical name, so the track immediately points at the already-enriched artist row rather than a fresh one β€” repoints track.primary_artist, and deletes the phantom once nothing references it (guarded on track_artist, album.artist_id, track.primary_artist and artist_similar_custom so it never SET-NULLs an album artist or CASCADEs a curated similar list). No file is re-tagged. Durability: the scanner's skip-branch re-normalisation treats a comma-joined tag (one "; "-split name) whose track already credits several artists as a deliberate split and leaves it alone, so a normal unchanged-file rescan won't collapse it back into the phantom. A deep rescan re-reads tags authoritatively and will re-create the phantom β€” re-tag with "; " for a fix that survives that too.

Album grouping

Albums are keyed on (canonical_title, album_artist_id), not on the title alone β€” otherwise every "Greatest Hits" in the library would collapse into one row. scan.rs::upsert_album resolves the album artist in that order:

  1. the Album Artist tag, when present;
  2. the is_compilation flag β‡’ the "Various Artists" sentinel;
  3. the track's primary artist, as a fallback.

album.is_compilation is sticky: once an album is marked as a compilation it stays one, so a later file whose tags disagree can't split the album in half. On top of that, merge_implicit_compilations runs after every scan and collapses same-title rows credited to β‰₯ 3 distinct artists into a single "Various Artists" album β€” the common shape of a soundtrack or a sampler ripped without an Album Artist tag.

Tag edits go through the same funnel: edit.rs re-runs upsert_album with the old album's Album Artist + compilation flags, so renaming an album (or fixing one track's title) re-groups the tracks instead of spawning a second row.

Browsing

  • Library tabs β€” Morceaux, Albums, Artistes, Genres, AnnΓ©es, Playlists, Dossiers; each tab keeps its own scroll position and, where it can be sorted, its sort memory (per profile). Six of the seven fetch their own data; Playlists reads PlaylistContext instead (PlaylistGrid, issue #461), so it has no query, no loading state, and no skeleton β€” the sidebar has already loaded the same rows. It shows user playlists only (is_smart === 0); the generated ones stay in Home's "Made for you" carousel, matching how HomeView filters them. Tiles are row-virtualized against the page scroller like the albums grid, sorted by the sidebar's manual order (playlist.position) by default, and fall back to the playlist's icon + colour tile when it has no cover β€” the same visual the sidebar row uses, so one playlist looks the same in both places. The remaining six tab queries (list_tracks / list_albums / list_artists / list_genres / list_years / list_folders) fire in parallel on first mount so subsequent tab switches hit cached React state instantly instead of paying a fresh SQL round-trip; the first paint shows a layout-shaped LibraryTabSkeleton (role="status" / aria-busy="true") until the data lands, never the EmptyState. Browse queries lean on partial indexes idx_track_album_available / idx_track_primary_artist_available (WHERE is_available = 1) so the GROUP BY aggregates stay index-only on healthy libraries. The Dossiers tab is the one whose content the tab query only starts: list_folders lists the roots, and descending into one switches to the folder browser described below. Clicking a genre tile opens a Spotify-style genre detail page (get_genre_detail in browse.rs) with every track tagged with that genre, sorted Artist β†’ Album β†’ Disc β†’ Track. Manual genre picture (issue #424): genres have no automatic/embedded artwork source of their own, so genre.artwork_id only gets a value when the user sets one β€” a Pencil overlay on the grid tile and the detail-page header opens GenreImagePickerModal (set_genre_artwork_from_file / clear_genre_artwork), which validates the file's magic bytes (jpg/png/webp, shared detect_image_format helper) and stores it hash-addressed in the same per-profile artwork/ dir as album/artist covers, tagged source = 'manual'.
  • Years tab β€” years.rs. list_years groups the available tracks by the year their tags give, newest first, with the tracks that have none last under "Unknown year"; a year outside 1–9999 (a 0, a full date pasted into the field) counts as none rather than as a year of its own. Each year carries its track and album counts and the cover of the album holding most of its tracks, the lowest album id breaking a tie so the tile does not change between loads. The tab lays the years out in a section per decade. A tile opens the year page, get_year_detail, which lists the tracks through the same query the genre page uses (detail_tracks_sql / detail_track in browse.rs), filtered on the same year expression, and shows the cover the tile did. album.year is not used: an album's tracks can carry different years, and the tab lists tracks.
  • Bulk list endpoints wire format β€” list_tracks / list_playlist_tracks / list_liked_tracks (track-shaped) and list_albums / list_artists (browse-shaped) all return { artwork_base, items: <Slim>[] } instead of the full row shape (artists additionally include metadata_artwork_base for the Deezer cache). Each slim row carries artwork_hash + artwork_format + artwork_has_1x + artwork_has_2x (artists also picture_hash + picture_has_*) instead of three full path strings; the ~70-char per-profile prefix only appears once in the response. Frontend wrappers (expandTrackResponse, expandAlbumRow / expandArtistRow in browse.ts) stitch the absolute paths back together so every UI consumer keeps the full Track / AlbumRow / ArtistRow shape unchanged. Cuts ~30 % off each payload (e.g. β‰ˆ 1.0 MB β†’ β‰ˆ 700 kB on a 1k-track list_tracks, β‰ˆ 650 kB β†’ β‰ˆ 250 kB on a 900-artist list_artists), proportionally shrinking JSON parse + IPC transfer time. Any future bulk endpoint shipping artworks for hundreds of rows should adopt the same { artwork_base, items } shape.
  • Artist page β€” its track list comes from list_tracks with an artist_id, matched by row id: every track that credits the artist through track_artist, in any position, plus every track of an album whose album artist they are. It used to load the whole library and keep the rows whose display string, split on ", ", contained the name β€” so "Earth, Wind & Fire" had no tracks, the multi-artist rule broken on the one page that needed it. get_artist_detail counts and lists albums the same way, so an artist who is only ever an album artist ("Various Artists", reached from a compilation's page) gets a page instead of "artist not found".
  • Single-click play β€” with ui.single_click_play on, the first click of a row plays it, in every list of tracks: the library's tables, album, artist, genre and year pages, liked tracks, history, playlists, the queue panel and the immersive queue (#932: only the library's song list read it, every other list still asked for a double-click). They all read it through useSingleClickPlay, so a change in Settings reaches the lists already on screen, and decide with isPlayClick: a plain click on the row itself, not one with a modifier (those select) nor one on a control inside the row (a like button, an artist link, a drag handle). A list rendered once per row (playlist, queue, history days) reads the setting once and hands it down, rather than once per row; the second click of a double-click (detail > 1) and the row's dblclick stand down, so a double-click out of habit is one play instead of three. Either way, playing a row clears the selection its own click made (#897): with double-click play the first click selects the row, and the blue selection used to outrank the green "now playing" on that very row. The library, album and playlist tables all do this; playing a selection from the action bar is a separate path and keeps it.
  • A-Z navigator β€” letter rail on the artists tab, NFD-normalised so accents (Γ‰ β†’ E, Γ‘ β†’ N) bucket correctly.
  • Multi-select β€” ctrl/shift across rows with a floating action bar (Play / Add to queue / Add to playlist / Remove) anchored to the bottom of the viewport. Ctrl+A (Cmd+A) selects every local row of the list on screen β€” the tracks tab, an opened folder, an inventory category, an album, a playlist, once that list has loaded (right after navigating, the previous one is still in state) β€” (useSelectAllShortcut) β€” and leaves the combo to a focused field or an open dialog. The selection bar shows on the tracks tab, in an opened folder and in an inventory category, and the selection clears when the tab, the folder or the inventory category changes. Each row's number gives way to a checkbox (RowSelectCheckbox) on keyboard focus (it is hidden by opacity, so it stays in the tab order) and on every row once a selection has been asked for β€” a Ctrl or Shift click, or Ctrl+A (useMultiSelect().checkboxMode). A plain click only highlights its row, which keeps its number: switching the whole list to boxes on the way to a double-click play is what #859 reported. The box no longer shows on hover, in either click mode (#867): it sits where the number is, so it caught clicks meant for the row, and it put a checkbox under every pass of the mouse. Each box is named after its row for screen readers; its click and Space never reach the row, which would play it or reset the selection. A row clicked with the mouse never shows a focus ring, whatever key is pressed next β€” Escape, a digit, or a desktop shortcut such as Super+Q under Hyprland (#866): inputModality marks focus that arrives from a pointer, and app.css keeps a marked element ringless; focus reached from the keyboard keeps its ring. Server rows have no rowid to select and keep their number.
  • Track Properties dialog β€” foobar2000-style modal with the full tag set, audio specs, analysis results, file path and a Show in Explorer button. The read-only view shows every tag the form edits (track and disc number on two rows β€” the old "1 / 37" read as track 1 of 37 β€” and the genre), and Edit sits in the header next to Close rather than in the footer a scroll away. In the form, artist, album and genre are TagCombobox fields: click or type and a list opens with what the library already holds. Genres are few and loaded whole, then filtered as you type (accent- and case-insensitive); artists and albums can run to thousands, so they come from the backend's search_artists / search_albums (bounded, prefix-first, the top bar's search) after a 150 ms pause, cached per query for the life of the dialog and shown as returned, since that search matches a normalised form the list would not. For the genre, the list adds the common genres of genrePresets.ts (the ID3v1 / Winamp list tag editors propose, trimmed, plus a few recent genres) β€” anything typed is still taken as is. A pick replaces the genre rather than adding one: the save stores one genre, so completing a ;-separated list would create a genre named after the whole list. The list is portalled (the metadata section clips with overflow: hidden) and focus never leaves the input, so Escape closes the list first and the dialog second. What a save sends, and what it changes. Title, numbers and year always; artist, album and genre only when the field differs from what the track holds. The artist field is filled from get_track_artists, the credits joined with "; " β€” the only separator split_artist_name splits on β€” not from the row's display artist_name, which is joined with ", ": echoing that back merged a multi-artist track's credits into one artist named "A, B", in the database and in the file, on any save, a title-only one included. Artists, albums and genres are found by a canonical key that ignores case, punctuation and spacing, so "The Wall" typed over "the wall" lands on the same row; the save then respells that row (respell in edit.rs), since nothing else ever renamed it and the fix showed in the file alone. Only on an explicit edit: the scanner leaves names as they were first seen, or two files spelling an artist differently would rename it back and forth on every scan. The save also recomputes the year of the album the track left and the one it joined (refresh_album_years, shared with the folder scan), which the header otherwise kept until the next scan of that folder. A file changed by another program while the dialog is open is not overwritten. Since the title, numbers and year always go back, a tag written by another tagger in the meantime would have been silently reverted to the values the dialog showed. The dialog reads the file's stamp (get_track_file_stamp: size and modification time, the pair the scanner's fast path trusts, as an opaque string so the nanoseconds survive JSON) each time it loads the track, and hands it back with the save; update_track_tags checks it before pausing playback and again just before the write, and refuses with AppError::FileChangedOnDisk when the file moved. The save returns the new stamp, so the dialog's own writes β€” and the rating and cover, which refresh the track β€” never trip it. The refusal names the cause and offers Reload from file, which rescans the track's library folder (reread_track_folder, the ordinary scan, so unchanged files cost a stat) and refills the form from the result: reloading from the database alone would show the same stale values with a fresh stamp. A change made before the dialog opened and not yet scanned is outside what the stamp can see; a watched folder catches that one. The batch editor and the tag fetcher send only the fields the user chose, so they carry no stamp.
  • POPM ratings β€” 5-star with half-steps, round-tripped to the file's tag. Edit surfaces: inline StarRating in the library track list, integer-star submenu in the right-click TrackContextMenu (any view), full half-star widget in the TrackPropertiesModal. The backend command set_track_rating writes the POPM frame back to the file (binary <email>\0<rating><counter> for ID3v2, text RATING=0-100 for Vorbis / MP4 / APE), updates track.rating in the DB, then emits track:updated so every open view re-fetches without polling. The write goes through edit::patch_file like every other tag edit (#644), so a .dsf gets its POPM frame through the DSF writer rather than stopping at the database. ID3v2 ratings are written as the POPM frame itself, on the concrete tag, and that is not a stylistic choice: lofty 0.25 changed what a POPM looks like on its generic tag β€” it arrives as <provider>|<stars>|<counter> through a whole-star StarRating rather than as the raw frame body its own documentation still describes β€” so a 0-255 value in half-star steps put into the generic tag is dropped on the way back to the file, silently, and a plain number or a raw body produces no frame at all (measured). The scanner reads both shapes for the same reason: reading only the old one is why an MP3's rating came back as nothing after that bump. .dff is still refused, with the reason. WaveFlow does not read a rating back out of a DSD file β€” the DSD extractor lifts title / artist / album / year and no more β€” so the database stays the copy this app trusts; what the write buys is the rating travelling with the file. Smart playlists expose this as the rating_min rule β€” see smart-playlists.md.
  • Lightbox β€” double-click any cover or artist photo to view full-size with keyboard navigation.

Tag editing

commands/edit.rs writes the file first, then mirrors the change into the database (track / album / artist / track_artist / track_genre) inside one transaction, re-hashes the file and emits track:updated + library:rescanned + player:queue-changed.

Every write goes through patch_file, which reads the concrete lofty file type rather than the generic TaggedFile. See the invariant for why: the generic round trip drops non-standard Vorbis comments, so editing a FLAC's title used to erase the SYNCEDLYRICS our own lyrics editor had written into it. The same function serves the cover write, which replaces the front cover and leaves a release's booklet, back cover and artist shots alone.

.dsf is written by a path of our own (#592): its tag is a plain ID3v2 block sitting after the audio at an offset the header declares, so it can grow or shrink without one audio byte moving, and the header's size and pointer fields are updated with it. The pointer is checked against the end of the data chunk first β€” a header claiming its tag starts inside the audio is refused, because believing it would overwrite the track and then cut the file back to the end of the tag. The file keeps the ID3 version it arrived with, since rewriting a 2.3 tag as 2.4 is a file some hardware DSD players stop reading.

.dff is still refused before anything is touched, and the dialog shows the reason instead of failing silently: it carries no ID3 by convention, its metadata lives in its own chunk structure, and some taggers append an ID3 chunk anyway β€” so which shape to write is a real question with no obvious answer.

Ratings and tag-saved lyrics take the same route (#644). Both used to reach for lofty::read_from_path themselves, which left a rating on a .dsf in the database only and made "save lyrics to tag" fail there with a message about the format rather than the situation. They are payloads on patch_file now β€” TagPatch::Rating and TagPatch::Lyrics β€” so they get the DSF writer, the in-place / rewrite split of #590 and #598, and the concrete-tag round trip that keeps a file's non-standard comments. The canonical SYNCEDLYRICS custom tag still takes a second pass over the concrete tag, because lofty's generic tag has no key for a custom one; the DSF branch of that pass writes it as a TXXX through the same with_dsf_tag seam. TTML is refused on every ID3v2-only container, .dsf included, and the UI says the lyrics stay in-app β€” lofty states outright that ItemKey::Lyrics has no ID3v2 mapping, so the alternative is a save that reports success and writes nothing.

The genres are fetched separately. They live in track_genre, not on the Track row, so TrackPropertiesModal loads them through get_track_genres and pre-fills the input with them joined by "; ". The field is a list: TagCombobox in separator mode completes the entry after the last ;, does not offer a genre already entered, and a pick leaves room for the next one. The save compares the field split as the backend will split it (splitGenres), sends it only when that list changed, and the backend writes one value per genre β€” through the generic half of the concrete-tag round trip, remove_genre then one pushed item each, never set_genre, which keeps one β€” and links one row per genre in sync_db. A DSF keeps the ID3 version it had, and only 2.4 has multi-value frames: asked for one in 2.2 or 2.3, the id3 crate joins the values with / ("Jazz/Trip Hop", which split_genres rightly does not split), so join_genres_for_legacy writes those versions one value joined with "; " instead. The batch editor's genre field takes the same ;-separated list. It has to: the dialog sends every field on save, and a genre input that opened empty read as "the user cleared it" β€” erasing the value from the file and from the database, with no rescan able to recover it. Until that fetch lands the field is omitted from the payload entirely rather than sent empty. The batch editor (BatchTagEditModal) never had the problem: it sends only the fields the user explicitly enabled.

A failed save is rendered in the dialog rather than logged to the console.

A server track is corrected, not rewritten

None of the above applies to a track that lives on the bound server: there is no file here to open, and rewriting somebody else's collection is not something a client gets to do. RemoteTrackTagsModal sends the correction to the server instead, which stores it beside the track β€” so it survives that library's rescans and every member of the library sees it.

It is a separate dialog rather than a mode of the Properties one, because Properties is a file inspector β€” codec, bit depth, path, size, on-disk analysis β€” and for a server track almost none of that exists. Six fields, and only the ones the server can store.

Two differences from the local editor, both load-bearing and spelled out in RFC-005: the whole form goes every time, because the server's patch is wholesale and an omitted field withdraws its correction; and clearing a box shows a blank that may not last, because the track then falls back to a tag this device has never seen β€” the server's reply is what fills it back in.

Search

FTS5 index over title, album_title, artist_name, tokenised with trigram since #579 β€” every 3-character window is a token, so a term matches anywhere in a field rather than only at its start. Auto-sync triggers (AFTER INSERT/UPDATE/DELETE on track, plus AFTER UPDATE OF name/title on artist / album so a rename reaches every track pointing at it) keep the index current. Queries are issued from the React top bar with a 250 ms debounce.

Why trigram, and what it cost. unicode61 does not segment CJK: an unbroken run of Han characters became a single token. Measured on a title of 中国人民解放军, δΈ­ε›½* found it and δΊΊζ°‘* found nothing β€” so a Chinese library was searchable only by typing a title from its first character, and typing correct characters from the middle failed silently. The defect was also asymmetric, which made it harder to read: search_albums / search_artists match with instr(), so the same query found the album and not the track.

Two consequences follow, both deliberate:

  • The table owns its content (no content=''). It has to: LIKE is what serves queries below trigram's three-character floor, and on a contentless table every column reads back NULL, so LIKE matches nothing at all. Measured on 50 000 tracks, the index goes from 2.3 MB to 8.6 MB, and the migration's rebuild takes 0.23 s β€” which is why it runs at migration time rather than as a background backfill. Owning the content also lets the sync triggers be plain INSERT / UPDATE / DELETE instead of the contentless 'delete' idiom, which required handing back the exact indexed values and silently rotted the index if one was wrong.
  • remove_diacritics 2 becomes 1 β€” trigram accepts no other value. The difference is confined to combining marks used as letters in a few scripts; chateau still finds chΓ’teau.

Two routes. Which one a query needs is a property of the query, so it is decided in the portable waveflow_core::search; how each is then written β€” FTS5 phrase syntax, LIKE patterns, the column aliases β€” is SQLite's business and lives in repository::sqlite::search, behind the sqlite feature. A term shorter than three characters cannot be a trigram, and MATCH finds nothing for it β€” not less, nothing. That is not an edge case: most Chinese words are two characters, and short Latin names (U2, M83) would have regressed too. So a query carrying any such term takes a LIKE '%term%' route over the same three columns instead, with % and _ escaped so a query of % doesn't return the library. Measured on the same 50 000 tracks: MATCH ~0.2 ms (the index answers it), the LIKE scan ~26 ms. The fallback is therefore reserved for queries that would otherwise return nothing, and never taken by an ordinary word. It carries no rank, so its results are ordered by title.

What the scanned route gives up. SQLite's LIKE folds case for ASCII only, so a one- or two-character accented term no longer finds its capitalised form — ét misses Été indien, though Ét finds it. The old unicode61 tokenizer folded case and accents at every length, so this is the single thing #579 gave up. From three characters up nothing is lost: the index answers, and it was built with remove_diacritics 1, so ete and ÉTÉ both find Été indien. Closing the gap needs a folded column on track — the shape album and artist already have in canonical_title / canonical_name — which is a migration and a scanner change rather than a query tweak; it is the one part of #579 still open, now that pinyin has shipped (below). A test pins the current behaviour so it stays deliberate rather than drifting.

Pinyin (#579). The substring fix above makes a Chinese title reachable by its characters; this makes it reachable by its romanisation, which is what the request actually asked for -- typing zhongguo, or the initials zgr, without switching input method.

Pinyin cannot be derived in SQL, so it is computed in Rust and stored: scanner::canonical::pinyin_blob turns δΈ­ε›½δΊΊ into zhongguoren zgr -- the syllables, a space, then the initials -- and track.pinyin / album.pinyin / artist.pinyin hold one blob each. Five things make that hold together:

  • The blob is one column, not two. The syllables and the initials share it because a search term never contains a space (the planner splits on whitespace), so no query can match across the boundary -- and one column means one LIKE per entity rather than two.
  • It is written wherever canonical_* is written, which is why it lives in the same module: the scanner's upsert_artist / upsert_album, the track INSERT, and the tag editor's title patch. The editor matters more than it looks -- the backfill only ever looks at NULL, so a renamed title whose blob was left behind would stay wrong for the life of the library.
  • The index carries it too. track_fts gained a fourth column holding all three blobs concatenated, fed by triggers that only ever copy what Rust computed. So one MATCH covers text and pinyin alike, with ranking intact, in an index that cannot transliterate anything. The triggers watch pinyin as well as the text columns, or a backfilled row would stay unsearchable until something else touched its title.
  • NULL means "not computed", '' means "nothing to romanise". db::pinyin_backfill fills the rows that predate the column, once per profile database, and needs that distinction to terminate: most libraries hold no Han at all, and a sweep keyed on "the blob is empty" would re-read every row on every launch. It is a background writer, so it takes the shape the invariant requires -- park behind the scan, batch, retry on SQLITE_BUSY -- and giving up is safe because what it did not reach is still NULL.
  • Both scripts, and a known false positive. The mapping is by codepoint, so traditional characters are covered as well as simplified -- WaveFlow ships zh-CN and zh-TW. Japanese kanji romanise as if they were Chinese, so a Japanese title can be reached by a pinyin query that has nothing to do with it. That is an extra way to find a track, never a missing one, which answers the other half of the issue's question about the shipped CJK locales: the tokenizer change costs Japanese and Korean nothing.

Short initials land on the scanned route by construction -- zg is two characters, below the trigram floor -- which is why LIKE_TERM_CLAUSE grew from three columns to six.

The top-bar dropdown shows sectioned results β€” Artists / Albums / Titles (Spotify-style). FTS is track-scoped, so the album/artist sections come from dedicated search_albums / search_artists commands (browse.rs) that substring-match the query's canonical_name form against album.canonical_title / artist.canonical_name (prefix matches rank first) and return the same slim { artwork_base, items } shape as list_albums / list_artists. The three entities fan out in one Promise.all; clicking an artist or album row navigates to ArtistDetailView / AlbumDetailView (via the onNavigateToArtist / onNavigateToAlbum callbacks), while a title row plays the track. The advanced filter panel is track-only β€” when any advanced filter is active the album/artist sections are suppressed and only search_tracks_advanced runs.

Folder browsing

The Folders tab lists the configured roots, and used to stop there β€” which is folder management, not folder browsing. Someone who organises their music as Artist/Album/ on disk lost that structure entirely once the library was scanned: reachable by album, artist, genre or playlist, but not by where the files actually live (#578). Clicking a root now descends into it. The roots are cards in a grid (the folder's name in the lead, its full path underneath, its track count and last scan at the foot, its actions in the corner on hover β€” except the watch eye, which stays while watching is on, so a watched folder says so at a glance), and the whole card opens it (#733): at first only the path text did, with an underline on hover as its only sign, and a tester never found the browser at all. A click that starts on one of the card's own controls, or in its playlist popover, stays theirs; the name remains a button for the keyboard.

The tree is derived at query time from track.file_path. Nothing is materialised, and no migration was needed. Three commands in browse.rs:

Command Answers
browse_folders the directories directly inside a path, with recursive track counts and sizes, plus the breadcrumb's anchors (parent, root_path)
list_folder_tracks the files of one directory, in the Tracks tab's own row shape β€” recursive decides whether that means "directly inside" or "everything under"
folder_track_ids every track beneath a path, in path order: what play / queue / add-to-playlist / batch-tag act on

Four decisions carry the design:

  • A band, not a LIKE. folder_prefix_range returns [dir + separator, dir + separator+1), so the scan walks the UNIQUE (library_id, file_path) b-tree, Rock cannot match Rockabilly, and a folder named with a % in it needs no escaping β€” an ESCAPE clause would cost the index anyway. The offsets handed to substr / instr are character counts, not byte lengths, because that is what SQLite counts.
  • What a folder's counter counts: indexed tracks, recursively. Not files on disk. The tree is derived from what the scanner indexed, so a directory holding only skipped files never appears at all β€” which is why no row here can read "0 tracks", the failure the issue warned about. A parent folder's count includes everything below it, because that is the question a parent answers.
  • The cover is the first file's, by path. The children query selects artwork_hash as a bare column beside MIN(rest), which is a documented SQLite guarantee rather than an accident: with exactly one min() in the select list, bare columns take their values from the row that produced that minimum. folder_cover_comes_from_the_first_file pins it, so a SQLite upgrade that changed it would fail the build rather than quietly reshuffle covers.
  • The files render in the library's own track table. renderTrackTable in LibraryView is called with either the Tracks tab's rows or the folder's, so the folder view inherits the columns, the persisted sort (library_track_order_clause, the same helper), the context menu and the Properties modal instead of growing a second table that would drift from them. rows is also what selection and single-click play range over, so each caller's clicks act on its own list.

The browser itself renders only the directories β€” as cover tiles or as rows with names and sizes β€” plus a breadcrumb built from path against root_path (so arriving by any route gives the same trail), an explicit parent entry, and the folder actions: play, queue, play next, add to a playlist, create a playlist, and the batch tag editor scoped to the folder, which is usually exactly the selection someone wants to act on. A listing is stamped with the path it was read for, and one whose stamp does not match the folder on screen counts as absent rather than as approximate β€” the same rule every other keyed fetch here follows.

Folder management

commands/library.rs exposes the watch-folder lifecycle: add_folder_to_library, set_folder_watched (toggle the in-memory notify watcher), and remove_folder_from_library. The remove path detaches the watcher, deletes every track that lived under the folder, then drops the library_folder row in a single transaction. The schema's track.folder_id ON DELETE SET NULL would otherwise leave orphan tracks with library_id still set β€” making the user "remove" a folder while its tracks stayed in the library, which never matches what they expect.

UI: per-folder trash button in the Library β†’ Folders tab, two-step confirm-on-second-click that auto-clears after 3 s.

Library folders never overlap. add_folder_to_library refuses a folder that is, lies under or holds a folder already in any library of the profile, with AppError::FolderOverlap (why). The comparison is by name, as paths are stored (scanner::folders: relation, overlap, and indexing_folder for the drop): . and .. resolved, a trailing separator ignored, case ignored on Windows and macOS, links not followed. The frontend reads the folder in the way from the message (parseFolderOverlap) and ImportNoticeToast says it, once, from LibraryContext.importFolder, so the four places that add a folder (sidebar, Home, Library, onboarding) are covered without each handling it.

Drag-and-drop import

hooks/useDragDropImport.ts wires Tauri 2's window-level onDragDropEvent to commands/library.rs::import_dropped, with the rules in drop_import.

A drop is copied into a folder WaveFlow manages, never registered where it lies. It used to make the folder the files came from a library folder: dropping one file from Music added all of Music, thousands of tracks nobody asked for, over the folders already in the library. Now the dropped files are copied into WaveFlow under the user's music folder (dirs::audio_dir, else ~/Music; the Flatpak has xdg-music read-write), and that folder is the library folder. The originals stay where they were.

  • As they are. A dropped folder keeps its name and its tree under WaveFlow, bringing its audio files and the sidecars the app reads (cover images in every format the scanner takes one from, .lrc, .ttml); a dropped file goes to the root, with its same-name lyrics. A .lrc or .ttml beside an audio file of the same name follows it under the name the audio ends up with, so a numbered copy keeps its own lyrics, and lyrics already at that name are kept (they may have been edited). Nothing is renamed from its tags. The .waveflow branch is skipped, as the scanner skips it.
  • Already in the library is passed over. An item under a library folder, WaveFlow included, has nothing to copy. A folder that holds a library folder is refused: copying it would duplicate that folder's tracks, and dropping the music folder that holds WaveFlow would copy it into itself.
  • Copied once. A file at the target with the same name and size is taken as already copied; one with the same name and another size gets name (2).ext. Each copy is written as <name>.waveflow-part and renamed when complete, so neither the watcher nor a scan indexes half a file. Choosing the target name and the rename that completes the copy happen under one process-wide lock, so two drops running at once take turns file by file instead of both claiming the same free name. Files already there are still scanned: they may not be indexed (the folder was removed from the library, then the same files dropped again), and a scan passes over unchanged files cheaply.
  • Indexed by one folder. The copies are scanned by WaveFlow's own row, else by the deepest library folder holding it (a library that is the whole music folder already covers it), else WaveFlow is added to the library being imported into. A library folder inside WaveFlow is an overlap the drop cannot resolve, refused before a byte is copied.
  • Bounded and stoppable. At most 20 000 files per drop (the lyrics that follow an audio file ride along) and 200 000 entries walked; past that the drop is cut short and says so. The drop_import task is announced before the walk, which can take a while on a large folder, so the status bar shows it and its flag stops the walk as well as the copy, between two files; what was copied is still scanned. An entry the walk cannot read is counted as an error rather than skipped in silence.

What the drop did comes back as a DropImportSummary (copied, already copied, in the library, refused, failed, cut, stopped, and the scan), which ImportNoticeToast shows at the bottom centre β€” apart from the scan's own card at the bottom right. A notice with something to look at (a refusal, a failure, a cut, nothing copied) stays until dismissed.

Auto-creates a default library on the very first drop when the profile has none, mirroring the existing pickFolder import path.

A drop has two targets. While files are dragged over the window, DropTargetsOverlay shows Play now and Add to the library side by side; only the second imports. The first plays the files without importing them (Playing files without importing them). Tauri reports the pointer in physical pixels from the webview's corner, so the hook divides by devicePixelRatio and tests the point against the targets' own boxes, which the overlay registers in a ref: a right-to-left layout that mirrors them needs no special case, and a point in the gap between them goes to the nearer one. The drop goes to the target under the pointer at the drop, else the last one hovered.

The overlay has pointer-events: none, so the drop still reaches Tauri's native handler, and goes away on the drop: an import's scan reports in the task status bar like any other scan, where it can be followed and stopped, instead of behind a screen that hid the app for as long as it ran β€” minutes for a whole music folder. Its base styling works in every skin; the skins on the fluid layout (Fluid, Liquid) recompose it through data-drop-* landmarks in their own material (surface cards, the accent ring and the wave mark under the target being hovered).

Track-list columns

Which columns the track table shows, in what order and how wide, per profile in profile_setting['ui.track_columns'] (#588). One preference for every list that renders the shared table β€” the Tracks tab, the folder browser, the inventory β€” so the three cannot drift. Model in src/lib/trackColumns.ts, header in TrackTableHeader, picker in ColumnPicker.

The row's chrome is not a column. The index / playing indicator, the artwork thumbnail, the like heart and the overflow menu stay pinned at the two ends: none carries a value, none sorts, and hiding the overflow menu would remove the only way to reach half the row's actions.

Five things that are easy to get wrong here, and are not:

  • Names sort under FOLD, not NOCASE. SQLite's NOCASE folds ASCII only, and the scanner's canonical_name / canonical_title keep their accents, so "Γ‰milie" sorted after "Zazie" while the A-Z rail filed her under E. collation.rs defines a FOLD collation that sets case and accents aside (every decomposable accent through NFD, plus the letters NFD leaves whole, so a mirrored row's normalize_name key sorts next to a local one that kept its accents; only then are ties broken on the lowercased and the original strings, so two distinct keys never compare equal), then breaks ties on case and bytes so the order stays total. The profile pool registers it on every connection (collation::register in profile_db.rs); a query naming it on a connection opened any other way fails to prepare, so a test pool that runs a listing registers it too. Paths, codecs, keys and the folder browser's grouping keep NOCASE.
  • Artists and albums sort under NAME, which can set a leading article aside. Settings β†’ Library β†’ "Ignore articles when sorting" (on by default, app_setting['library.sort_ignore_articles']) files "The Beatles" under B and "L'ImpΓ©ratrice" under I. NAME is FOLD on the names without their article, then on the full names, so "Kills" and "The Kills" keep a total order; off, it is FOLD. It covers ar.canonical_name, al.canonical_title and the sort_artist / sort_album / sort_name / sort_title aliases built from them; track titles, genres and folders keep FOLD. The articles are the definite ones of the shipped languages that write them as a word (the le la les l der die das het el los las il lo gli; Dutch de is left out, being as often a particle of a name β€” "De La Soul" β€” as an article), English's a / an, and the elided l. A name that is only an article, or whose article runs into the next word ("Theory"), stays whole.
    • One flag, read on every comparison. The setting lives in a process-wide atomic in collation.rs, loaded at boot before the first listing and set by set_sort_ignore_articles after the row is saved. No index is built on the collation, so flipping it reorders the next listing and changes nothing stored. useSortIgnoreArticles adds it to LibraryView's fetch signature so every tab refetches, and AlphabetIndex files a name under its letter with sortName.ts, the TypeScript copy of strip_article, applied to the same canonical key the listings sort (canonical_name turns punctuation into spaces, so the elided l is a word and "A-ha", stored a ha, files under H) β€” keep the two lists in step, or the rail jumps to the wrong row.
  • Sorting happens in SQL, over a whitelist. browse::track_sort_expr maps a column id to an expression; anything unmatched falls back to the default clause, which is the only reason the caller may hand the result to AssertSqlSafe. Nullable columns put their NULLs last in both directions β€” an untagged track is not a track with the smallest value β€” and every sort carries a tie-break so two identical queries cannot return two different orders. The previous shape spelled out two match arms per column and had quietly stopped at seven of them.
  • The resize handle lives outside the sort button. Inside it, a press to widen the column fires the sort on release, so every resize reorders the list.
  • "Fit to content" measures through a canvas, not the DOM. The rows are virtualised, so the ones outside the viewport have no computed layout and a DOM measurement would size the column to whatever happened to be on screen. The header label is measured too, in the header's own (uppercase, bold, letter-spaced) font: a column fitted to short content otherwise shows a truncated title, which reads as the fit having failed.
  • The title column is flexible and has a floor, and cannot be removed. It is the only one that takes leftover space, so without a minmax() floor it absorbs every pixel the others take and disappears once a few columns are added; and a table of metadata about songs it does not name is reachable by unticking one box, so sanitizeLayout puts it back.
  • The header is sticky at -top-8: the TopBar sits outside the page scroller, so the offset cancels the scroller's p-8 and the header docks right under the TopBar (top-16 left a 96px band that rows scrolled through). The table's overflow-hidden had to go with it: it clips a sticky descendant against its own rounded corners, and the header then stops sticking at all.
  • Columns that do not fit are hidden, not squeezed (#669). The table measures the width its container gives it and fitLayoutToWidth drops columns from the right end of the user's order until the rest fit at their floors (trackMinWidthFor), counting the index, thumbnail, heart and actions, the gaps, the padding and the border. The order the user chose is the priority, and the title is never dropped. Only the render changes: the saved layout, the column picker and the widths are untouched, so a column hidden while the right panel is open comes back in place when it closes.
  • The table is never narrower than what it shows. The frame gets a min-width summing the shown tracks' floors, gaps and padding. After fitting, that only bites when the title and the chrome alone exceed the view; the page then scrolls sideways rather than letting the grid spill out of the frame, which left the background, border and header stopping partway along the rows.

Columns from the user's own tags

An id of the form tag:<key> shows a frame from the user's files. Keys are offered from what the library actually holds, with a count each (commands/track_tags.rs) β€” the theoretical list of frames each format allows would bury the five useful ones under thirty-five nobody has.

Reading those tags needs a second parse of the file, and this is the part the issue mispredicted. It assumed the reading already existed because edit.rs goes through the concrete tag type per format so non-standard frames survive a save β€” true of the writer. The scanner reads through lofty::read_from_path, which hands back generic Tags produced by splitting each concrete tag: ItemKey has no Unknown variant, and the remainder is stashed in a pub(crate) companion slot (and for Vorbis comments not kept at all). The custom frames are therefore simply absent from what the scanner parsed.

waveflow_core::scanner::extra_tags opens the concrete container and reads both halves of the split: the remainder β€” TXXX descriptions for ID3v2, comment names for Vorbis, freeform atoms for MP4, items for APE β€” and the generic half minus the fields WaveFlow already stores. The second half is not optional: Composer has an ItemKey, so it never reaches the remainder, and a version that read only the remainder missed the very field the issue opens with. Keys are named through map_key(TagType::VorbisComments) so the same field reads as one column whatever container it came from, and Vorbis names are upper-cased before deduplication because they are case-insensitive by spec β€” ID3v2 TXXX descriptions are not. Consequences worth knowing:

  • A rescan is needed before a tag column has anything to show: values land in track_tag at scan time. A library that predates the feature has files whose mtime and size have not moved, so the fast path would skip every one of them β€” the first scan of each folder after the upgrade therefore forces a read, marked by profile_setting['scan.tag_backfill_done.<folder_id>'] so it never repeats. Unlike the ReplayGain backfill beside it, this one cannot narrow to the affected rows: a track with no custom tags has no track_tag rows either, so "has none" and "was never read" are the same shape and only the marker separates them. A cancelled scan does not write it.
  • The cost is one extra tag parse per file, over the header region the first parse has just pulled into the page cache. It is timed separately as extra_tag_cpu_ms_total in the scan log, on purpose: it is the one cost this feature adds to every scan, and burying it in the total would make it impossible to answer whether it is worth paying.
  • Writes are delete-then-insert, so a tag the user removed from a file disappears from the column too; an upsert would leave it behind for good.
  • A tag column does not sort. Its values live in a side table the listing query does not join, and a control that silently does nothing is worse than no control.
  • Keys WaveFlow already models (REPLAYGAIN_*, MUSICBRAINZ*, lyrics, cue sheets) are skipped: a column for one of those would show a stale copy of something the library models properly.

"Needs attention" inventory

commands/inventory.rs, surfaced as the library's last tab (#589). Fourteen counted categories you click into β€” six "missing field" checks, one for formats we cannot write tags into, probable fake lossless files (above), four album-level inconsistencies, probable duplicates, and artists to split.

It is an entry point, not a report. Every category is an extra WHERE on browse::library_tracks_sql_where, the same query the Tracks tab and the folder browser render, so clicking one loads those tracks into the library's own table β€” same columns, same sort, same context menu, same properties modal. Fixing a track from the inventory is therefore the ordinary edit flow, not a second one.

Five things the implementation has to get right:

  • "Missing title" needs a column, not a WHERE. The scanner substitutes the file stem when a file carries no title tag, which is right for display and erases the difference the category is asked for β€” leaving trim(title) = '' able to find only the rarer fault, a title tag present and empty. track.title_from_filename records it at scan time. It cannot be worked out afterwards by comparing the title to the path: every correctly tagged Song.mp3 would come back untitled. Rows written before the column exists default to 0, so the count is short until each folder is scanned again β€” the same caveat the custom-tag columns carry.
  • This tab does not prefetch. The six other tabs with a query of their own (Playlists reads PlaylistContext) fire them in parallel on mount; these counts are album-level aggregates plus a walk over every track, so they run when the tab is opened. They re-run after a tag edit, because a count that does not move reads as the fix not having worked.
  • Album identity is (canonical_title, album_artist_id). The album-level checks group on album.id, which is that pair. Note the column holding the album artist is album.artist_id β€” there is no album_artist_id on the table, and the album.album_artist beside it is free text rather than the link.
  • "Gap in the numbering" measures holes inside the observed range, MAX - MIN + 1 > COUNT(DISTINCT). The obvious spelling, MAX > COUNT(DISTINCT), assumes every disc starts at 1 and reports a box set's third disc numbered 20-22 as missing nineteen tracks. The cost is that a missing first or last track is invisible β€” numbering alone cannot see it β€” which needs album.total_tracks and is left out rather than half-implemented.
  • Probable duplicates chain, they do not compare pairs. SQL groups by exact normalized title + credit list; waveflow_core::inventory::chain_by_duration then chains sorted durations in Rust. 180 s, 181.5 s and 183 s are one recording, but the outer pair is 3 s apart, so a pairwise tolerance splits them β€” and which two it splits into depends on iteration order, so the same library answers differently between runs. Chaining is transitive by construction, which is the right trade for a list a human reviews.

Artists to split (#719) is the one category made of artists, not tracks. A comma-joined credit (Ice Spice, Central Cee) is one phantom credited on many tracks, all needing the same single fix, so it is listed once per artist, with the names the split would produce and the split inline β€” a second click confirms it, since it relinks every track. The list uses the split's own split_fragments, so it never offers what the split would not do. Two decisions, both measured:

  • Every comma name is listed, not only those whose fragments already exist as artists. That stronger signal was the obvious one, and a real library ruled it out: 18 of its 39 comma names had no fragment in the library at all, and every one was a genuine duo. Fragments that do exist are marked (they are the rows the split reuses) and sort a name up β€” evidence, not a gate.
  • "Comma" means four characters (#851): the ASCII ,, and the ones a Chinese or Japanese credit is written with β€” the ideographic enumeration comma 、 (the usual mark between two names, 庄心妍、η₯ιš†), its halfwidth form ο½€, and the fullwidth , a CJK keyboard types for a comma. NAME_SEPARATORS in artist_split.rs is the one list; the inventory's SQL filter and the editor's split preview spell the same set. With the ASCII comma alone, a Chinese library's duos never reached the list, and neither did a tag the user rewrote by hand with their keyboard's comma.
  • A comma is still only a hint (Tyler, The Creator), so each row offers "don't split", stored per profile in profile_setting['inventory.dismissed_phantoms'] by canonical name, so a rescan that recreates the row does not bring it back.

This is not the same question as duplicate detection below, which hashes content and finds byte-identical copies. Probable duplicates are for the re-rips and re-encodes content hashing can never group; the two are complementary.

Duplicate detection

commands/duplicates.rs::find_duplicates surfaces byte-identical copies in different folders regardless of metadata: it prefilters candidates by file_size, then groups them with a full-content BLAKE3 hash (scanner::hash_file_full) β€” not by the scan-time file_hash directly. The scan-time hash is partial β€” file size + first 1 MiB + last 1 MiB (scanner::hash_file) rather than every byte β€” because full-file hashing was the dominant scan cost (reading ~9 GB to scan 900 tracks). Byte-identical copies still collide (same bytes β†’ same digest), and a tag rewrite still changes the digest (ID3v2 head / ID3v1Β·APE tail sit inside the window). Because a partial digest could in theory collide on the unread middle bytes β€” and because a legacy full hash and a newer partial hash for the same file never match (both are 64-char blake3 hex, indistinguishable) β€” find_duplicates prefilters candidates by byte size (a format-stable field every real duplicate shares) and then re-verifies each candidate with a full-content hash (scanner::hash_file_full) β€” computed off-thread, only on the handful of same-size files β€” before returning a group. The move to the trash therefore only ever sees byte-identical files, regardless of when each row was scanned. Re-encodes of the same source β€” e.g. CBR vs VBR rips β€” won't match because the bytes differ; that's a fingerprinting problem and out of scope for the MVP.

find_duplicates returns one entry per group, ordered by added_at ASC so the oldest copy renders first (usually the one to keep).

The copies go to the system's trash, then lose their rows (move_tracks_to_trash, #878). Removing the rows alone, as delete_tracks did, undid itself: the scanner adds any audio file that has no row, so the next rescan or the folder watcher brought every copy back β€” as a new track, without the playlists, likes and history the old row had carried away through ON DELETE CASCADE. The files go first, and only a copy whose file actually left loses its row, so a copy that stays on disk never loses its row. The one way the library can list a file that left is a row whose removal failed after the move; that copy is reported apart (in the trash, still listed until the next scan of its folder). Each row goes in a transaction of its own, since the files are already in the trash by then: a row that cannot be removed is reported apart (in the trash, still listed until the next scan) and does not take the others' with it. A refusal keeps its row and is reported with its reason. The checks run on the trash thread right before each move, not when the user clicked, since the confirmation can sit open for minutes: the track playing now is never moved (on Windows its file is held open, and pulling it from under the player was not the question), and a copy only goes while the copy kept for it is still there, still byte-identical (full-content BLAKE3, the kept file read again before every copy it vouches for, since in a group of three it can change between two moves) and not the same file reached by another path (a mapped drive and its UNC path, a bind mount, another case on a case-insensitive volume), which same-file compares by volume and file index or device and inode, where canonicalize would see two paths. Otherwise the copy about to go could be the last one. A file that cannot be found is refused and keeps its row: from inside this command a file deleted by hand and an unplugged drive look the same β€” even the folder can still be there, as the empty mount point of a library at a volume's root β€” so the next scan of the folder, which can tell, decides. The watcher sees the files leave and rescans the folder; whichever of that scan and this transaction runs first removes the rows, and the other finds them gone.

trash.rs has three routes. Windows uses the Recycle Bin through the trash crate. macOS uses NSFileManager rather than the crate's default, which scripts Finder and makes macOS ask the user to let WaveFlow control it. Linux uses the freedesktop trash, except inside Flatpak: the sandbox sees ~/Music and not the user's trash, and $XDG_DATA_HOME points into the app's own data, so the crate would have trashed files into a folder no file manager shows. There the org.freedesktop.portal.Trash portal moves the file, from a descriptor, on the host's side. The work runs on a thread of its own, because the crate initialises COM in apartment mode and panics on a thread that already chose another mode, as a pooled thread that served WASAPI may have.

UI: DuplicatesModal launched from Settings β†’ Library β†’ Import and scanning β†’ "Detect duplicates". Each group exposes a radio selector (defaults to oldest). The footer's "Move N duplicates to the trash" first lists every file it is about to move and asks; the outcome β€” how many moved, and each one that did not, with its reason β€” stays at the top of the window rather than in a toast that fades.

Replacing a file with a better copy

A re-rip or a lossless copy dropped into the library arrives under a new path, so the scanner makes it a new track β€” and the old one keeps the plays, the likes and the playlists. Replace file… in a local track's context menu swaps the file under the existing row instead (commands/replace.rs, ReplaceFileModal): the track id, and everything hanging off it, stays. It is the answer to a fake lossless file once the real one is found.

The file picker opens first; the dialog then shows the current file against the chosen one β€” format, bit depth, rate, length β€” and where the new file will be saved: the old one's folder and name, with its own extension (.mp3 β†’ .flac). It is refused when the track is playing (its file is open, and swapping it under the player is never what was asked), when the chosen file is the track's own (by file identity, so a second path to it counts), when the player cannot decode it, when another file or row already holds the target name, and when the lengths differ by more than the larger of 1% of the current length and 2 s β€” encoder padding moves a length by tens of milliseconds, a different edit by seconds. The backend checks all of it again on confirm.

The order makes every step but the last undoable:

  1. the new file is copied (the user's original is never moved) into the folder's reserved .waveflow/ directory β€” same volume, so what follows are renames, and invisible to the scanner and the watcher;
  2. the old file moves aside, into .waveflow/ as well;
  3. the row is pointed at the target before the copy is renamed into place, with file_modified = 0 so the scan cannot skip it on a kept timestamp β€” a scan running meanwhile then finds a row for the new file, where the other order would let it add the file as a new track; a scan already running is waited out first;
  4. the folder is rescanned, which keys rows by path and so re-reads the new file's tags and format into the same row; once the row holds the new file, its analysis is dropped, since it described the old one (a fake-lossless verdict above all) β€” after the check rather than before, so a rollback keeps it;
  5. only then does the old file go to the system's trash.

A failure before step 5 puts the old file and the row back and rescans, and says where a file is left if a step of the rollback fails too; a file that appeared at the target meanwhile is never overwritten nor removed. A trash that refuses (a share with none) leaves the old file under .waveflow/replaced/ and says where. The new file's own tags win: the scan reads them as for any file.

The better copy is often in the library already β€” a FLAC downloaded next to the MP3, scanned as a track of its own, under the very name the replacement would take. It is then adopted where it is, under its exact path (on a case-insensitive volume Song.FLAC and Song.flac are one file, and the row keeps the scanner's spelling): no copy, and its own row, with its few minutes of history, is deleted in the same transaction that points the track at it. It is refused while that row is the one playing. An undo leaves the user's file in place, and the rescan lists it again as the track it was.

Cover picker

commands/deezer.rs::set_album_artwork_from_deezer and set_album_artwork_from_file. The file picker validates magic bytes (JPEG / PNG / WebP) before accepting upload, and batch_fetch_missing_album_covers walks all albums without an artwork_id, querying Deezer in parallel with a small concurrency cap.

Local artist images

Scanner sidecar lookup, mirror of the folder-cover fallback but resolved against the track's ancestors instead of the immediate parent.

commands/scan.rs::extract_artist_image walks up to 3 parent directories from each track and accepts the first match where either:

  • the filename stem is one of ARTIST_IMAGE_STEMS = ["artist", "performer", "band"], or
  • the stem's canonical_name(...) equals the artist's canonical name (covers Daft Punk.jpg at the root of a Daft Punk/ folder).

Both common layouts from issue #31 work out of the box:

  • Music/<Artist>/<Album>/track.flac β†’ matches artist.jpg two levels up.
  • Music/<Album>/track.flac β†’ matches <Artist>.jpg sitting beside the album folder (strict name-match so an unrelated cover.jpg is never mistaken for an artist photo).

Hash-addressed via BLAKE3 into the shared artwork/<hash>.{jpg,png,webp,…} cache and linked through the existing artist.artwork_id β†’ artwork foreign key (no schema change). The UPDATE … WHERE artwork_id IS NULL guard means scanner runs never overwrite a manually uploaded image or a previously cached Deezer picture.

Resolution priority in commands/browse.rs::get_artist_detail is now: local sidecar β†’ Deezer cache β†’ live Deezer fetch (last skipped when offline). ArtistDetailView prefers artwork_path over picture_path and refuses to clobber a local image with a late-arriving Deezer response.

The "Various Artists" sentinel is skipped by the per-track pass because it's an album artist β€” it's written to album.artist_id (never to track_artist), so the per-track join can't reach it. It's handled separately by scanner::link_va_artist_image, which resolves a curated Various Artists/artist.jpg (or Various Artists.jpg) via the album relationship (issue #292). Because extract_artist_image only matches an explicit artist-named sidecar β€” never a generic cover.jpg / folder.jpg β€” VA still never inherits a stray album cover. The helper runs at the end of every scan (after merge_implicit_compilations) and inside the rescan below.

For libraries scanned before the feature shipped, commands/scan.rs::rescan_local_artist_images (exposed as Settings β†’ Images and lyrics β†’ Covers and artist images β†’ Local artist images) walks every artist WHERE artwork_id IS NULL and probes up to 16 tracks per artist with extract_artist_image, stopping at the first hit (plus a dedicated VA pass via the album relationship). Already-linked rows are filtered out at the SQL level, so the rescan is cheap to re-run.

Manual override

The pencil overlay on the artist photo in ArtistDetailView opens ArtistImagePickerModal, which exposes three actions backed by commands/deezer.rs:

  • Search Deezer β†’ search_artists_deezer + set_artist_artwork_from_deezer (downloads the chosen picture into the profile artwork cache, marks source "deezer", and writes artist.deezer_id).
  • Pick a local file β†’ set_artist_artwork_from_file (same magic-byte validation as the album cover picker: jpg / png / webp).
  • Remove image β†’ clear_artist_artwork sets artist.artwork_id = NULL so the next render falls back through the resolution chain (Deezer cache β†’ live fetch).

Both set_artist_artwork_from_* overwrite artwork_id unconditionally β€” an explicit user pick beats any automatic resolution.

The Deezer pick re-links the artist, not just the photo (issue #692). The picker lists every Deezer artist matching the name with its fan count, so it is the one place a user can say which artist this is β€” and artist.deezer_id is what every other surface resolves through: the fan count, the bio, the wide fanart and similar artists all join app.metadata_artist on it. Writing only the picture left the page showing one artist's photo above another artist's numbers. The id and the artwork are written in one statement, and the hand-picked id then sticks: the automatic link in enrich_artist_deezer_inner only fills the column when it is NULL, so no later enrichment pass can undo the choice. Picking a local file deliberately does not re-link β€” a file on disk says nothing about which Deezer artist this is.

All three commands emit artist:updated with the artist id. Without it only the page the picker was opened from refreshed: the now-playing panel and the cover slideshow resolve a photo once per artist id and, while the same track keeps playing, had no reason to look again. useArtistUpdated is the listener side, the sibling of useTrackUpdated.

Wide artist fanart (hero)

Everything above carries the square artist photo. The artist hero needs a wide one, and the only source in the stack that has one is TheAudioDB (issue #482).

metadata::theaudiodb already queried search.php for multi-language bios; the same response carries strArtistFanart (+ 2/3/4), strArtistWideThumb and strArtistBanner. TheAudioDbClient::artist_info returns bio and fanart_urls from one lookup. It returns Some for any name match even with neither bio nor fanart, so the caller can cache the "looked, nothing there" outcome; a failure status is an Err, never that outcome.

All four fanarts come back, and the strips no longer do (issue #693). The client used to fall back to strArtistWideThumb and strArtistBanner when an artist had no fanart; both are 1000Γ—185 strips by TheAudioDB's own spec, and the hero they were feeding is routinely wider than 1000 px and far taller than 185 px relative to its width β€” so they arrived upscaled and cropped to a sliver, which is what "the backdrop is blurry" looked like. They are not deserialized at all now; an artist with no fanart falls through to the blurred-photo tier, which is honest about being an ambient colour field rather than a picture. The fanarts themselves are kept all four, because they differ enormously in framing and the picker below lets the user choose.

enrich_artist_deezer calls it independently of the metadata.bio_source setting: Last.fm has no equivalent image, so gating the fanart on the bio source would leave every Last.fm user with no hero at all. One request serves both consumers (the bio half is used only when TheAudioDB is the selected source) β€” TheAudioDB's shared free key is rate-limited, so it's one call, cached hard. The URL is downloaded through the usual metadata_artwork::download_and_cache (BLAKE3-addressed, shared across profiles) and kept at full resolution β€” no _1x / _2x tier, downscaling a full-bleed banner would only soften it. Offline mode short-circuits before any of this, and the blurred-photo tier still works.

Cached in app.metadata_artist next to the picture pair: background_url + background_hash (the one in use), background_urls (the whole candidate list, JSON, migration 20260920120000_metadata_artist_background_candidates.sql), plus background_fetched_at β€” the "we already looked" marker (migration 20260802120000_metadata_artist_background.sql). Without it a NULL hash can't be told apart from "never queried", so every artist without fanart would re-hit a rate-limited API on each page visit. It is stamped whenever the API was reached (match or not) and left NULL on a transport error, so a network blip retries instead of caching as "this artist has no fanart" for the row's whole 30-day TTL. Rows written before the migration have NULL there and are treated as a background cache miss on their next refresh, which backfills them once.

Both get_artist_detail (first paint, straight from the cache) and enrich_artist_deezer (refresh) return background_url / background_path.

Choosing the banner

TheAudioDB's first fanart is a guess, and the hero is the biggest thing on the page β€” so the banner has a picker of its own (ArtistBackdropPickerModal, opened from the button in the hero's top-right corner, issue #693). Two tabs, shaped like the photo picker: the artist's own fanarts, and a local file. There is nothing to search β€” the candidates are already cached β€” and each is shown in the hero's own 16:6 proportions, cropped the way the hero crops, so what the thumbnail cuts off is what the header will cut off.

  • get_artist_backdrop_candidates reads background_urls through artist.deezer_id.
  • set_artist_background_from_url / set_artist_background_from_file download or copy the image into the profile artwork dir β€” not the shared metadata cache, which a prune sweeps by metadata_artist reference β€” and point artist.background_artwork_id at the artwork row (migration 20260920120000_artist_background_artwork.sql).
  • The chosen image is registered with artwork.source = 'manual', whichever provider offered it: a suggestion the user picked is a manual pick. It was written as 'theaudiodb', a value the column's CHECK refuses, so every suggestion failed while a local file, written as 'manual', worked (#750). The source is now an ArtworkSource enum whose variants are exactly the constraint's values, so a new one does not compile until the constraint has it β€” see the invariant for why the constraint cannot simply grow.
  • clear_artist_background goes back to the automatic choice. "No banner at all" is a different thing and already exists: the per-profile Artist banner toggle in Settings.

A chosen banner wins over the cached fanart in both paths that resolve it β€” get_artist_detail, which paints the first frame, and enrich_artist_deezer, which the artist page applies a moment later. Resolving it in only one of them would let the automatic one win the race. All three commands emit artist:updated.