Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d80b78f
feat(radio, tags): rank mood radio by fit, and fetch album tags for r…
InstaZDLL Sep 15, 2026
6779a82
fix(radio, tags): four findings from the local review
InstaZDLL Sep 15, 2026
f6ea9f9
refactor(radio): one spelling of the tempo gate, and tests that run t…
InstaZDLL Sep 15, 2026
b3a6f3a
fix(radio): a guess must not take a pool slot from a measured tempo
InstaZDLL Sep 15, 2026
b3aa3c7
test(radio): the library table has no path column
InstaZDLL Sep 15, 2026
22e2626
fix(tags): a write in flight cannot be dismissed, and both reads are …
InstaZDLL Sep 15, 2026
7005743
test(tags): artist and album carry a canonical name, and it is NOT NULL
InstaZDLL Sep 15, 2026
be2b925
fix(tags): route the two remaining close paths through the busy guard
InstaZDLL Sep 15, 2026
f1a9e12
fix(radio, tags): a fetch must not lock the modal, and the coverage l…
InstaZDLL Sep 16, 2026
735bb74
fix(tags): retire the fetch token when the modal closes
InstaZDLL Sep 16, 2026
b6a5a79
fix(tags): going back drops the error with the release it belonged to
InstaZDLL Sep 16, 2026
deb7b70
fix(tags): a search cancelled by closing left the reopened modal loading
InstaZDLL Sep 16, 2026
1a1535d
fix(tags): the disc separates two tracks numbered the same, and both …
InstaZDLL Sep 16, 2026
26cd94e
fix(tags): a write publishes only into the session that started it
InstaZDLL Sep 16, 2026
193a7a8
fix(tags): the toggles decide from prev and say their state, and a wr…
InstaZDLL Sep 16, 2026
7a3c713
fix(locales): the german hint was missing the noun it selects
InstaZDLL Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docs/features/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ A single global toggle — Settings → Intégrations → "Mode hors-ligne" —
- Artist pictures (`enrich_artist_deezer`, `batch_fetch_missing_artist_pictures`)
- Album covers (`enrich_album_deezer`, `search_albums_deezer`, `set_album_artwork_from_deezer`, `batch_fetch_missing_album_covers`)
- Label / fan-count metadata
- Fetching an album's tags for review (#599, below)

**Deezer refuses with HTTP 200.** A rate-limited or rejected call answers `{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}` with a 200 status, so the response type has a `DeezerError::Api` arm that reads the object instead of letting the missing `data` field surface as a decode failure. Before that, a throttled client and an artist Deezer had never heard of produced the same log line and the same empty result — which is the fog #406 was diagnosed through; the reason now reaches the log the enrichment paths already write. A 429 from the edge in front of the API carries no JSON to read at all and gets its own `RateLimited` arm.

Expand Down Expand Up @@ -237,3 +238,72 @@ The row UI shows each word as a chip — pink for captured, green-ringed for the
- Plain / LRC / Enhanced LRC → `ItemKey::UnsyncLyrics` (USLT for ID3v2, UNSYNCEDLYRICS for Vorbis, `©lyr` for MP4) — unchanged.
- TTML on Vorbis / MP4 / FLAC → `ItemKey::Lyrics` (the XML-friendly key).
- TTML on MP3 — **skipped**. lofty has no clean ID3v2 mapping for arbitrary XML lyrics, so the file is left untouched, the DB cache still gets the TTML content, and `save_lyrics` returns `tag_write_skipped: true`. The editor surfaces this as a `lyrics.toast.tagWriteSkipped` warning so the user knows the file itself wasn't touched.

## Fetching an album's tags (#599)

"These tags are wrong, fetch them and let me approve the result" —
[`commands/tag_fetch.rs`](../../src-tauri/crates/app/src/commands/tag_fetch.rs)
with the matching in
[`waveflow_core::metadata::album_match`](../../src-tauri/crates/core/src/metadata/album_match.rs),
reviewed in
[`TagFetchModal`](../../src/components/common/TagFetchModal.tsx).

Two steps, deliberately. `search_album_tag_sources` offers the
catalogue releases that might be this record and the user picks one,
because a title and an artist match several releases of the same album
— an original, a remaster, a deluxe edition with four more tracks — and
they carry different track lists. `fetch_album_tag_proposals` then
pairs the chosen release's tracks with the local files.

### Matching the tracks

Matching the album is the easy half. Inside it, three weighted signals:
**title 0.60, duration 0.25, track number 0.15**. Unequal on purpose —
the title carries most of the identity, the duration confirms it, and
the track number is corroboration from a field that is wrong often
enough to trust least.

- **Missing data scores 0.5, not 0.** A track with no number is not
evidence *against* a match; scoring it zero would push every untagged
file below the threshold and make the feature useless on exactly the
libraries that need it.
- **Assignment is global and greedy**, each side consumed once. Asking
"what is the best remote track for this local one" lets a generic
title — "Intro", "Interlude" — win against several local files at
once and capture one that belonged to another.
- **Two thresholds**, both inclusive: confident at or above 0.85,
doubtful at or above 0.55, nothing below. The middle band is the point — it is what the review
screen exists to resolve, and it is why confident matches arrive
pre-accepted and doubtful ones do not.

Titles are compared over
[`name_match::normalize_name`](../../src-tauri/crates/core/src/metadata/name_match.rs),
the normaliser the metadata providers already share: it folds NFD
combining marks, so a library tagged `Bjo\u{308}rk` matches a
catalogue's `Björk`. A transliteration table written for this feature
would not, and accented titles are not an edge case in a music library.

### What is not offered

**No composer and no track-level genre** — Deezer does not carry them,
and a review screen listing a field the source cannot fill invites
accepting a blank over something the user typed. **No disc number**
either: the API returns one, but it is unreliable on box sets, which is
precisely where the local value is usually right.

### Nothing here writes

No command in the module touches a file or a row. What the review
screen accepts is applied through `update_track_tags`, one track at a
time — the path that pauses playback before opening the file, writes
through the concrete tag so non-standard frames survive, re-hashes into
`track.file_hash` and relinks the album and artist rows. Writing across
a whole album is exactly where a second, simpler write path would turn
one bad moment into a folder in an unknown state, which is why #599
waited on #598.

Only the accepted fields are sent: `update_track_tags` leaves an
omitted field alone, which is what makes "accept this one value" mean
that and nothing more. A track whose write fails is counted and the
rest still run — stopping halfway through an album leaves a folder
nobody can describe.
69 changes: 69 additions & 0 deletions docs/features/smart-playlists.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,72 @@ Wired into [`SmartPlaylistEditorModal`](../../src/components/common/SmartPlaylis
[`describeRules`](../../src/lib/smartRuleSummary.ts) renders a rule tree as a sentence, shown by [`SmartRuleSummary`](../../src/components/common/SmartRuleSummary.tsx) under a custom smart playlist's title. Built from translated fragments — one key per predicate with its value interpolated, groups joined by a separator, nested groups parenthesised — so a translator only ever sees short phrases and the recursion stays out of the locale files.

It reads the rules through `get_custom_smart_playlist_rules` rather than parsing `playlist.smart_rules`, which is already in the row: playlists created before the tree carry the v1 flat shape and **only the backend deserializer migrates it**, so reading the column here would render an empty sentence for exactly the oldest playlists.

## Mood Radio

Five presets, each a **tempo gate plus a shape**
([`commands/mood_radio.rs`](../../src-tauri/crates/app/src/commands/mood_radio.rs),
scoring in [`waveflow_core::mood`](../../src-tauri/crates/core/src/mood.rs)).
The gate decides what may be considered; everything inside it is ranked
by how well it fits, and the forty tracks that play are the best of the
pool rather than the first forty drawn out of it (#616).

| Mood | Tempo gate | Centre | Loudness |
| --- | --- | --- | --- |
| Focus | 72–108 | 88 | prefers ≤ −14 LUFS |
| Chill | 65–95 | 78 | prefers ≤ −10 LUFS |
| Workout | 128–180 | 150 | prefers ≥ −12 LUFS |
| Party | 110–132 | 122 | prefers ≥ −12 LUFS |
| Sleep | ≤ 68 | 52 | prefers ≤ −18 LUFS |

The pool of 400 is drawn **measured readings first**, shuffled within
each group, and the ranking then decides which forty of it play. The
shuffle is what keeps two runs of one mood from being the same queue;
the priority is what stops a guess from taking a slot from a track that
really is this tempo — see the octave note below.

### Why only tempo gates

A gate answers "is this the wrong kind of track", a score answers "how
right is it". Tempo is the only signal where falling outside the range
really does mean the wrong mood — a 160 BPM track is not Sleep at any
loudness. Loudness and genre rank instead, which is what lets a thin
library still return forty tracks, closest fits first, rather than an
error.

That also fixes the case the issue opened on: **an unmeasured loudness
used to satisfy a ceiling exactly as well as a measured quiet track**
(`loudness_lufs IS NULL` passed the filter). It now scores 0.5 — below
a track measured inside the mood, above one measured outside it, which
is the only honest ordering and the same rule the tag matcher uses for
missing data.

### Octave correction

Tempo estimators land an octave out often enough that a 170 BPM track
is recorded as 85, and a library where that happened is a library where
Focus quietly fills with drum'n'bass. The candidate query accepts any
octave reading (`bpm`, `bpm × 2`, `bpm ÷ 2`) and the scorer discounts
the corrected one by a third: a guess about a measurement is worth less
than a measurement, so rescued tracks sit below the honest ones rather
than beside them. A tempo already inside the window is **never**
reinterpreted, however well its double would score.

### Narrowed, not de-overlapped

Chill's window used to sit entirely inside Focus's, and Party shared
fifteen beats with Workout — two different moods could return the same
kind of list. The windows above share four beats at most between Party
and Workout, and Focus and Chill still overlap because the moods
genuinely do: "calm enough to work to" and "calm enough to sit in" are
the same tempo, and what separates them is the centre, the loudness and
the genre words.

### What the home tile says

`mood_radio_counts` returns the per-mood counts **and** how much of the
library carries a tempo at all, because a thin radio has a reason the
counts cannot show: they look small without saying small *of what*. The
grid shows the coverage line only while the two numbers differ. The
subtitle now says what the radio does — it promised "tempo and energy"
while energy was a loudness ceiling on two of the five moods.
1 change: 1 addition & 0 deletions src-tauri/crates/app/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub mod smart_playlists;
pub mod spotify;
pub mod stats;
pub mod storage;
pub mod tag_fetch;
pub mod tasks;
pub mod track;
pub mod track_tags;
Expand Down
Loading