diff --git a/docs/features/integrations.md b/docs/features/integrations.md index 7fc17efa..4f92332c 100644 --- a/docs/features/integrations.md +++ b/docs/features/integrations.md @@ -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. @@ -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. diff --git a/docs/features/smart-playlists.md b/docs/features/smart-playlists.md index 226fd1c9..0a905687 100644 --- a/docs/features/smart-playlists.md +++ b/docs/features/smart-playlists.md @@ -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. diff --git a/src-tauri/crates/app/src/commands/mod.rs b/src-tauri/crates/app/src/commands/mod.rs index d2a7c3e6..3ce5f115 100644 --- a/src-tauri/crates/app/src/commands/mod.rs +++ b/src-tauri/crates/app/src/commands/mod.rs @@ -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; diff --git a/src-tauri/crates/app/src/commands/mood_radio.rs b/src-tauri/crates/app/src/commands/mood_radio.rs index 0f7e7845..a1700d95 100644 --- a/src-tauri/crates/app/src/commands/mood_radio.rs +++ b/src-tauri/crates/app/src/commands/mood_radio.rs @@ -1,12 +1,20 @@ //! Mood-based radio — Spotify-style "moment of the day" queues. //! -//! Five presets, each mapped to a BPM range plus an optional LUFS -//! ceiling (focus/sleep want quieter tracks; workout/party are tempo- -//! only). The query joins `track` with `track_analysis` and filters -//! out tracks that have no analysis row at all — without BPM data we -//! can't honour the constraint, and a "mood" radio that randomly -//! sneaks in heavy metal between two ambient tracks would defeat the -//! whole point. +//! Five presets. Each one is a **tempo gate plus a shape**: the gate +//! decides what may be considered, and [`waveflow_core::mood`] ranks +//! everything inside it by how well it actually fits — distance from +//! the mood's tempo centre, loudness, and any genre word the mood +//! names. The forty tracks that play are the best of the pool, not the +//! first forty drawn out of it (#616). +//! +//! # 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 one of the three signals where +//! falling outside the range really does mean the wrong mood — a +//! 160 BPM track is not Sleep at any loudness. Loudness and genre are +//! ranked instead, which is what lets a thin library still return +//! forty tracks, closest fits first, rather than an error. //! //! Returns the ordered `Vec` of track IDs. The frontend hands //! this to `player_play_tracks` with `source_type = "radio"` so @@ -18,6 +26,8 @@ use serde::Deserialize; use sqlx::SqlitePool; +use waveflow_core::mood::{MoodCandidate, MoodProfile}; + use crate::{ error::{AppError, AppResult}, state::AppState, @@ -32,9 +42,11 @@ const TARGET_LEN: usize = 40; /// has 100+ play_events on a single act. const PER_ARTIST_CAP: usize = 4; -/// Pool size before the per-artist cap + shuffle. Larger = more -/// variety in the candidate set, but also slower SQL — 400 hits the -/// sweet spot for libraries up to ~50k tracks. +/// Pool size before ranking. Drawn at random, which is what keeps two +/// runs of the same mood from being the same queue; the ranking then +/// decides which forty of it play. Larger = more variety in the +/// candidate set, but also slower SQL — 400 hits the sweet spot for +/// libraries up to ~50k tracks. const POOL_SIZE: i64 = 400; use waveflow_core::album_playback::{ALBUM_FIT, ALBUM_MIN_ANALYSED}; @@ -49,6 +61,43 @@ const ALBUM_POOL_SIZE: i64 = 60; /// the same artist in a row. const ALBUM_PER_ARTIST_CAP: usize = 2; +/// The tempo gate, in SQL: does any octave reading of `ta.bpm` fall +/// inside `?1 .. ?2`? +/// +/// Written once because three queries ask it — the track pool, the +/// album pool and the count behind the home tile — and a copy that +/// drifted would make a mood report a number it cannot deliver, or +/// hide one it can. A macro rather than a `const`, so the fragment is +/// pasted by `concat!` and every query stays a `&'static str` literal: +/// building the string at runtime would mean handing sqlx SQL it +/// cannot verify. +macro_rules! bpm_any_octave { + () => { + concat!( + "( ", + bpm_plain_in_window!(), + " OR ((?1 IS NULL OR ta.bpm * 2.0 >= ?1) AND (?2 IS NULL OR ta.bpm * 2.0 <= ?2))", + " OR ((?1 IS NULL OR ta.bpm / 2.0 >= ?1) AND (?2 IS NULL OR ta.bpm / 2.0 <= ?2)))" + ) + }; +} + +/// The tempo gate for the **measured** reading alone. +/// +/// Half of [`bpm_any_octave!`], and also what orders the draw: a +/// corrected reading is a guess, and a guess must not take a place in +/// the pool from a track that really is this tempo. Sleep is where it +/// shows — its window has no floor, so everything up to 136 BPM +/// qualifies once halved, which is most of a library. Drawing at +/// random across all of that would fill the mood built on slowness +/// with halved dance records whenever the genuinely slow ones are +/// rare, which is the complaint the issue opened on. +macro_rules! bpm_plain_in_window { + () => { + "((?1 IS NULL OR ta.bpm >= ?1) AND (?2 IS NULL OR ta.bpm <= ?2))" + }; +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Mood { @@ -59,61 +108,117 @@ pub enum Mood { Sleep, } -struct MoodFilter { - /// Inclusive BPM range. `None` means unbounded on that side. - bpm_min: Option, - bpm_max: Option, - /// Inclusive LUFS ceiling — `Some(-14.0)` keeps tracks at or below - /// −14 LUFS (perceptually quieter, fits Focus / Sleep). LUFS is - /// negative; tracks with NULL loudness pass through (the constraint - /// only filters when we have data, so we don't punish unanalysed - /// quiet tracks). - lufs_max: Option, -} - impl Mood { - fn filter(&self) -> MoodFilter { + /// The five shapes. + /// + /// The tempo windows are narrower than they were: Chill's used to + /// sit **entirely inside** Focus's, and Party shared fifteen beats + /// with Workout, so two different moods could return the same kind + /// of list. They still overlap where the moods genuinely do — + /// Focus and Chill are both slow and calm, and no tempo separates + /// them — and there the centres, the loudness and the genre words + /// do the separating. + fn profile(&self) -> MoodProfile { match self { - // Calm, mid-tempo, quiet — picked by ear after a few work - // sessions. The −14 LUFS ceiling is roughly Spotify's - // normalisation target; tracks louder than that tend to - // pull attention away from whatever you're focusing on. - Mood::Focus => MoodFilter { - bpm_min: Some(60.0), - bpm_max: Some(110.0), + // Calm, mid-tempo, quiet. The −14 LUFS preference is + // roughly Spotify's normalisation target; louder than that + // tends to pull attention away from whatever you are + // focusing on. + Mood::Focus => MoodProfile { + bpm_min: Some(72.0), + bpm_max: Some(108.0), + bpm_centre: 88.0, lufs_max: Some(-14.0), + lufs_min: None, + genre_words: &[ + "ambient", + "classical", + "instrumental", + "piano", + "soundtrack", + "score", + "post-rock", + ], }, - // Lounge / chill territory — same tempo band as Focus but - // no loudness constraint so the radio can include warmer - // mixes (jazz, soul, downtempo). - Mood::Chill => MoodFilter { - bpm_min: Some(60.0), - bpm_max: Some(100.0), - lufs_max: None, + // Lounge / chill territory: slower than Focus on average, + // and separated from it mostly by genre — "calm enough to + // work to" and "calm enough to sit in" are the same tempo. + Mood::Chill => MoodProfile { + bpm_min: Some(65.0), + bpm_max: Some(95.0), + bpm_centre: 78.0, + lufs_max: Some(-10.0), + lufs_min: None, + genre_words: &[ + "chill", + "lounge", + "downtempo", + "soul", + "jazz", + "bossa", + "r&b", + "trip hop", + "folk", + ], }, - // Workout: keeps the cadence above ~125 (running, lifting). - // Upper bound at 180 to avoid drum'n'bass / speedcore that - // most users wouldn't want for a treadmill session. - Mood::Workout => MoodFilter { - bpm_min: Some(125.0), + // Workout: keeps the cadence above ~128 (running, + // lifting), and wants the loud end of the library rather + // than merely tolerating it. Upper bound at 180 to avoid + // the drum'n'bass and speedcore most users wouldn't want + // for a treadmill session. + Mood::Workout => MoodProfile { + bpm_min: Some(128.0), bpm_max: Some(180.0), + bpm_centre: 150.0, lufs_max: None, + lufs_min: Some(-12.0), + genre_words: &[ + "electronic", + "dance", + "techno", + "rock", + "metal", + "punk", + "hip hop", + "rap", + "drum", + ], }, - // Dance-pop tempo band, broad on purpose — house (~120), - // pop (~120-130), reggaeton (~95-100, deliberately - // excluded so a "Party" radio doesn't accidentally drop - // tempo mid-set). - Mood::Party => MoodFilter { + // Dance-pop tempo band, narrowed off Workout's: they now + // share four beats instead of fifteen. + Mood::Party => MoodProfile { bpm_min: Some(110.0), - bpm_max: Some(140.0), + bpm_max: Some(132.0), + bpm_centre: 122.0, lufs_max: None, + lufs_min: Some(-12.0), + genre_words: &[ + "dance", + "pop", + "house", + "disco", + "funk", + "reggaeton", + "electronic", + "latin", + ], }, - // Sleep: very slow, very quiet. The −18 LUFS floor catches - // almost anything that isn't ambient / piano / classical. - Mood::Sleep => MoodFilter { + // Sleep: very slow, very quiet. + Mood::Sleep => MoodProfile { bpm_min: None, - bpm_max: Some(75.0), + bpm_max: Some(68.0), + bpm_centre: 52.0, lufs_max: Some(-18.0), + lufs_min: None, + genre_words: &[ + "ambient", + "classical", + "piano", + "meditation", + "drone", + "new age", + "sleep", + ], }, } } @@ -125,7 +230,7 @@ pub async fn start_mood_radio( mood: Mood, ) -> AppResult> { let pool = state.require_profile_pool().await?; - let f = mood.filter(); + let profile = mood.profile(); if waveflow_core::album_playback::album_mode_enabled(&pool).await { // Album mode is a preference, not a contract: a library whose @@ -135,74 +240,110 @@ pub async fn start_mood_radio( // genuinely nothing, the track path says so in the words that // actually apply ("no tracks match this mood") rather than // blaming the albums. - let by_album = mood_radio_by_album(&pool, &f).await?; + let by_album = mood_radio_by_album(&pool, &profile).await?; if !by_album.is_empty() { return Ok(by_album); } tracing::info!("no record fits this mood; falling back to individual tracks"); } - // Pull the candidate pool. `bpm IS NOT NULL` is mandatory — we - // can't honour a tempo-based mood without it. LUFS is optional - // (NULL passes through when no ceiling is set, otherwise the - // ceiling acts as a soft "skip very loud tracks" filter). - let rows: Vec = sqlx::query_as::<_, TrackCandidate>( - r#" - SELECT t.id AS track_id, - t.primary_artist AS primary_artist, - COALESCE(ta.bpm, 0.0) AS bpm, - COALESCE(ta.loudness_lufs, 0.0) AS lufs - FROM track t - JOIN track_analysis ta ON ta.track_id = t.id - WHERE t.is_available = 1 - AND ta.bpm IS NOT NULL - AND (?1 IS NULL OR ta.bpm >= ?1) - AND (?2 IS NULL OR ta.bpm <= ?2) - AND (?3 IS NULL OR ta.loudness_lufs IS NULL OR ta.loudness_lufs <= ?3) - ORDER BY RANDOM() - LIMIT ?4 - "#, - ) - .bind(f.bpm_min) - .bind(f.bpm_max) - .bind(f.lufs_max) - .bind(POOL_SIZE) - .fetch_all(&*pool) - .await?; - + let rows = candidate_pool(&pool, &profile, POOL_SIZE).await?; if rows.is_empty() { return Err(AppError::Other( "no tracks match this mood — run BPM analysis on your library first".into(), )); } - // Per-artist cap so a single heavy contributor doesn't dominate. - let mut per_artist: std::collections::HashMap = std::collections::HashMap::new(); - let mut out: Vec = Vec::with_capacity(TARGET_LEN); - for c in rows { - if out.len() >= TARGET_LEN { - break; - } - let count = per_artist.entry(c.primary_artist).or_insert(0); - if *count >= PER_ARTIST_CAP { - continue; - } - *count += 1; - out.push(c.track_id); + Ok(waveflow_core::mood::rank_and_cap( + &profile, + rows, + TARGET_LEN, + PER_ARTIST_CAP, + )) +} + +/// The tracks a mood may consider, drawn at random. +/// +/// `bpm IS NOT NULL` is mandatory — a tempo-based mood cannot be +/// honoured without it. The gate accepts **any octave reading** +/// (`bpm`, `bpm × 2`, `bpm ÷ 2`): estimators land an octave out often +/// enough that a 170 BPM track is recorded as 85, and without this the +/// correction in [`waveflow_core::mood`] would never see the tracks it +/// exists to rescue. The corrected reading is discounted when scoring, +/// so those tracks sit below the honest ones rather than beside them. +/// +/// Loudness and genre are **not** filtered here — they rank. See the +/// module header. +async fn candidate_pool( + pool: &SqlitePool, + profile: &MoodProfile, + limit: i64, +) -> AppResult> { + #[derive(sqlx::FromRow)] + struct Row { + track_id: i64, + primary_artist: Option, + bpm: f64, + loudness_lufs: Option, + genres: Option, } - Ok(out) + let rows: Vec = sqlx::query_as::<_, Row>(concat!( + "SELECT t.id AS track_id, + t.primary_artist AS primary_artist, + ta.bpm AS bpm, + ta.loudness_lufs AS loudness_lufs, + (SELECT GROUP_CONCAT(g.name, ' ') + FROM track_genre tg JOIN genre g ON g.id = tg.genre_id + WHERE tg.track_id = t.id) AS genres + FROM track t + JOIN track_analysis ta ON ta.track_id = t.id + WHERE t.is_available = 1 + AND ta.bpm IS NOT NULL + AND ta.bpm > 0 + AND ", + bpm_any_octave!(), + // Measured matches first, still shuffled among themselves; the + // corrected ones only top up what is left. The album path does + // not need this: it is ranked on the record's average tempo, + // which the scorer discounts the same way, and its gate already + // asks that most of the record fit — a record of corrected + // readings rarely clears that. + " ORDER BY CASE WHEN ", + bpm_plain_in_window!(), + " THEN 0 ELSE 1 END, RANDOM() + LIMIT ?3" + )) + .bind(profile.bpm_min) + .bind(profile.bpm_max) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| MoodCandidate { + track_id: r.track_id, + primary_artist: r.primary_artist, + bpm: r.bpm, + loudness_lufs: r.loudness_lufs, + genres: r.genres, + }) + .collect()) } /// The same radio, built out of whole records (#618). /// /// Selection is per album rather than per track: a record qualifies /// when most of what we have measured of it sits inside the mood's -/// window, and it then plays in the order it was pressed. The -/// per-artist cap becomes a cap on records, for the same reason — +/// tempo window, and the records that qualify are then **ranked by the +/// fit of their average track** rather than drawn at random — the same +/// change as the track path, applied to the unit album mode plays in. +/// Each record then plays in the order it was pressed. The per-artist +/// cap becomes a cap on records, for the same reason as on tracks: /// without it a heavy listener's mood radio is one artist's /// discography. -async fn mood_radio_by_album(pool: &SqlitePool, f: &MoodFilter) -> AppResult> { +async fn mood_radio_by_album(pool: &SqlitePool, profile: &MoodProfile) -> AppResult> { #[derive(sqlx::FromRow)] struct Row { album_id: i64, @@ -215,33 +356,33 @@ async fn mood_radio_by_album(pool: &SqlitePool, f: &MoodFilter) -> AppResult, + /// The record judged as one track: the mood ranks it by the + /// average of what was measured on it. + avg_bpm: Option, + avg_lufs: Option, } - let rows: Vec = sqlx::query_as::<_, Row>( - r#" - SELECT t.album_id AS album_id, - COUNT(*) AS track_count, - MIN(t.primary_artist) AS primary_artist - FROM track t - LEFT JOIN track_analysis ta ON ta.track_id = t.id - WHERE t.is_available = 1 - AND t.album_id IS NOT NULL - GROUP BY t.album_id - HAVING SUM(CASE WHEN ta.bpm IS NOT NULL THEN 1 ELSE 0 END) >= ?5 - AND SUM(CASE WHEN ta.bpm IS NOT NULL - AND (?1 IS NULL OR ta.bpm >= ?1) - AND (?2 IS NULL OR ta.bpm <= ?2) - AND (?3 IS NULL OR ta.loudness_lufs IS NULL - OR ta.loudness_lufs <= ?3) - THEN 1 ELSE 0 END) * 1.0 - / SUM(CASE WHEN ta.bpm IS NOT NULL THEN 1 ELSE 0 END) >= ?4 - ORDER BY RANDOM() - LIMIT ?6 - "#, - ) - .bind(f.bpm_min) - .bind(f.bpm_max) - .bind(f.lufs_max) + let rows: Vec = sqlx::query_as::<_, Row>(concat!( + "SELECT t.album_id AS album_id, + COUNT(*) AS track_count, + MIN(t.primary_artist) AS primary_artist, + AVG(ta.bpm) AS avg_bpm, + AVG(ta.loudness_lufs) AS avg_lufs + FROM track t + LEFT JOIN track_analysis ta ON ta.track_id = t.id + WHERE t.is_available = 1 + AND t.album_id IS NOT NULL + GROUP BY t.album_id + HAVING SUM(CASE WHEN ta.bpm IS NOT NULL THEN 1 ELSE 0 END) >= ?4 + AND SUM(CASE WHEN ta.bpm IS NOT NULL AND ta.bpm > 0 AND ", + bpm_any_octave!(), + " THEN 1 ELSE 0 END) * 1.0 + / SUM(CASE WHEN ta.bpm IS NOT NULL THEN 1 ELSE 0 END) >= ?3 + ORDER BY RANDOM() + LIMIT ?5" + )) + .bind(profile.bpm_min) + .bind(profile.bpm_max) .bind(ALBUM_FIT) .bind(ALBUM_MIN_ANALYSED) .bind(ALBUM_POOL_SIZE) @@ -254,25 +395,41 @@ async fn mood_radio_by_album(pool: &SqlitePool, f: &MoodFilter) -> AppResult, usize> = - std::collections::HashMap::new(); - let candidates: Vec = rows + // Rank the records the way the track path ranks tracks, by reusing + // the same scorer over the record's averages. `album_id` stands in + // for the track id, so the tie-break stays total. The cap is a cap + // on records here, and `rank_and_cap` applies it to the ranking, + // so what it drops is that artist's worst-fitting record. + let ranked = waveflow_core::mood::rank_and_cap( + profile, + rows.iter() + .map(|r| MoodCandidate { + track_id: r.album_id, + primary_artist: r.primary_artist, + // A record only qualified because it has analysed + // tracks, so this is `None` in no reachable case; zero + // then scores as the worst possible tempo rather than + // taking the radio down. + bpm: r.avg_bpm.unwrap_or(0.0), + loudness_lufs: r.avg_lufs, + genres: None, + }) + .collect(), + rows.len(), + ALBUM_PER_ARTIST_CAP, + ); + + let track_counts: std::collections::HashMap = + rows.iter().map(|r| (r.album_id, r.track_count)).collect(); + let candidates: Vec = ranked .into_iter() - .filter(|row| { - // Records with no artist at all share one bucket rather - // than each counting as a different artist: that is the - // conservative reading, and it keeps a pile of untagged - // rips from filling the radio between them. - let count = per_artist.entry(row.primary_artist).or_insert(0); - if *count >= ALBUM_PER_ARTIST_CAP { - return false; - } - *count += 1; - true - }) - .map(|row| waveflow_core::album_playback::AlbumCandidate { - album_id: row.album_id, - track_count: row.track_count, + .filter_map(|album_id| { + track_counts.get(&album_id).map(|&track_count| { + waveflow_core::album_playback::AlbumCandidate { + album_id, + track_count, + } + }) }) .collect(); @@ -280,29 +437,27 @@ async fn mood_radio_by_album(pool: &SqlitePool, f: &MoodFilter) -> AppResult) -> AppResult { let pool = state.require_profile_pool().await?; + let (analysed_tracks, total_tracks) = analysis_coverage(&pool).await?; Ok(MoodCounts { - focus: count_for_mood(&pool, &Mood::Focus.filter()).await?, - chill: count_for_mood(&pool, &Mood::Chill.filter()).await?, - workout: count_for_mood(&pool, &Mood::Workout.filter()).await?, - party: count_for_mood(&pool, &Mood::Party.filter()).await?, - sleep: count_for_mood(&pool, &Mood::Sleep.filter()).await?, + focus: count_for_mood(&pool, &Mood::Focus.profile()).await?, + chill: count_for_mood(&pool, &Mood::Chill.profile()).await?, + workout: count_for_mood(&pool, &Mood::Workout.profile()).await?, + party: count_for_mood(&pool, &Mood::Party.profile()).await?, + sleep: count_for_mood(&pool, &Mood::Sleep.profile()).await?, + analysed_tracks, + total_tracks, }) } @@ -313,25 +468,153 @@ pub struct MoodCounts { pub workout: i64, pub party: i64, pub sleep: i64, + /// Tracks carrying a tempo measurement — the population every mood + /// draws from. + pub analysed_tracks: i64, + /// Playable tracks in the library, analysed or not. + pub total_tracks: i64, } -async fn count_for_mood(pool: &SqlitePool, f: &MoodFilter) -> AppResult { - let n: i64 = sqlx::query_scalar( +/// How much of the library has a tempo, and how big the library is. +async fn analysis_coverage(pool: &SqlitePool) -> AppResult<(i64, i64)> { + // `SUM` over an empty set is NULL, and an empty library is a real + // state (a fresh profile) — decoding that into `i64` would fail + // the command on exactly the install that has nothing to show. + let row: (Option, i64) = sqlx::query_as( r#" - SELECT COUNT(*) + SELECT SUM(CASE WHEN ta.bpm IS NOT NULL THEN 1 ELSE 0 END), + COUNT(*) FROM track t - JOIN track_analysis ta ON ta.track_id = t.id + LEFT JOIN track_analysis ta ON ta.track_id = t.id WHERE t.is_available = 1 - AND ta.bpm IS NOT NULL - AND (?1 IS NULL OR ta.bpm >= ?1) - AND (?2 IS NULL OR ta.bpm <= ?2) - AND (?3 IS NULL OR ta.loudness_lufs IS NULL OR ta.loudness_lufs <= ?3) "#, ) - .bind(f.bpm_min) - .bind(f.bpm_max) - .bind(f.lufs_max) + .fetch_one(pool) + .await?; + Ok((row.0.unwrap_or(0), row.1)) +} + +async fn count_for_mood(pool: &SqlitePool, profile: &MoodProfile) -> AppResult { + let n: i64 = sqlx::query_scalar(concat!( + "SELECT COUNT(*) + FROM track t + JOIN track_analysis ta ON ta.track_id = t.id + WHERE t.is_available = 1 + AND ta.bpm IS NOT NULL + AND ta.bpm > 0 + AND ", + bpm_any_octave!() + )) + .bind(profile.bpm_min) + .bind(profile.bpm_max) .fetch_one(pool) .await?; Ok(n) } + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::str::FromStr; + + /// The repo's own profile migrations, with `foreign_keys` on. + /// + /// A hand-written fixture would let these queries pass against a + /// schema the app never has — the same reason the inventory tests + /// run the real migrations. + async fn pool() -> SqlitePool { + let options = SqliteConnectOptions::from_str(":memory:") + .unwrap() + .foreign_keys(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + sqlx::migrate!("../../migrations/profile") + .run(&pool) + .await + .unwrap(); + pool + } + + /// Every query in this module runs, against the real schema, for + /// every mood. + /// + /// The tempo gate is pasted into three statements by a macro and + /// none of them is a compile-time-checked query: a missing + /// parenthesis or a column that does not exist would compile + /// perfectly and fail the first time a user pressed a mood tile. + /// An empty library is enough to prove the SQL parses and that the + /// placeholders line up with the binds. + #[tokio::test] + async fn every_mood_query_parses_and_binds() { + let pool = pool().await; + for mood in [ + Mood::Focus, + Mood::Chill, + Mood::Workout, + Mood::Party, + Mood::Sleep, + ] { + let profile = mood.profile(); + assert!(candidate_pool(&pool, &profile, 10) + .await + .unwrap() + .is_empty()); + assert!(mood_radio_by_album(&pool, &profile) + .await + .unwrap() + .is_empty()); + assert_eq!(count_for_mood(&pool, &profile).await.unwrap(), 0); + } + assert_eq!(analysis_coverage(&pool).await.unwrap(), (0, 0)); + } + + /// An octave-out tempo reaches the pool, which is what the gate in + /// SQL exists for — the scorer cannot rescue a row the query never + /// returned. + #[tokio::test] + async fn the_pool_accepts_a_doubled_tempo() { + let pool = pool().await; + sqlx::query( + "INSERT INTO library (id, name, created_at, updated_at) VALUES (1, 'l', 0, 0); + INSERT INTO track (id, library_id, file_path, file_hash, file_size, + file_modified, title, duration_ms, added_at) + VALUES (1, 1, '/l/a.flac', 'h1', 1, 0, 'Fast', 200000, 0), + (2, 1, '/l/b.flac', 'h2', 1, 0, 'Slow', 200000, 0), + (3, 1, '/l/c.flac', 'h3', 1, 0, 'Plain', 200000, 0); + -- 30 BPM, not 40: doubled, 40 lands on 80, which is + -- inside Focus. Every reading of 30 (30, 60, 15) is out. + INSERT INTO track_analysis (track_id, bpm, analyzed_at) + VALUES (1, 170.0, 0), (2, 30.0, 0), (3, 90.0, 0);", + ) + .execute(&pool) + .await + .unwrap(); + + let focus = Mood::Focus.profile(); + let ids: Vec = candidate_pool(&pool, &focus, 10) + .await + .unwrap() + .into_iter() + .map(|c| c.track_id) + .collect(); + assert!( + ids.contains(&1), + "170 BPM must reach a mood built on 88, as a halved reading" + ); + assert!(!ids.contains(&2), "30 BPM is out under every reading"); + assert_eq!(count_for_mood(&pool, &focus).await.unwrap(), 2); + + // A measured match comes before a corrected one, whatever the + // shuffle does inside each group: a guess must not take a place + // in the pool from a track that really is this tempo. + assert_eq!( + ids.first(), + Some(&3), + "90 BPM is measured inside the window; 170 is a rescue" + ); + } +} diff --git a/src-tauri/crates/app/src/commands/tag_fetch.rs b/src-tauri/crates/app/src/commands/tag_fetch.rs new file mode 100644 index 00000000..9b9e3346 --- /dev/null +++ b/src-tauri/crates/app/src/commands/tag_fetch.rs @@ -0,0 +1,464 @@ +//! Fetching an album's tags from Deezer, for review (#599). +//! +//! Deezer enrichment already fills artwork and artist pages; this is +//! the other half — "these tags are wrong, fetch them and let me +//! approve the result". It is two steps, deliberately: +//! +//! 1. [`search_album_tag_sources`] offers the catalogue albums that +//! might be this record. Choosing is the user's, because a title +//! and an artist name match several releases of the same album — +//! an original, a remaster, a deluxe edition with four more tracks +//! — and they carry different track lists. +//! 2. [`fetch_album_tag_proposals`] pairs the chosen release's tracks +//! with the local files through +//! [`waveflow_core::metadata::album_match`] and hands back both +//! sides, field by field. +//! +//! # Nothing here writes +//! +//! No command in this module touches a file or a row. The proposals go +//! to the review screen, and what the user accepts is applied through +//! [`crate::commands::edit::update_track_tags`] — the path that pauses +//! playback before opening the file, writes through the concrete tag +//! so non-standard frames survive, re-hashes, 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. +//! +//! # What Deezer cannot fill +//! +//! No composer, and no genre at track level. Neither is offered: a +//! review screen that lists a field the source cannot fill invites the +//! user to accept a blank over something they typed themselves. +//! `disk_number` is returned by the API but is unreliable on box sets, +//! so it is not offered either — the field it would overwrite is +//! usually right where the catalogue's is wrong. + +use serde::Serialize; +use sqlx::SqlitePool; +use waveflow_core::metadata::{ + album_match::{self, Confidence, TrackSignals}, + deezer::DeezerClient, +}; + +use crate::{ + error::{AppError, AppResult}, + state::AppState, +}; + +/// How many catalogue releases to offer for one local album. +/// +/// Enough that a remaster and a deluxe edition both appear, few enough +/// that the choice stays a choice. +const MAX_SOURCES: usize = 8; + +/// One catalogue release that might be this record. +#[derive(Debug, Serialize)] +pub struct AlbumSource { + pub deezer_id: i64, + pub title: String, + pub artist: Option, + pub track_count: Option, + pub year: Option, + pub cover_url: Option, +} + +#[tauri::command] +pub async fn search_album_tag_sources( + state: tauri::State<'_, AppState>, + album_id: i64, +) -> AppResult> { + // Before the reads, as in `fetch_album_tag_proposals`: everything + // this command does afterwards is in service of a network call it + // is not going to make. + if crate::offline::is_offline() { + return Err(AppError::Other( + "offline mode is on — turn it off to fetch tags".into(), + )); + } + + let pool = state.require_profile_pool().await?; + let (title, artist) = album_identity(&pool, album_id).await?; + + let query = match artist.as_deref() { + Some(artist) => format!("{title} {artist}"), + None => title.clone(), + }; + let client = DeezerClient::new(); + let hits = client + .search_album(&query) + .await + .map_err(|e| AppError::Other(format!("Deezer album search failed: {e}")))?; + + Ok(hits + .into_iter() + .take(MAX_SOURCES) + .map(|hit| AlbumSource { + deezer_id: hit.id, + title: hit.title, + artist: hit.artist.map(|a| a.name), + track_count: hit.nb_tracks, + // `release_date` is `YYYY-MM-DD`; only the year is worth + // offering, and a malformed one is dropped rather than + // guessed at. + year: hit + .release_date + .as_deref() + .and_then(|d| d.get(0..4)) + .and_then(|y| y.parse::().ok()), + cover_url: hit.cover_medium.or(hit.cover_big), + }) + .collect()) +} + +/// The values a tag fetch can offer for one track. +/// +/// Every field is optional on both sides: the local file may not carry +/// it, and the catalogue may not either. The review screen compares +/// them field by field, so it needs the pair rather than a diff. +#[derive(Debug, Clone, Default, Serialize)] +pub struct TagValues { + pub title: Option, + pub artist: Option, + pub album: Option, + pub year: Option, + pub track_number: Option, +} + +/// One local file, with what the catalogue says about it. +#[derive(Debug, Serialize)] +pub struct TrackProposal { + pub track_id: i64, + /// Shown for a track the matcher could not pair, where the title + /// alone would not tell the user which file is meant. + pub file_name: String, + pub current: TagValues, + /// `None` when nothing in the release matched this file well + /// enough — a real answer, and the reason the screen must not + /// silently apply "the best available". + pub fetched: Option, + pub score: Option, + pub confidence: Option, +} + +#[derive(Debug, Serialize)] +pub struct AlbumProposals { + pub album_id: i64, + pub deezer_id: i64, + /// Tracks in the album's own order, matched or not. + pub tracks: Vec, + /// Catalogue tracks no local file claimed — a deluxe edition's + /// extras, or the songs a partial rip is missing. + pub unmatched_remote: Vec, +} + +#[tauri::command] +pub async fn fetch_album_tag_proposals( + state: tauri::State<'_, AppState>, + album_id: i64, + deezer_album_id: i64, +) -> AppResult { + let pool = state.require_profile_pool().await?; + + if crate::offline::is_offline() { + return Err(AppError::Other( + "offline mode is on — turn it off to fetch tags".into(), + )); + } + + let locals = local_tracks(&pool, album_id).await?; + if locals.is_empty() { + return Err(AppError::Other(format!("album {album_id} has no tracks"))); + } + + let client = DeezerClient::new(); + let album = client + .get_album(deezer_album_id) + .await + .map_err(|e| AppError::Other(format!("Deezer album fetch failed: {e}")))?; + let remote_tracks = client + .get_album_tracks(deezer_album_id) + .await + .map_err(|e| AppError::Other(format!("Deezer track listing failed: {e}")))?; + if remote_tracks.is_empty() { + return Err(AppError::Other( + "this release has no track listing on Deezer".into(), + )); + } + + let album_year = album + .release_date + .as_deref() + .and_then(|d| d.get(0..4)) + .and_then(|y| y.parse::().ok()); + let album_title = album.title.clone(); + + let local_signals: Vec = locals + .iter() + .map(|l| TrackSignals { + title: l.title.clone(), + duration_ms: Some(l.duration_ms), + track_number: l.track_number, + disc_number: l.disc_number, + }) + .collect(); + let remote_signals: Vec = remote_tracks + .iter() + .map(|r| TrackSignals { + title: r.title.clone(), + duration_ms: r.duration_ms(), + track_number: r.track_position, + // Read for matching only. It is still not offered as a + // value to write: the catalogue's disc numbers are + // unreliable on box sets, which is where the local ones are + // usually right — and a signal that costs 0.15 when it + // disagrees is a different risk from a value that + // overwrites a correct field. + disc_number: r.disk_number, + }) + .collect(); + + let assignments = album_match::assign(&local_signals, &remote_signals); + // Indexed by local position so the album's order is preserved + // whatever order the matcher settled the pairs in. + let mut by_local: std::collections::HashMap = + std::collections::HashMap::new(); + for a in &assignments { + by_local.insert(a.local, a); + } + let claimed: std::collections::HashSet = assignments.iter().map(|a| a.remote).collect(); + + let tracks = locals + .iter() + .enumerate() + .map(|(idx, local)| { + let matched = by_local.get(&idx); + TrackProposal { + track_id: local.track_id, + file_name: file_name_of(&local.file_path), + current: TagValues { + title: Some(local.title.clone()), + artist: local.artist.clone(), + album: local.album.clone(), + year: local.year, + track_number: local.track_number, + }, + fetched: matched.map(|a| { + let r = &remote_tracks[a.remote]; + TagValues { + title: Some(r.title.clone()), + // The track's own credit where the catalogue + // gives one: a compilation's tracks are not all + // by the album's artist, and taking the album's + // would rewrite every guest credit on it. + artist: r.artist.as_ref().map(|a| a.name.clone()), + album: Some(album_title.clone()), + year: album_year, + track_number: r.track_position, + } + }), + score: matched.map(|a| a.score), + confidence: matched.map(|a| a.confidence), + } + }) + .collect(); + + let unmatched_remote = remote_tracks + .iter() + .enumerate() + .filter(|(i, _)| !claimed.contains(i)) + .map(|(_, r)| r.title.clone()) + .collect(); + + Ok(AlbumProposals { + album_id, + deezer_id: deezer_album_id, + tracks, + unmatched_remote, + }) +} + +// ── Reads ─────────────────────────────────────────────────────────── + +/// The album's title and its artist's name, for the search query. +async fn album_identity(pool: &SqlitePool, album_id: i64) -> AppResult<(String, Option)> { + let row: Option<(String, Option)> = sqlx::query_as( + "SELECT al.title, ar.name + FROM album al + LEFT JOIN artist ar ON ar.id = al.artist_id + WHERE al.id = ?", + ) + .bind(album_id) + .fetch_optional(pool) + .await?; + row.ok_or_else(|| AppError::Other(format!("album {album_id} not found"))) +} + +struct LocalTrack { + track_id: i64, + file_path: String, + title: String, + duration_ms: i64, + track_number: Option, + disc_number: Option, + year: Option, + artist: Option, + album: Option, +} + +/// The album's tracks, in the order the record plays. +/// +/// The artist credit is rebuilt from `track_artist` with `"; "`, the +/// library's one spelling for a multi-artist credit — reading +/// `track.primary_artist` instead would offer to replace a full credit +/// with its first name, which is a silent loss dressed as a fix. +/// +/// The ordering goes in an **inner subquery**, the shape the rest of +/// the codebase uses: an `ORDER BY` in the aggregate's own query runs +/// after the aggregation and orders one row, so the names inside the +/// string come out in whatever order the scan happened to reach them. +/// Ordering inside the aggregate call is SQLite 3.44, newer than what +/// we can require. +async fn local_tracks(pool: &SqlitePool, album_id: i64) -> AppResult> { + #[derive(sqlx::FromRow)] + struct Row { + id: i64, + file_path: String, + title: String, + duration_ms: i64, + track_number: Option, + disc_number: Option, + year: Option, + artists: Option, + album: Option, + } + + let rows: Vec = sqlx::query_as::<_, Row>( + r#" + SELECT t.id AS id, + t.file_path AS file_path, + t.title AS title, + t.duration_ms AS duration_ms, + t.track_number AS track_number, + t.disc_number AS disc_number, + t.year AS year, + (SELECT GROUP_CONCAT(name, '; ') FROM ( + SELECT ar.name AS name + FROM track_artist ta + JOIN artist ar ON ar.id = ta.artist_id + WHERE ta.track_id = t.id + ORDER BY ta.position + )) AS artists, + al.title AS album + FROM track t + LEFT JOIN album al ON al.id = t.album_id + WHERE t.album_id = ? + AND t.is_available = 1 + ORDER BY COALESCE(t.disc_number, 1), COALESCE(t.track_number, 9999), t.title + "#, + ) + .bind(album_id) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| LocalTrack { + track_id: r.id, + file_path: r.file_path, + title: r.title, + duration_ms: r.duration_ms, + track_number: r.track_number, + disc_number: r.disc_number, + year: r.year, + artist: r.artists, + album: r.album, + }) + .collect()) +} + +/// The last path segment, whichever separator the scanning OS used. +fn file_name_of(path: &str) -> String { + path.rsplit(['/', '\\']).next().unwrap_or(path).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::str::FromStr; + + #[test] + fn file_name_handles_both_separators() { + assert_eq!(file_name_of(r"E:\Music\a\b.flac"), "b.flac"); + assert_eq!(file_name_of("/home/u/Music/a/b.flac"), "b.flac"); + assert_eq!(file_name_of("bare.flac"), "bare.flac"); + } + + /// The repo's own profile migrations, with `foreign_keys` on. + async fn pool() -> SqlitePool { + let options = SqliteConnectOptions::from_str(":memory:") + .unwrap() + .foreign_keys(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + sqlx::migrate!("../../migrations/profile") + .run(&pool) + .await + .unwrap(); + pool + } + + /// Both reads run against the real schema, and the credit comes + /// back in `track_artist.position` order. + /// + /// Neither query is compile-time checked, and the credit is built + /// by a correlated `GROUP_CONCAT` over an ordered subquery — the + /// shape that is easy to write in a way that compiles, runs, and + /// quietly returns the names in whatever order the scan reached + /// them. + #[tokio::test] + async fn the_reads_run_and_the_credit_keeps_its_order() { + let pool = pool().await; + sqlx::raw_sql( + "INSERT INTO library (id, name, created_at, updated_at) + VALUES (1, 'l', 0, 0); + INSERT INTO artist (id, name, canonical_name) + VALUES (1, 'Second', 'second'), (2, 'First', 'first'); + INSERT INTO album (id, title, canonical_title, artist_id) + VALUES (1, 'Record', 'record', 2); + INSERT INTO track (id, library_id, file_path, file_hash, file_size, + file_modified, title, duration_ms, added_at, + album_id, track_number, year) + VALUES (1, 1, '/l/a.flac', 'h1', 1, 0, 'Song', 200000, 0, 1, 1, 1999); + -- Inserted out of order on purpose: position decides, not + -- the insertion order and not the artist id. + INSERT INTO track_artist (track_id, artist_id, position) + VALUES (1, 1, 1), (1, 2, 0);", + ) + .execute(&pool) + .await + .unwrap(); + + let (title, artist) = album_identity(&pool, 1).await.unwrap(); + assert_eq!(title, "Record"); + assert_eq!(artist.as_deref(), Some("First")); + + let tracks = local_tracks(&pool, 1).await.unwrap(); + assert_eq!(tracks.len(), 1); + assert_eq!(tracks[0].artist.as_deref(), Some("First; Second")); + assert_eq!(tracks[0].album.as_deref(), Some("Record")); + assert_eq!(tracks[0].year, Some(1999)); + } + + /// An album nobody has is an error, not an empty answer: the + /// review screen would otherwise open on nothing and say nothing. + #[tokio::test] + async fn a_missing_album_is_an_error() { + let pool = pool().await; + assert!(album_identity(&pool, 404).await.is_err()); + } +} diff --git a/src-tauri/crates/app/src/lib.rs b/src-tauri/crates/app/src/lib.rs index 8130c9e2..2c1a83cd 100644 --- a/src-tauri/crates/app/src/lib.rs +++ b/src-tauri/crates/app/src/lib.rs @@ -1088,6 +1088,8 @@ pub fn run() { commands::player::player_set_match_source_rate, commands::inventory::inventory_summary, commands::inventory::inventory_tracks, + commands::tag_fetch::search_album_tag_sources, + commands::tag_fetch::fetch_album_tag_proposals, commands::track_tags::list_track_tag_keys, commands::track_tags::list_track_tag_values, commands::tasks::list_tasks, diff --git a/src-tauri/crates/core/src/lib.rs b/src-tauri/crates/core/src/lib.rs index af30d1e2..122e81ad 100644 --- a/src-tauri/crates/core/src/lib.rs +++ b/src-tauri/crates/core/src/lib.rs @@ -18,6 +18,7 @@ pub mod domain; pub mod error; pub mod inventory; pub mod metadata; +pub mod mood; // `plugin` carries the wasmtime + Cranelift + WASI stack (~5 MiB // of native codegen). Gated so `waveflow-server` (which never // executes guest WASM in v1) can opt out and stay lean. The diff --git a/src-tauri/crates/core/src/metadata/album_match.rs b/src-tauri/crates/core/src/metadata/album_match.rs new file mode 100644 index 00000000..26561925 --- /dev/null +++ b/src-tauri/crates/core/src/metadata/album_match.rs @@ -0,0 +1,392 @@ +//! Matching a local album's tracks against a catalogue's (#599). +//! +//! Matching the *album* is the easy half — a title and an artist go to +//! a search endpoint and the first plausible hit is usually right. +//! Matching the tracks inside it is where this kind of feature fails, +//! and it fails in two specific ways that this module is shaped +//! against: +//! +//! 1. **Judging on one signal.** A title alone cannot separate the two +//! "Intro"s on a record; a duration alone cannot tell two three- +//! minute songs apart. Three weighted signals are used instead, and +//! the weights are deliberately unequal — 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. +//! 2. **Choosing per track.** Asking "what is the best remote track +//! for this local one" lets a generic title win against several +//! local tracks at once and capture one that belonged to another. +//! The assignment here is global and greedy: every pair is scored, +//! the best pair wins, and both sides are consumed. +//! +//! Nothing here touches the network or the database, which is what +//! makes the rules above testable as arithmetic. + +use super::name_match::normalize_name; + +/// Weights of the three signals. They sum to 1. +const W_TITLE: f64 = 0.60; +const W_DURATION: f64 = 0.25; +const W_NUMBER: f64 = 0.15; + +/// What a signal scores when one side has no value for it. +/// +/// **Not zero.** 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 most — the badly tagged ones. +pub const UNKNOWN_SCORE: f64 = 0.5; + +/// At or above this, the match is offered as settled. +pub const CONFIDENT: f64 = 0.85; +/// At or above this but below [`CONFIDENT`], the match is offered for +/// review. Below it there is no match at all. +pub const DOUBTFUL: f64 = 0.55; + +/// How far apart two durations may be before the signal is worthless. +/// +/// Ten seconds: longer than the gap between a catalogue's rounding and +/// a file's real length, shorter than the gap between two different +/// songs of about the same size. +const DURATION_TOLERANCE_MS: f64 = 10_000.0; + +/// One side of a comparison — a local file or a catalogue entry. +#[derive(Debug, Clone, Default)] +pub struct TrackSignals { + pub title: String, + pub duration_ms: Option, + pub track_number: Option, + /// The disc it sits on, where the release has more than one. + /// + /// A track number alone cannot separate disc 1's third track from + /// disc 2's: both are "3", and on a box set of live recordings the + /// titles and the durations are close enough that the number is + /// what the pairing turns on. + pub disc_number: Option, +} + +/// How sure the assignment is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Confidence { + Confident, + Doubtful, +} + +/// One local track paired with one catalogue entry. +#[derive(Debug, Clone, PartialEq)] +pub struct Assignment { + /// Index into the local slice. + pub local: usize, + /// Index into the remote slice. + pub remote: usize, + pub score: f64, + pub confidence: Confidence, +} + +/// How well two tracks agree, in `0.0..=1.0`. +pub fn score(local: &TrackSignals, remote: &TrackSignals) -> f64 { + let title = title_similarity(&local.title, &remote.title); + let duration = duration_similarity(local.duration_ms, remote.duration_ms); + let number = number_similarity(local, remote); + (W_TITLE * title + W_DURATION * duration + W_NUMBER * number).clamp(0.0, 1.0) +} + +/// Pair local tracks with catalogue entries, best pair first. +/// +/// Each side is consumed once, so a catalogue entry cannot be handed to +/// two local files however well it scores against both — the second +/// file keeps looking, which is exactly what stops a generic title from +/// swallowing a record. +/// +/// Pairs scoring below [`DOUBTFUL`] are not returned: a local track +/// with no match is a real answer, and inventing one for it is how this +/// kind of feature corrupts a library. +pub fn assign(locals: &[TrackSignals], remotes: &[TrackSignals]) -> Vec { + let mut pairs: Vec<(f64, usize, usize)> = Vec::with_capacity(locals.len() * remotes.len()); + for (li, local) in locals.iter().enumerate() { + for (ri, remote) in remotes.iter().enumerate() { + let s = score(local, remote); + if s >= DOUBTFUL { + pairs.push((s, li, ri)); + } + } + } + // Descending score; the indices break ties so the result is a + // function of its inputs rather than of the sort's stability. + pairs.sort_by(|a, b| { + b.0.partial_cmp(&a.0) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + + let mut local_taken = vec![false; locals.len()]; + let mut remote_taken = vec![false; remotes.len()]; + let mut out = Vec::new(); + for (s, li, ri) in pairs { + if local_taken[li] || remote_taken[ri] { + continue; + } + local_taken[li] = true; + remote_taken[ri] = true; + out.push(Assignment { + local: li, + remote: ri, + score: s, + confidence: if s >= CONFIDENT { + Confidence::Confident + } else { + Confidence::Doubtful + }, + }); + } + // Back into the album's own order, which is how the review screen + // reads: a list that jumps around by score is a list nobody can + // check against the record in front of them. + out.sort_by_key(|a| a.local); + out +} + +/// Title agreement, over [`normalize_name`]. +/// +/// The normaliser is the shared one on purpose: 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. +pub fn title_similarity(a: &str, b: &str) -> f64 { + let a = normalize_name(a); + let b = normalize_name(b); + if a.is_empty() || b.is_empty() { + return UNKNOWN_SCORE; + } + if a == b { + return 1.0; + } + let distance = levenshtein(&a, &b) as f64; + let longest = a.chars().count().max(b.chars().count()) as f64; + (1.0 - distance / longest).clamp(0.0, 1.0) +} + +fn duration_similarity(a: Option, b: Option) -> f64 { + let (Some(a), Some(b)) = (a, b) else { + return UNKNOWN_SCORE; + }; + let gap = (a - b).abs() as f64; + (1.0 - gap / DURATION_TOLERANCE_MS).clamp(0.0, 1.0) +} + +/// A track number agrees or it does not — there is no near miss. Track +/// 4 is not "almost" track 5; it is a different song. +/// +/// The disc joins the comparison **only when both sides carry one**. A +/// release where one side numbers its discs and the other does not is +/// common — and it is not disagreement, so it must not read as one; +/// the track number then answers alone, exactly as before. When both +/// have it, the pair is compared as a pair, which is the only way +/// "disc 2, track 3" stops looking identical to "disc 1, track 3". +fn number_similarity(local: &TrackSignals, remote: &TrackSignals) -> f64 { + let (Some(a), Some(b)) = (local.track_number, remote.track_number) else { + return UNKNOWN_SCORE; + }; + if a != b { + return 0.0; + } + match (local.disc_number, remote.disc_number) { + (Some(x), Some(y)) if x != y => 0.0, + _ => 1.0, + } +} + +/// Edit distance over characters, two rows at a time. +/// +/// Titles are short and an album is a few dozen of them, so the +/// quadratic cost is a few thousand character comparisons — far below +/// the network call that fetched the catalogue side. +fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + if a.is_empty() { + return b.len(); + } + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur = vec![0usize; b.len() + 1]; + for (i, ca) in a.iter().enumerate() { + cur[0] = i + 1; + for (j, cb) in b.iter().enumerate() { + let cost = usize::from(ca != cb); + cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost); + } + std::mem::swap(&mut prev, &mut cur); + } + prev[b.len()] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn track(title: &str, duration_ms: Option, track_number: Option) -> TrackSignals { + TrackSignals { + title: title.to_string(), + duration_ms, + track_number, + disc_number: None, + } + } + + fn on_disc(mut t: TrackSignals, disc: i64) -> TrackSignals { + t.disc_number = Some(disc); + t + } + + /// The accented-title case the shared normaliser exists for: a + /// decomposed local tag and a precomposed catalogue title are the + /// same song. + #[test] + fn a_decomposed_title_matches_its_precomposed_twin() { + assert_eq!(title_similarity("Bjo\u{308}rk", "Björk"), 1.0); + assert_eq!(title_similarity("Céline", "Celine"), 1.0); + } + + /// Missing data is neutral, not damning. Without this the feature + /// would be useless on the untagged libraries that need it. + #[test] + fn a_missing_track_number_does_not_sink_a_match() { + let local = track("Villanelle", Some(212_000), None); + let remote = track("Villanelle", Some(212_000), Some(3)); + let s = score(&local, &remote); + assert!( + s >= CONFIDENT, + "a title and duration that agree must be enough: {s}" + ); + } + + /// A number that disagrees is evidence, where a missing one is not. + #[test] + fn a_wrong_track_number_costs_more_than_a_missing_one() { + let local = track("Villanelle", Some(212_000), Some(9)); + let missing = track("Villanelle", Some(212_000), None); + let remote = track("Villanelle", Some(212_000), Some(3)); + assert!(score(&local, &remote) < score(&missing, &remote)); + } + + /// The failure this module is shaped against: two generic titles + /// where a per-track best match would hand the same remote entry to + /// both local files. + #[test] + fn a_generic_title_cannot_be_taken_twice() { + let locals = vec![ + track("Intro", Some(60_000), Some(1)), + track("Intro", Some(95_000), Some(7)), + ]; + let remotes = vec![ + track("Intro", Some(60_000), Some(1)), + track("Intro", Some(95_000), Some(7)), + ]; + let out = assign(&locals, &remotes); + assert_eq!(out.len(), 2); + assert_eq!(out[0].remote, 0); + assert_eq!(out[1].remote, 1, "the second file must get the other entry"); + } + + /// The duration is what separates them when the numbers are gone. + #[test] + fn duration_separates_identical_titles() { + let locals = vec![ + track("Interlude", Some(45_000), None), + track("Interlude", Some(180_000), None), + ]; + let remotes = vec![ + track("Interlude", Some(179_000), None), + track("Interlude", Some(46_000), None), + ]; + let out = assign(&locals, &remotes); + assert_eq!(out.len(), 2); + assert_eq!(out[0].remote, 1, "45 s goes with 46 s"); + assert_eq!(out[1].remote, 0, "180 s goes with 179 s"); + } + + /// A local track with nothing like it is left unmatched rather than + /// paired with the least bad option. + #[test] + fn a_track_with_no_counterpart_stays_unmatched() { + let locals = vec![ + track("Hidden Track", Some(30_000), Some(99)), + track("Villanelle", Some(212_000), Some(3)), + ]; + let remotes = vec![track("Villanelle", Some(212_000), Some(3))]; + let out = assign(&locals, &remotes); + assert_eq!(out.len(), 1); + assert_eq!(out[0].local, 1); + } + + /// Two discs, one track number each: without the disc the number + /// signal says "match" for both pairings, and on a box set of live + /// takes the titles and durations are close enough that it decides. + #[test] + fn the_disc_separates_two_tracks_numbered_the_same() { + // Identical on every other signal, so the disc is the only + // thing that can tell them apart: with differing durations the + // duration would separate them and this would pass whether or + // not the disc were read at all. + let locals = vec![ + on_disc(track("Improvisation", Some(400_000), Some(3)), 1), + on_disc(track("Improvisation", Some(400_000), Some(3)), 2), + ]; + let remotes = vec![ + on_disc(track("Improvisation", Some(400_000), Some(3)), 2), + on_disc(track("Improvisation", Some(400_000), Some(3)), 1), + ]; + let out = assign(&locals, &remotes); + assert_eq!(out.len(), 2); + assert_eq!(out[0].remote, 1, "disc 1 goes with disc 1"); + assert_eq!(out[1].remote, 0, "disc 2 goes with disc 2"); + } + + /// A disc on one side only is not a disagreement. Plenty of + /// releases number their discs where the local files do not, and + /// reading that as a mismatch would cost every one of them the + /// number signal. + #[test] + fn a_disc_on_one_side_only_costs_nothing() { + let local = track("Villanelle", Some(212_000), Some(3)); + let remote = on_disc(track("Villanelle", Some(212_000), Some(3)), 2); + assert_eq!(score(&local, &remote), score(&local, &local.clone())); + } + + /// The middle band is the reason the review screen exists: a match + /// good enough to offer, not good enough to apply unseen. + #[test] + fn a_near_miss_is_doubtful_rather_than_absent() { + let local = track("Villanelle (Remastered)", Some(212_000), Some(3)); + let remote = track("Villanelle", Some(214_000), Some(3)); + let out = assign(&[local], &[remote]); + assert_eq!(out.len(), 1); + assert_eq!(out[0].confidence, Confidence::Doubtful); + } + + /// Results come back in the album's order, not in score order. + #[test] + fn assignments_are_returned_in_local_order() { + let locals = vec![ + track("Bad Match", Some(100_000), Some(1)), + track("Exact", Some(200_000), Some(2)), + ]; + let remotes = vec![ + track("Exact", Some(200_000), Some(2)), + track("Bad Match Indeed", Some(101_000), Some(1)), + ]; + let out = assign(&locals, &remotes); + assert_eq!(out.len(), 2); + assert!(out[0].local < out[1].local); + } + + #[test] + fn levenshtein_counts_edits() { + assert_eq!(levenshtein("kitten", "sitting"), 3); + assert_eq!(levenshtein("", "abc"), 3); + assert_eq!(levenshtein("abc", ""), 3); + assert_eq!(levenshtein("same", "same"), 0); + } +} diff --git a/src-tauri/crates/core/src/metadata/deezer.rs b/src-tauri/crates/core/src/metadata/deezer.rs index 6c87eabb..cfb4a5e9 100644 --- a/src-tauri/crates/core/src/metadata/deezer.rs +++ b/src-tauri/crates/core/src/metadata/deezer.rs @@ -28,6 +28,12 @@ pub struct DeezerClient { #[derive(Debug, Deserialize)] pub struct DeezerSearchResponse { pub data: Vec, + /// Link to the following page, absent on the last one. Deezer sends + /// it on every list endpoint; the search paths here read one page + /// on purpose, so it is only consulted where a short answer would + /// be a wrong answer. + #[serde(default)] + pub next: Option, } /// Deezer's in-band error object. @@ -186,7 +192,7 @@ pub struct DeezerAlbumHit { pub artist: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct DeezerAlbumArtist { pub name: String, } @@ -215,6 +221,32 @@ pub struct DeezerTrackAlbum { pub cover_xl: Option, } +/// One track of an album listing. +/// +/// `duration` is in **seconds** — the only place in this file where a +/// duration is not milliseconds, and the matcher compares against +/// `track.duration_ms`, so the conversion has to happen at the border +/// rather than being noticed later as "every duration disagrees". +#[derive(Debug, Clone, Deserialize)] +pub struct DeezerAlbumTrack { + pub id: i64, + pub title: String, + pub duration: Option, + /// Position on its disc, which is what a track number means on a + /// multi-disc release. + pub track_position: Option, + pub disk_number: Option, + pub artist: Option, +} + +impl DeezerAlbumTrack { + /// The track's length in milliseconds, or `None` when the + /// catalogue did not give one. + pub fn duration_ms(&self) -> Option { + self.duration.map(|s| s * 1000) + } +} + // ── Client implementation ─────────────────────────────────────────── impl Default for DeezerClient { @@ -299,6 +331,53 @@ impl DeezerClient { Self::fetch(self.http.get(format!("{BASE_URL}/album/{deezer_id}"))).await } + /// The tracks of an album, in the catalogue's own order, **every + /// page of them**. + /// + /// `/album/{id}/tracks` rather than the `tracks` field of + /// `/album/{id}`: the embedded list is capped. But so is this one — + /// it is a paged list like every other Deezer collection, and a box + /// set read as one page comes back short with nothing in the + /// response to say it was cut. The pages are walked until the API + /// stops offering a next one. + /// + /// The following page is requested by `index`, computed here, + /// rather than by following the `next` URL the response carries: it + /// keeps every request this client makes one this code built, and + /// it costs a line. + pub async fn get_album_tracks(&self, deezer_id: i64) -> DeezerResult> { + /// Deezer's own maximum page size for a collection. + const PAGE: usize = 100; + /// Ten thousand tracks. A real release is three orders of + /// magnitude below this; the cap is here so a catalogue that + /// keeps saying "there is more" cannot spin forever. + const MAX_PAGES: usize = 100; + + let mut out: Vec = Vec::new(); + let mut index = 0usize; + for _ in 0..MAX_PAGES { + let resp: DeezerSearchResponse = Self::fetch( + self.http + .get(format!("{BASE_URL}/album/{deezer_id}/tracks")) + .query(&[("index", index.to_string()), ("limit", PAGE.to_string())]), + ) + .await?; + let received = resp.data.len(); + out.extend(resp.data); + // `next` is the authority on whether there is more — a page + // shorter than asked for is not the end, and treating it as + // one truncated the listing. The offset advances by what + // actually arrived rather than by the page size, or a short + // page would leave a hole. An empty page stops the walk + // whatever `next` claims, since nothing else would. + if resp.next.is_none() || received == 0 { + break; + } + index += received; + } + Ok(out) + } + /// Fetch artists Deezer reports as related to the given artist. /// Used as a fallback when Last.fm has no API key or returned no /// similar artists. Deezer's `/artist/{id}/related` returns a fixed diff --git a/src-tauri/crates/core/src/metadata/mod.rs b/src-tauri/crates/core/src/metadata/mod.rs index f79faa83..6d56cba9 100644 --- a/src-tauri/crates/core/src/metadata/mod.rs +++ b/src-tauri/crates/core/src/metadata/mod.rs @@ -6,6 +6,7 @@ //! both the desktop app (`crates/app`) and the future //! `waveflow-server` (RFC-001 §6.2) without any glue. +pub mod album_match; pub mod deezer; pub mod lastfm; pub mod lrclib; diff --git a/src-tauri/crates/core/src/mood.rs b/src-tauri/crates/core/src/mood.rs new file mode 100644 index 00000000..571a8986 --- /dev/null +++ b/src-tauri/crates/core/src/mood.rs @@ -0,0 +1,430 @@ +//! What a mood is, and how well a track fits one (#616). +//! +//! Mood Radio used to be a tempo window and nothing else: a track +//! inside the window was drawn as readily as any other, so Focus was +//! as likely to open on 109 BPM as on 85, and the home tile's promise +//! of "tempo and energy" was carried by a loudness ceiling on two of +//! the five moods. Two moods could also return the same kind of list, +//! because Chill's window sat entirely inside Focus's. +//! +//! The fix is to stop treating the window as the answer. It stays as a +//! gate — a track far outside a mood's tempo is not that mood — and +//! everything inside it is then **ranked by how well it fits**, so the +//! forty tracks that play are the forty best of the pool rather than +//! the first forty drawn. +//! +//! # Why the scoring lives here +//! +//! It is arithmetic over four numbers, it decides what the user hears, +//! and none of it needs a database. Keeping it in `waveflow-core` is +//! what lets it be tested against values rather than against a +//! library — the tempo octave rule below is impossible to be sure of +//! any other way. + +/// A mood, as a shape rather than a window. +/// +/// `bpm_centre` is what the mood *is*; `bpm_min` / `bpm_max` are only +/// how far from it a track may sit and still be considered. The +/// loudness bounds work the same way: they gate, and the distance from +/// the preferred side ranks. +#[derive(Debug, Clone, Copy)] +pub struct MoodProfile { + /// Inclusive tempo gate. `None` is unbounded on that side. + pub bpm_min: Option, + pub bpm_max: Option, + /// The tempo this mood is built around — the peak of the ranking. + pub bpm_centre: f64, + /// Loudness ceiling in LUFS (a negative number): quieter than this. + pub lufs_max: Option, + /// Loudness floor in LUFS: louder than this. + pub lufs_min: Option, + /// Genre words that suit the mood, matched case-insensitively as + /// substrings of the track's genres. A **bonus only** — never a + /// gate. Genre strings come from whatever tagger wrote the file, + /// so absence proves nothing and a miss must cost nothing. + pub genre_words: &'static [&'static str], +} + +/// How much a tempo read at half or double speed is discounted. +/// +/// Tempo estimators land an octave out often enough that a 170 BPM +/// track can be recorded as 85 — and a library where that happened is +/// a library where Focus quietly fills with drum'n'bass. Both +/// readings are therefore considered, and the corrected one is worth +/// less: it is a guess about a measurement, where the plain reading is +/// only a measurement. +pub const OCTAVE_PENALTY: f64 = 0.65; + +/// What an unmeasured value scores. +/// +/// Neither the reward of a match nor the cost of a miss: a track whose +/// loudness nobody has measured is not evidence that it is loud. It +/// ranks below a track measured inside the mood and above one measured +/// outside it, which is the only honest ordering — and it is what +/// keeps an unanalysed library from returning an empty radio. +pub const UNKNOWN_SCORE: f64 = 0.5; + +/// Weights of the three signals. Tempo carries the mood; loudness +/// confirms it; genre is corroboration from a field nobody validates. +const W_TEMPO: f64 = 0.6; +const W_LOUDNESS: f64 = 0.3; +const W_GENRE: f64 = 0.1; + +/// One track, as the ranking sees it. +#[derive(Debug, Clone)] +pub struct MoodCandidate { + pub track_id: i64, + /// `track.primary_artist` is nullable, and the per-artist cap has + /// to bucket the tracks that lost theirs together rather than + /// treating each as its own artist. + pub primary_artist: Option, + pub bpm: f64, + pub loudness_lufs: Option, + /// The track's genres, already lower-cased and joined — the shape + /// the query hands back. + pub genres: Option, +} + +/// The tempo reading to judge a track by, and what that reading costs. +/// +/// Returns the interpretation that best suits the profile: the plain +/// reading, or half or double it when the plain one falls outside the +/// gate and the corrected one does not. The second element is the +/// factor the tempo score is multiplied by. +pub fn effective_bpm(profile: &MoodProfile, bpm: f64) -> (f64, f64) { + if bpm <= 0.0 { + return (bpm, 1.0); + } + if in_window(profile, bpm) { + return (bpm, 1.0); + } + // Only when the plain reading is out of the window: a track that + // already fits is never reinterpreted, however well the double of + // it would score. Octave correction exists to rescue a + // misestimated track, not to move a correct one. + for candidate in [bpm * 2.0, bpm / 2.0] { + if in_window(profile, candidate) { + return (candidate, OCTAVE_PENALTY); + } + } + (bpm, 1.0) +} + +/// Does this tempo pass the mood's gate? +pub fn in_window(profile: &MoodProfile, bpm: f64) -> bool { + // `map_or(true, …)` rather than `is_none_or`, which is stable only + // since 1.82 — the workspace MSRV is 1.80, and the lint that would + // have caught it only fires on code the build actually compiles. + profile.bpm_min.map_or(true, |min| bpm >= min) && profile.bpm_max.map_or(true, |max| bpm <= max) +} + +/// Does this tempo pass the gate under any reading — plain, doubled or +/// halved? This is what the candidate query has to ask, or the octave +/// correction below would never see the tracks it exists to rescue. +pub fn in_window_any_octave(profile: &MoodProfile, bpm: f64) -> bool { + bpm > 0.0 + && (in_window(profile, bpm) + || in_window(profile, bpm * 2.0) + || in_window(profile, bpm / 2.0)) +} + +/// How well a track fits a mood, in `0.0..=1.0`. +pub fn fit_score(profile: &MoodProfile, candidate: &MoodCandidate) -> f64 { + let (bpm, octave_factor) = effective_bpm(profile, candidate.bpm); + let tempo = tempo_score(profile, bpm) * octave_factor; + let loudness = loudness_score(profile, candidate.loudness_lufs); + let genre = genre_score(profile, candidate.genres.as_deref()); + (W_TEMPO * tempo + W_LOUDNESS * loudness + W_GENRE * genre).clamp(0.0, 1.0) +} + +/// Distance from the mood's centre, normalised by the half-width of +/// its window. A track at the centre scores 1, one at the edge scores +/// close to 0, and the fall is linear because nothing about a tempo +/// preference justifies a sharper curve. +/// +/// **An unbounded side has no "too far".** Sleep has no floor, so a +/// 30 BPM drone is not a worse fit for it than one at its 52 BPM +/// centre — measuring the distance anyway scored the slowest tracks in +/// the library worst for the mood built on slowness. +fn tempo_score(profile: &MoodProfile, bpm: f64) -> f64 { + if bpm < profile.bpm_centre && profile.bpm_min.is_none() { + return 1.0; + } + if bpm > profile.bpm_centre && profile.bpm_max.is_none() { + return 1.0; + } + let reach = half_width(profile); + if reach <= 0.0 { + return 1.0; + } + (1.0 - (bpm - profile.bpm_centre).abs() / reach).clamp(0.0, 1.0) +} + +/// How far the centre sits from the furthest edge of the window. An +/// unbounded side is measured from the bounded one, so an open-ended +/// mood (Sleep has no floor) still has a scale to divide by. +fn half_width(profile: &MoodProfile) -> f64 { + let below = profile.bpm_min.map(|min| profile.bpm_centre - min); + let above = profile.bpm_max.map(|max| max - profile.bpm_centre); + match (below, above) { + (Some(a), Some(b)) => a.max(b), + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => 0.0, + } +} + +/// Where a measured loudness sits relative to the mood's bounds. +/// +/// A track inside both bounds scores 1; outside one of them the score +/// falls with the overshoot, over a fixed six-decibel run — beyond +/// that the track is simply the wrong loudness for the mood and scores +/// 0. Unmeasured loudness scores [`UNKNOWN_SCORE`]. +fn loudness_score(profile: &MoodProfile, lufs: Option) -> f64 { + let Some(lufs) = lufs else { + return UNKNOWN_SCORE; + }; + /// Decibels of overshoot that take the score from 1 to 0. + const RUN: f64 = 6.0; + let mut overshoot: f64 = 0.0; + if let Some(max) = profile.lufs_max { + overshoot = overshoot.max(lufs - max); + } + if let Some(min) = profile.lufs_min { + overshoot = overshoot.max(min - lufs); + } + (1.0 - overshoot.max(0.0) / RUN).clamp(0.0, 1.0) +} + +/// Whether any of the mood's words appear in the track's genres. +/// +/// A miss scores [`UNKNOWN_SCORE`] rather than 0, for the same reason +/// as an unmeasured loudness: most libraries carry genres nobody +/// curated, and a mood that punished every unrecognised genre would +/// rank a correctly-tagged library worse than an untagged one. +fn genre_score(profile: &MoodProfile, genres: Option<&str>) -> f64 { + if profile.genre_words.is_empty() { + return UNKNOWN_SCORE; + } + let Some(genres) = genres.filter(|g| !g.trim().is_empty()) else { + return UNKNOWN_SCORE; + }; + let haystack = genres.to_lowercase(); + if profile.genre_words.iter().any(|w| haystack.contains(w)) { + 1.0 + } else { + UNKNOWN_SCORE + } +} + +/// Rank a pool and take the best of it, one artist at a time. +/// +/// The pool arrives in random order — that is what keeps two runs of +/// the same mood from being the same list — and this picks the best +/// fits out of it. The per-artist cap is applied while walking the +/// ranking, so the tracks it skips are that artist's *weakest*, not +/// whichever the draw happened to reach first. +pub fn rank_and_cap( + profile: &MoodProfile, + candidates: Vec, + target_len: usize, + per_artist_cap: usize, +) -> Vec { + let mut scored: Vec<(f64, MoodCandidate)> = candidates + .into_iter() + .map(|c| (fit_score(profile, &c), c)) + .collect(); + // Descending fit, with the track id as a tie-break so the order is + // total: an unstable comparison over equal scores would make the + // same pool produce different queues, which is untestable and, for + // a user re-running a mood, inexplicable. + scored.sort_by(|a, b| { + b.0.partial_cmp(&a.0) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.1.track_id.cmp(&b.1.track_id)) + }); + + let mut per_artist: std::collections::HashMap, usize> = + std::collections::HashMap::new(); + let mut out = Vec::with_capacity(target_len); + for (_, c) in scored { + if out.len() >= target_len { + break; + } + let count = per_artist.entry(c.primary_artist).or_insert(0); + if *count >= per_artist_cap { + continue; + } + *count += 1; + out.push(c.track_id); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const FOCUS: MoodProfile = MoodProfile { + bpm_min: Some(70.0), + bpm_max: Some(105.0), + bpm_centre: 85.0, + lufs_max: Some(-14.0), + lufs_min: None, + genre_words: &["ambient", "classical"], + }; + + fn candidate(track_id: i64, bpm: f64, lufs: Option) -> MoodCandidate { + MoodCandidate { + track_id, + primary_artist: Some(1), + bpm, + loudness_lufs: lufs, + genres: None, + } + } + + /// The whole point of the change: inside one window, the track + /// nearer the mood's centre wins. Before this, both were equally + /// likely. + #[test] + fn a_track_nearer_the_centre_scores_higher() { + let near = fit_score(&FOCUS, &candidate(1, 85.0, Some(-16.0))); + let far = fit_score(&FOCUS, &candidate(2, 104.0, Some(-16.0))); + assert!(near > far, "near={near} far={far}"); + } + + /// A tempo read an octave out is rescued, and discounted for it. + #[test] + fn an_octave_error_is_rescued_but_discounted() { + let (bpm, factor) = effective_bpm(&FOCUS, 170.0); + assert_eq!(bpm, 85.0, "170 must be read as 85 for a mood built on 85"); + assert_eq!(factor, OCTAVE_PENALTY); + + let honest = fit_score(&FOCUS, &candidate(1, 85.0, Some(-16.0))); + let rescued = fit_score(&FOCUS, &candidate(2, 170.0, Some(-16.0))); + assert!( + rescued < honest, + "a corrected reading must rank below a plain one: {rescued} vs {honest}" + ); + assert!(rescued > 0.0, "but it must still be a candidate"); + } + + /// A track already inside the window is never reinterpreted, even + /// when the double of it would land nearer the centre. + #[test] + fn a_tempo_already_in_the_window_is_left_alone() { + let party = MoodProfile { + bpm_min: Some(60.0), + bpm_max: Some(140.0), + bpm_centre: 124.0, + lufs_max: None, + lufs_min: None, + genre_words: &[], + }; + let (bpm, factor) = effective_bpm(&party, 62.0); + assert_eq!((bpm, factor), (62.0, 1.0)); + } + + /// Unmeasured loudness is neither a pass nor a fail. This is the + /// defect the issue names: an unmeasured track used to satisfy the + /// ceiling exactly as well as a measured quiet one. + #[test] + fn unmeasured_loudness_ranks_between_measured_ones() { + let quiet = fit_score(&FOCUS, &candidate(1, 85.0, Some(-20.0))); + let unknown = fit_score(&FOCUS, &candidate(2, 85.0, None)); + let loud = fit_score(&FOCUS, &candidate(3, 85.0, Some(-4.0))); + assert!(quiet > unknown, "quiet={quiet} unknown={unknown}"); + assert!(unknown > loud, "unknown={unknown} loud={loud}"); + } + + /// A genre the mood does not name costs nothing, because most + /// libraries carry genres nobody curated. + #[test] + fn an_unrecognised_genre_costs_nothing_a_missing_one_does_not_either() { + let mut named = candidate(1, 85.0, Some(-16.0)); + named.genres = Some("ambient".into()); + let mut other = candidate(2, 85.0, Some(-16.0)); + other.genres = Some("death metal".into()); + let missing = candidate(3, 85.0, Some(-16.0)); + + let named = fit_score(&FOCUS, &named); + let other = fit_score(&FOCUS, &other); + let missing = fit_score(&FOCUS, &missing); + assert!(named > other, "a named genre is a bonus"); + assert_eq!( + other, missing, + "an unrecognised genre must cost exactly what no genre costs" + ); + } + + /// The cap skips an artist's weakest tracks, not the ones the draw + /// reached last — which is only true because the cap is applied to + /// the ranking rather than to the pool. + #[test] + fn the_per_artist_cap_keeps_the_best_of_that_artist() { + let mut far = candidate(10, 104.0, Some(-16.0)); + far.primary_artist = Some(7); + let mut near = candidate(11, 85.0, Some(-16.0)); + near.primary_artist = Some(7); + let mut other = candidate(12, 86.0, Some(-16.0)); + other.primary_artist = Some(8); + + let picked = rank_and_cap(&FOCUS, vec![far, near, other], 10, 1); + assert!(picked.contains(&11), "the artist's best must be kept"); + assert!( + !picked.contains(&10), + "its weaker track must be the one cut" + ); + assert!(picked.contains(&12), "another artist is unaffected"); + } + + /// A mood open on one side does not punish tracks that go further + /// that way. Sleep is the case: it has no floor, and scoring by + /// distance from its centre made the slowest track in the library + /// the worst fit for the mood built on slowness. + #[test] + fn an_unbounded_side_has_no_too_far() { + const SLEEP: MoodProfile = MoodProfile { + bpm_min: None, + bpm_max: Some(68.0), + bpm_centre: 52.0, + lufs_max: Some(-18.0), + lufs_min: None, + genre_words: &[], + }; + let centre = fit_score(&SLEEP, &candidate(1, 52.0, Some(-20.0))); + let slower = fit_score(&SLEEP, &candidate(2, 30.0, Some(-20.0))); + assert_eq!( + slower, centre, + "below the centre, with no floor, is as good" + ); + let edge = fit_score(&SLEEP, &candidate(3, 68.0, Some(-20.0))); + assert!( + edge < centre, + "the bounded side still ranks: {edge} vs {centre}" + ); + } + + /// Tracks that lost their artist share one bucket, rather than + /// each counting as a different artist and filling the queue. + #[test] + fn artistless_tracks_share_one_bucket() { + let mut a = candidate(1, 85.0, None); + a.primary_artist = None; + let mut b = candidate(2, 86.0, None); + b.primary_artist = None; + assert_eq!(rank_and_cap(&FOCUS, vec![a, b], 10, 1).len(), 1); + } + + /// Every octave reading of a tempo is offered to the gate, or the + /// correction above would never see the tracks it rescues. + #[test] + fn the_gate_accepts_any_octave() { + assert!(in_window_any_octave(&FOCUS, 85.0)); + assert!(in_window_any_octave(&FOCUS, 170.0), "double"); + assert!(in_window_any_octave(&FOCUS, 42.5), "half"); + assert!(!in_window_any_octave(&FOCUS, 130.0)); + assert!(!in_window_any_octave(&FOCUS, 0.0), "no tempo at all"); + } +} diff --git a/src/components/common/TagFetchModal.tsx b/src/components/common/TagFetchModal.tsx new file mode 100644 index 00000000..25589fcf --- /dev/null +++ b/src/components/common/TagFetchModal.tsx @@ -0,0 +1,735 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { X, Check, Loader2, ChevronLeft, Download } from "lucide-react"; +import { + fetchAlbumTagProposals, + searchAlbumTagSources, + TAG_FIELDS, + type AlbumProposals, + type AlbumSource, + type TagField, + type TagValues, + type TrackProposal, +} from "../../lib/tauri/tagFetch"; +import { updateTrackTags, type TrackEdit } from "../../lib/tauri/track"; +import { useModalA11y } from "../../hooks/useModalA11y"; +import { AnimatedModalContent, AnimatedModalShell } from "./AnimatedModalShell"; + +/** + * Fetch an album's tags and approve them, field by field (#599). + * + * Three grains of acceptance, because "take all the years but none of + * the titles" is the common case: one value, one track, or one field + * across the whole album. + * + * Confident matches arrive pre-accepted and doubtful ones do not — + * that is the whole reason the matcher reports two thresholds instead + * of one. A doubtful pairing is worth showing and not worth applying + * for you. + * + * Nothing is written until Apply, and Apply goes through + * `updateTrackTags` one track at a time: the path that pauses + * playback, writes through the concrete tag so non-standard frames + * survive, re-hashes and relinks. A track that fails is counted and + * the rest still run — stopping halfway through an album is what + * leaves a folder in a state nobody can describe. + */ +interface TagFetchModalProps { + isOpen: boolean; + onClose: () => void; + albumId: number; + /** Called after at least one track was written, so the view can + * refetch — the rows on screen are now stale. */ + onApplied?: () => void; +} + +/** Which fields of which tracks the user has accepted. */ +type Accepted = Record>>; + +export function TagFetchModal({ + isOpen, + onClose, + albumId, + onApplied, +}: TagFetchModalProps) { + const { t } = useTranslation(); + /** A catalogue round-trip is in flight. Shows a spinner, nothing more. */ + const [isLoading, setIsLoading] = useState(false); + /** Files are being written. This is the one that locks the modal. */ + const [isApplying, setIsApplying] = useState(false); + /** + * Dismissal, unless a **write** is in flight. + * + * Apply walks the album one file at a time and closing does not stop + * it: the loop keeps writing into a folder whose review screen is + * gone, and the summary of what was written and what failed is lost + * with it. Every path is routed through here — the X, the footer + * button, the backdrop, and the Escape key `useModalA11y` binds. + * + * A *fetch* in flight does not lock anything. Two Deezer calls can + * take ten seconds between them, and a modal that refuses Escape + * while it waits for a network it may never hear from is worse than + * one whose answer arrives to nobody. + * + * A reply that lands after the close is dropped by the token, which + * the effect below retires on the way out. + */ + const closeUnlessBusy = () => { + if (!isApplying) onClose(); + }; + const dialogRef = useModalA11y(isOpen, closeUnlessBusy); + /** + * Which fetch is the current one. + * + * Claimed when a fetch starts, retired when the modal closes, and + * checked when the reply lands — so an answer to a release the user + * has already left, or to a modal they have closed, is dropped + * instead of appearing under the wrong record. + */ + const fetchTokenRef = useRef(0); + const [sources, setSources] = useState(null); + const [proposals, setProposals] = useState(null); + const [accepted, setAccepted] = useState({}); + const [error, setError] = useState(null); + const [applied, setApplied] = useState<{ + ok: number; + failed: number; + } | null>(null); + + useEffect(() => { + if (!isOpen) { + // Retire the token on the way out, and here rather than in the + // close handler for two reasons: the handler is passed to + // `useModalA11y`, and a ref mutated inside a hook's argument is + // one the lint refuses to see mutated anywhere; and a close the + // parent decided — navigating away from the album — goes through + // no handler of ours at all. + // + // Bumping it only when a fetch *starts* left a window: a reply + // landing between the close and the next opening still passed + // its own check and wrote its proposals into state, and the next + // opening rendered them for a frame before the reset below + // cleared them — the previous album's track list, under the new + // album's name. + fetchTokenRef.current += 1; + return; + } + let alive = true; + /* eslint-disable react-hooks/set-state-in-effect */ + setSources(null); + setProposals(null); + setAccepted({}); + setApplied(null); + setError(null); + // Including the spinner. Closing mid-fetch retires the token, so + // that fetch's `finally` declines to lower `isLoading` — rightly, + // since a stale reply must not blank a newer request's spinner — + // and nothing else ever would: the flag stayed raised and the + // reopened modal showed "matching…" over a search that had ended + // long ago and would never end again. + setIsLoading(false); + // And its twin, for the session starting here — which has no write + // of its own yet. The write that may still be running belongs to + // the previous session and no longer speaks for this one: the + // token below is what stops it publishing into a screen that has + // moved on. + setIsApplying(false); + /* eslint-enable react-hooks/set-state-in-effect */ + const token = ++fetchTokenRef.current; + searchAlbumTagSources(albumId) + .then((s) => { + if (alive && fetchTokenRef.current === token) setSources(s); + }) + .catch((err) => { + if (!alive || fetchTokenRef.current !== token) return; + console.error("[TagFetch] source search failed", err); + setError(String(err)); + setSources([]); + }); + return () => { + alive = false; + }; + }, [isOpen, albumId]); + + const pickSource = async (source: AlbumSource) => { + // Nothing is picked while this album is being written. The button + // that leads here is disabled too; this is the guard that holds if + // a keyboard or a stale render gets past it. + if (isApplying) return; + const token = ++fetchTokenRef.current; + setIsLoading(true); + setError(null); + try { + const result = await fetchAlbumTagProposals(albumId, source.deezer_id); + if (fetchTokenRef.current !== token) return; + setProposals(result); + setAccepted(defaultAcceptance(result)); + } catch (err) { + if (fetchTokenRef.current !== token) return; + console.error("[TagFetch] proposals failed", err); + setError(String(err)); + } finally { + if (fetchTokenRef.current === token) setIsLoading(false); + } + }; + + const toggle = (trackId: number, field: TagField) => { + setAccepted((prev) => ({ + ...prev, + [trackId]: { ...prev[trackId], [field]: !prev[trackId]?.[field] }, + })); + }; + + // Both toggles read what they are inverting from `prev`, inside the + // updater, rather than from the copy this render closed over: two + // clicks landing before a re-render would otherwise both decide + // against the same stale snapshot and the second would repeat the + // first instead of undoing it. + const toggleTrack = (proposal: TrackProposal) => { + setAccepted((prev) => { + const fields = changedFields(proposal); + const allOn = trackFullyAccepted(proposal, prev); + return { + ...prev, + [proposal.track_id]: Object.fromEntries( + fields.map((f) => [f, !allOn]), + ) as Partial>, + }; + }); + }; + + const toggleColumn = (field: TagField) => { + if (!proposals) return; + setAccepted((prev) => { + const rows = proposals.tracks.filter((p) => + changedFields(p).includes(field), + ); + const allOn = columnFullyAccepted(proposals, prev, field); + const next = { ...prev }; + for (const p of rows) { + next[p.track_id] = { ...next[p.track_id], [field]: !allOn }; + } + return next; + }); + }; + + const pendingCount = proposals + ? proposals.tracks.reduce( + (sum, p) => + sum + + changedFields(p).filter((f) => accepted[p.track_id]?.[f]).length, + 0, + ) + : 0; + + const apply = async () => { + if (!proposals || pendingCount === 0) return; + /** + * The session this write belongs to. + * + * `AlbumDetailView` is not keyed by album, so navigating from one + * record to another while the writes run changes `albumId` under a + * modal that is still open: the reset effect takes its *opening* + * branch and the screen becomes another album's. The loop keeps + * going — those edits were accepted, and stopping halfway through + * an album is what leaves a folder nobody can describe — but from + * that moment it publishes nothing: no summary over the new + * album's screen, no refetch attributed to it, and no lowering of + * a lock that now belongs to somebody else's write. + */ + const session = fetchTokenRef.current; + setIsApplying(true); + setError(null); + let ok = 0; + let failed = 0; + try { + for (const proposal of proposals.tracks) { + const fields = changedFields(proposal).filter( + (f) => accepted[proposal.track_id]?.[f], + ); + if (fields.length === 0) continue; + const edit: TrackEdit = {}; + for (const field of fields) { + // Only the accepted fields are sent: `update_track_tags` + // leaves an omitted field alone, which is what makes + // accepting one value of one track mean exactly that. + Object.assign(edit, editFragment(proposal, field)); + } + try { + await updateTrackTags(proposal.track_id, edit); + ok += 1; + } catch (err) { + console.error("[TagFetch] write failed", proposal.track_id, err); + failed += 1; + } + } + if (fetchTokenRef.current === session) { + setApplied({ ok, failed }); + if (ok > 0) onApplied?.(); + } + } finally { + // Whatever happened — including a throw from outside the + // per-track guard above — this session's lock comes off. + // Leaving it on is a modal nothing can close; lowering another + // session's is a write nothing guards. + if (fetchTokenRef.current === session) setIsApplying(false); + } + }; + + return ( + + +
+
+ {proposals && !applied && ( + + )} + +

+ {t("tagFetch.title")} +

+
+ +
+ +
+ {applied ? ( + + ) : proposals ? ( + + ) : ( + + )} + {error &&

{error}

} +
+ +
+ + {proposals && !applied && t("tagFetch.hint")} + +
+ + {proposals && !applied && ( + + )} +
+
+
+
+ ); +} + +// ============================================================================= +// Steps +// ============================================================================= + +function SourceList({ + sources, + busy, + onPick, +}: { + sources: AlbumSource[] | null; + busy: boolean; + onPick: (s: AlbumSource) => void; +}) { + const { t } = useTranslation(); + if (sources == null || busy) { + return ( +

+ + {busy ? t("tagFetch.matching") : t("tagFetch.searching")} +

+ ); + } + if (sources.length === 0) { + return ( +

+ {t("tagFetch.noSources")} +

+ ); + } + return ( + <> +

+ {t("tagFetch.sourcesHint")} +

+
    + {sources.map((source) => ( +
  • + +
  • + ))} +
+ + ); +} + +function ReviewList({ + proposals, + accepted, + onToggle, + onToggleTrack, + onToggleColumn, +}: { + proposals: AlbumProposals; + accepted: Accepted; + onToggle: (trackId: number, field: TagField) => void; + onToggleTrack: (p: TrackProposal) => void; + onToggleColumn: (field: TagField) => void; +}) { + const { t } = useTranslation(); + const changedAnywhere = TAG_FIELDS.filter((field) => + proposals.tracks.some((p) => changedFields(p).includes(field)), + ); + + if (changedAnywhere.length === 0) { + return ( +

+ {t("tagFetch.nothingToChange")} +

+ ); + } + + return ( + <> + {/* One field across the whole album — the grain that makes "all + the years, none of the titles" one click instead of twelve. */} +
+ + {t("tagFetch.acceptColumn")} + + {changedAnywhere.map((field) => { + // A toggle with no state tells the reader nothing about what + // the next click will do — they have to infer it from the + // checkboxes below. `aria-pressed` says it outright, and the + // filled style says it to everyone else. + const allOn = columnFullyAccepted(proposals, accepted, field); + return ( + + ); + })} +
+ +
    + {proposals.tracks.map((proposal) => { + const fields = changedFields(proposal); + return ( +
  • +
    + + {proposal.current.title || proposal.file_name} + + {proposal.confidence && ( + + {t(`tagFetch.confidence.${proposal.confidence}`)} + + )} + {fields.length > 0 && ( + + )} +
    + + {proposal.fetched == null ? ( +

    + {t("tagFetch.noMatch", { file: proposal.file_name })} +

    + ) : fields.length === 0 ? ( +

    + {t("tagFetch.trackUnchanged")} +

    + ) : ( +
      + {fields.map((field) => ( +
    • + onToggle(proposal.track_id, field)} + className="accent-sky-500" + /> + +
    • + ))} +
    + )} +
  • + ); + })} +
+ + {proposals.unmatched_remote.length > 0 && ( +

+ {t("tagFetch.unmatchedRemote", { + titles: proposals.unmatched_remote.join(", "), + })} +

+ )} + + ); +} + +function AppliedSummary({ + applied, +}: { + applied: { ok: number; failed: number }; +}) { + const { t } = useTranslation(); + return ( +
+

+ + {t("tagFetch.appliedCount", { count: applied.ok })} +

+ {applied.failed > 0 && ( +

+ {t("tagFetch.failedCount", { count: applied.failed })} +

+ )} +
+ ); +} + +// ============================================================================= +// Field helpers +// ============================================================================= + +/** + * Is every field this track would change already accepted? + * + * Takes the map it should judge rather than reading one from a + * closure, so the updaters can ask it about `prev` and the render can + * ask it about the current state — one answer, two callers, no way for + * them to disagree. + */ +function trackFullyAccepted(proposal: TrackProposal, accepted: Accepted) { + const fields = changedFields(proposal); + return ( + fields.length > 0 && fields.every((f) => accepted[proposal.track_id]?.[f]) + ); +} + +/** The same question about one field, across every track that changes it. */ +function columnFullyAccepted( + proposals: AlbumProposals, + accepted: Accepted, + field: TagField, +) { + const rows = proposals.tracks.filter((p) => changedFields(p).includes(field)); + return rows.length > 0 && rows.every((p) => accepted[p.track_id]?.[field]); +} + +/** The fields where the catalogue says something different. */ +function changedFields(proposal: TrackProposal): TagField[] { + if (!proposal.fetched) return []; + const fetched = proposal.fetched; + return TAG_FIELDS.filter((field) => { + const next = fetched[field]; + // A field the catalogue does not carry is not a change to nothing: + // offering it would invite replacing a value the user typed with a + // blank. + if (next == null || next === "") return false; + return String(next) !== String(proposal.current[field] ?? ""); + }); +} + +function display(values: TagValues, field: TagField): string { + const value = values[field]; + return value == null ? "" : String(value); +} + +/** The `TrackEdit` fragment for one accepted field. */ +function editFragment(proposal: TrackProposal, field: TagField): TrackEdit { + const value = proposal.fetched?.[field]; + switch (field) { + case "title": + return { title: value as string }; + case "artist": + return { artist: value as string }; + case "album": + return { album: value as string }; + case "year": + return { year: value as number }; + case "track_number": + return { track_number: value as number }; + } +} + +/** The library's one spelling for a multi-artist credit. */ +const MULTI_ARTIST_SEPARATOR = "; "; + +/** + * What arrives pre-accepted. + * + * Confident matches, and only those: a doubtful pairing is worth + * showing and not worth applying on the user's behalf, which is the + * entire reason the matcher reports two thresholds rather than one. + * + * With one exception. Deezer gives a track **one** artist, and a local + * credit of "A; B" therefore always reads as a change — so a confident + * match would arrive with "replace both names with the first" already + * ticked. The row stays visible and can still be accepted by hand; it + * is only the default that refuses to throw away a credit the library + * models better than the catalogue does. + */ +function defaultAcceptance(proposals: AlbumProposals): Accepted { + const out: Accepted = {}; + for (const proposal of proposals.tracks) { + if (proposal.confidence !== "confident") continue; + const multiArtist = (proposal.current.artist ?? "").includes( + MULTI_ARTIST_SEPARATOR, + ); + const fields = changedFields(proposal).filter( + (f) => !(f === "artist" && multiArtist), + ); + if (fields.length === 0) continue; + out[proposal.track_id] = Object.fromEntries( + fields.map((f) => [f, true]), + ) as Partial>; + } + return out; +} diff --git a/src/components/views/AlbumDetailView.tsx b/src/components/views/AlbumDetailView.tsx index 4157d99c..752d026c 100644 --- a/src/components/views/AlbumDetailView.tsx +++ b/src/components/views/AlbumDetailView.tsx @@ -7,6 +7,7 @@ import { Music2, Heart, ImageIcon, + DownloadCloud, Film, } from "lucide-react"; import { @@ -20,6 +21,7 @@ import { ArtistLink } from "../common/ArtistLink"; import { EmptyState } from "../common/EmptyState"; import { DetailViewSkeleton } from "../common/DetailViewSkeleton"; import { CreatePlaylistModal } from "../common/CreatePlaylistModal"; +import { TagFetchModal } from "../common/TagFetchModal"; import { CoverPickerModal } from "../common/CoverPickerModal"; import { MotionCoverPickerModal } from "../common/MotionCoverPickerModal"; import { HiResBadge } from "../common/HiResBadge"; @@ -167,6 +169,7 @@ export function AlbumDetailView({ // one-frame "album not found" flash before the fetch schedules. const [isLoading, setIsLoading] = useState(true); const [likedIds, setLikedIds] = useState>(new Set()); + const [isTagFetchOpen, setIsTagFetchOpen] = useState(false); const [isCreatePlaylistModalOpen, setIsCreatePlaylistModalOpen] = useState(false); const [isCoverPickerOpen, setIsCoverPickerOpen] = useState(false); @@ -535,6 +538,15 @@ export function AlbumDetailView({ {t("albumDetail.setMotionCover")} + )} @@ -578,6 +590,16 @@ export function AlbumDetailView({ /> )} + {!remote && albumId != null && ( + setIsTagFetchOpen(false)} + albumId={albumId} + // The rows on screen carry the old tags; a write makes them + // stale, and this is the same stamp a tag edit already bumps. + onApplied={() => setEditRefetch((n) => n + 1)} + /> + )} setIsCreatePlaylistModalOpen(false)} diff --git a/src/components/views/home/MoodRadioGrid.tsx b/src/components/views/home/MoodRadioGrid.tsx index 6dfde482..fbf574db 100644 --- a/src/components/views/home/MoodRadioGrid.tsx +++ b/src/components/views/home/MoodRadioGrid.tsx @@ -93,14 +93,22 @@ export function MoodRadioGrid() { } }; - const totalAnalysed = counts + const eligible = counts ? counts.focus + counts.chill + counts.workout + counts.party + counts.sleep : 0; // When no mood matches anything, the library either has no BPM // analysis at all or only a handful of analysed tracks. In that // case we hide the section entirely instead of showing a row of // disabled tiles — feels less broken. - if (counts != null && totalAnalysed === 0) return null; + if (counts != null && eligible === 0) return null; + + // Every mood draws from the analysed part of the library, so a + // partly-analysed one gives thin radios for a reason the tiles + // cannot show: their counts look small without saying small *of + // what*. Shown only while the two numbers differ — once everything + // is analysed the line has nothing to add. + const partlyAnalysed = + counts != null && counts.analysed_tracks < counts.total_tracks; return (
{t("home.moodRadio.title")} - + {t("home.moodRadio.subtitle")} + {partlyAnalysed && ( + <> +
+ {/* `count` is the total, because that is the number the + sentence's noun agrees with — Russian and Arabic + inflect "tracks" on it, and a fixed form is wrong for + most of the values this line actually shows. */} + {t("home.moodRadio.coverage", { + count: counts.total_tracks, + analysed: counts.analysed_tracks, + })} + + )}
{ return invoke("start_mood_radio", { mood }); } -/** How many qualifying tracks each mood would yield, given the - * library's current state of BPM/loudness analysis. */ +/** + * How many tracks each mood could draw from, plus how much of the + * library has been analysed at all. + * + * The per-mood numbers answer the tempo gate only — loudness and genre + * rank rather than exclude, so they cannot make a mood empty. The + * coverage pair is what lets the UI say *why* a mood is thin instead + * of leaving the user to guess. + */ export function moodRadioCounts(): Promise { return invoke("mood_radio_counts"); } diff --git a/src/lib/tauri/tagFetch.ts b/src/lib/tauri/tagFetch.ts new file mode 100644 index 00000000..b1face38 --- /dev/null +++ b/src/lib/tauri/tagFetch.ts @@ -0,0 +1,94 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** + * Fetching an album's tags from Deezer, for review (#599). + * + * Two steps on purpose. A title and an artist match several releases of + * the same record — an original, a remaster, a deluxe edition with four + * more tracks — and they carry different track lists, so which release + * this is stays the user's call. + * + * Neither call writes anything. What the review screen accepts is + * applied through `updateTrackTags`, the path that pauses playback + * before opening the file, writes through the concrete tag so + * non-standard frames survive, re-hashes and relinks the rows. + */ + +/** One catalogue release that might be this record. */ +export interface AlbumSource { + deezer_id: number; + title: string; + artist: string | null; + track_count: number | null; + year: number | null; + cover_url: string | null; +} + +/** + * The values a fetch can offer for one track. + * + * Every field is optional on both sides: the file may not carry it and + * the catalogue may not either. Composer, genre and disc number are + * absent because Deezer cannot fill them reliably, and a review screen + * that lists a field the source cannot fill invites accepting a blank + * over something the user typed. + */ +export interface TagValues { + title: string | null; + artist: string | null; + album: string | null; + year: number | null; + track_number: number | null; +} + +/** The fields the review screen can accept, one at a time. */ +export const TAG_FIELDS = [ + "title", + "artist", + "album", + "year", + "track_number", +] as const; +export type TagField = (typeof TAG_FIELDS)[number]; + +/** How sure the matcher is about a pairing. */ +export type MatchConfidence = "confident" | "doubtful"; + +export interface TrackProposal { + track_id: number; + /** Shown for a track nothing matched, where a title alone would not + * tell the user which file is meant. */ + file_name: string; + current: TagValues; + /** `null` when nothing in the release matched this file well enough. + * A real answer — the screen must not apply "the best available". */ + fetched: TagValues | null; + score: number | null; + confidence: MatchConfidence | null; +} + +export interface AlbumProposals { + album_id: number; + deezer_id: number; + /** Tracks in the album's own order, matched or not. */ + tracks: TrackProposal[]; + /** Catalogue tracks no local file claimed — a deluxe edition's + * extras, or the songs a partial rip is missing. */ + unmatched_remote: string[]; +} + +/** Catalogue releases that might be this album. */ +export function searchAlbumTagSources(albumId: number): Promise { + return invoke("search_album_tag_sources", { albumId }); +} + +/** Pair the chosen release's tracks with the local files. */ +export function fetchAlbumTagProposals( + albumId: number, + deezerAlbumId: number, +): Promise { + return invoke("fetch_album_tag_proposals", { + albumId, + deezerAlbumId, + }); +}