Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/features/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ Tag edits go through the same funnel: [`edit.rs`](../../src-tauri/crates/app/src

## Browsing

- **Library tabs** — Morceaux, Albums, Artistes, Genres, Playlists, Dossiers; each tab keeps its own scroll position and sort memory (per profile). Five of the six fetch their own data; **Playlists reads `PlaylistContext`** instead ([`PlaylistGrid`](../../src/components/views/library/PlaylistGrid.tsx), issue #461), so it has no query, no loading state, and no skeleton — the sidebar has already loaded the same rows. It shows **user playlists only** (`is_smart === 0`); the generated ones stay in Home's "Made for you" carousel, matching how `HomeView` filters them. Tiles are row-virtualized against the page scroller like the albums grid, sorted by the sidebar's manual order (`playlist.position`) by default, and fall back to the playlist's icon + colour tile when it has no cover — the same visual the sidebar row uses, so one playlist looks the same in both places. The remaining five tab queries (`list_tracks` / `list_albums` / `list_artists` / `list_genres` / `list_folders`) fire in parallel on first mount so subsequent tab switches hit cached React state instantly instead of paying a fresh SQL round-trip; the first paint shows a layout-shaped `LibraryTabSkeleton` (`role="status"` / `aria-busy="true"`) until the data lands, never the EmptyState. Browse queries lean on partial indexes `idx_track_album_available` / `idx_track_primary_artist_available` (`WHERE is_available = 1`) so the GROUP BY aggregates stay index-only on healthy libraries. The Dossiers tab is the one whose content the tab query only starts: `list_folders` lists the roots, and descending into one switches to the folder browser described below. Clicking a genre tile opens a Spotify-style genre detail page (`get_genre_detail` in [`browse.rs`](../../src-tauri/crates/app/src/commands/browse.rs)) with every track tagged with that genre, sorted Artist → Album → Disc → Track. **Manual genre picture** (issue #424): genres have no automatic/embedded artwork source of their own, so `genre.artwork_id` only gets a value when the user sets one — a `Pencil` overlay on the grid tile and the detail-page header opens [`GenreImagePickerModal`](../../src/components/common/GenreImagePickerModal.tsx) (`set_genre_artwork_from_file` / `clear_genre_artwork`), which validates the file's magic bytes (jpg/png/webp, shared `detect_image_format` helper) and stores it hash-addressed in the same per-profile `artwork/` dir as album/artist covers, tagged `source = 'manual'`.
- **Library tabs** — Morceaux, Albums, Artistes, Genres, Années, Playlists, Dossiers; each tab keeps its own scroll position and, where it can be sorted, its sort memory (per profile). Six of the seven fetch their own data; **Playlists reads `PlaylistContext`** instead ([`PlaylistGrid`](../../src/components/views/library/PlaylistGrid.tsx), issue #461), so it has no query, no loading state, and no skeleton — the sidebar has already loaded the same rows. It shows **user playlists only** (`is_smart === 0`); the generated ones stay in Home's "Made for you" carousel, matching how `HomeView` filters them. Tiles are row-virtualized against the page scroller like the albums grid, sorted by the sidebar's manual order (`playlist.position`) by default, and fall back to the playlist's icon + colour tile when it has no cover — the same visual the sidebar row uses, so one playlist looks the same in both places. The remaining six tab queries (`list_tracks` / `list_albums` / `list_artists` / `list_genres` / `list_years` / `list_folders`) fire in parallel on first mount so subsequent tab switches hit cached React state instantly instead of paying a fresh SQL round-trip; the first paint shows a layout-shaped `LibraryTabSkeleton` (`role="status"` / `aria-busy="true"`) until the data lands, never the EmptyState. Browse queries lean on partial indexes `idx_track_album_available` / `idx_track_primary_artist_available` (`WHERE is_available = 1`) so the GROUP BY aggregates stay index-only on healthy libraries. The Dossiers tab is the one whose content the tab query only starts: `list_folders` lists the roots, and descending into one switches to the folder browser described below. Clicking a genre tile opens a Spotify-style genre detail page (`get_genre_detail` in [`browse.rs`](../../src-tauri/crates/app/src/commands/browse.rs)) with every track tagged with that genre, sorted Artist → Album → Disc → Track. **Manual genre picture** (issue #424): genres have no automatic/embedded artwork source of their own, so `genre.artwork_id` only gets a value when the user sets one — a `Pencil` overlay on the grid tile and the detail-page header opens [`GenreImagePickerModal`](../../src/components/common/GenreImagePickerModal.tsx) (`set_genre_artwork_from_file` / `clear_genre_artwork`), which validates the file's magic bytes (jpg/png/webp, shared `detect_image_format` helper) and stores it hash-addressed in the same per-profile `artwork/` dir as album/artist covers, tagged `source = 'manual'`.
- **Years tab** — [`years.rs`](../../src-tauri/crates/app/src/commands/years.rs). `list_years` groups the available tracks by the year their tags give, newest first, with the tracks that have none last under "Unknown year"; a year outside 1–9999 (a `0`, a full date pasted into the field) counts as none rather than as a year of its own. Each year carries its track and album counts and the cover of the album holding most of its tracks, the lowest album id breaking a tie so the tile does not change between loads. The tab lays the years out in a section per decade. A tile opens the year page, `get_year_detail`, which lists the tracks through the same query the genre page uses (`detail_tracks_sql` / `detail_track` in `browse.rs`), filtered on the same year expression, and shows the cover the tile did. `album.year` is not used: an album's tracks can carry different years, and the tab lists tracks.
- **Bulk list endpoints wire format** — `list_tracks` / `list_playlist_tracks` / `list_liked_tracks` (track-shaped) and `list_albums` / `list_artists` (browse-shaped) all return `{ artwork_base, items: <Slim>[] }` instead of the full row shape (artists additionally include `metadata_artwork_base` for the Deezer cache). Each slim row carries `artwork_hash` + `artwork_format` + `artwork_has_1x` + `artwork_has_2x` (artists also `picture_hash` + `picture_has_*`) instead of three full path strings; the ~70-char per-profile prefix only appears once in the response. Frontend wrappers ([`expandTrackResponse`](../../src/lib/tauri/track.ts), `expandAlbumRow` / `expandArtistRow` in [`browse.ts`](../../src/lib/tauri/browse.ts)) stitch the absolute paths back together so every UI consumer keeps the full `Track` / `AlbumRow` / `ArtistRow` shape unchanged. Cuts ~30 % off each payload (e.g. ≈ 1.0 MB → ≈ 700 kB on a 1k-track `list_tracks`, ≈ 650 kB → ≈ 250 kB on a 900-artist `list_artists`), proportionally shrinking JSON parse + IPC transfer time. Any future bulk endpoint shipping artworks for hundreds of rows should adopt the same `{ artwork_base, items }` shape.
- **Artist page** — its track list comes from `list_tracks` with an `artist_id`, matched **by row id**: every track that credits the artist through `track_artist`, in any position, plus every track of an album whose album artist they are. It used to load the whole library and keep the rows whose display string, split on `", "`, contained the name — so "Earth, Wind & Fire" had no tracks, the multi-artist rule broken on the one page that needed it. [`get_artist_detail`](../../src-tauri/crates/app/src/commands/browse.rs) counts and lists albums the same way, so an artist who is only ever an album artist ("Various Artists", reached from a compilation's page) gets a page instead of "artist not found".
- **Single-click play** — with `ui.single_click_play` on, the first click of a row plays it; the second click of a double-click (`detail > 1`) and the row's `dblclick` stand down, so a double-click out of habit is one play instead of three.
Expand Down Expand Up @@ -224,7 +225,7 @@ An id of the form `tag:<key>` shows a frame from the user's files. Keys are offe
Five things the implementation has to get right:

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

/// One track row of a detail page that lists tracks across albums (a
/// genre, a year), shaped by [`detail_tracks_sql`].
#[derive(FromRow)]
struct GenreTrackRaw {
id: i64,
pub(crate) struct DetailTrackRaw {
pub(crate) id: i64,
library_id: i64,
title: String,
album_id: Option<i64>,
Expand Down Expand Up @@ -2940,7 +2942,41 @@ pub async fn get_genre_detail(
_ => (None, None, None),
};

let rows = sqlx::query_as::<_, GenreTrackRaw>(
let rows = sqlx::query_as::<_, DetailTrackRaw>(detail_tracks_sql(
"JOIN track_genre tg ON tg.track_id = t.id",
"tg.genre_id = ?",
))
.bind(genre_id)
.fetch_all(&*pool)
.await?;

let tracks: Vec<crate::commands::track::Track> = rows
.into_iter()
.map(|row| detail_track(row, &artwork_dir))
.collect();

let track_count = tracks.len() as i64;
let total_duration_ms = tracks.iter().map(|t| t.duration_ms).sum();

Ok(GenreDetail {
id: header.id,
name: header.name,
track_count,
total_duration_ms,
artwork_path,
artwork_path_1x,
artwork_path_2x,
tracks,
})
}

/// The track query of a detail page: every available track that `join`
/// and `filter` keep, ordered by artist → album → disc → track number to
/// match `list_tracks`'s default layout. `filter` binds what the caller
/// binds. Both fragments are literals of this crate, never user input,
/// which is what makes the assertion of safety true.
pub(crate) fn detail_tracks_sql(join: &str, filter: &str) -> sqlx::AssertSqlSafe<String> {
sqlx::AssertSqlSafe(format!(
r#"
SELECT t.id, t.library_id, t.title,
t.album_id,
Expand All @@ -2965,80 +3001,65 @@ pub async fn get_genre_detail(
aw.format AS artwork_format,
t.rating AS rating
FROM track t
JOIN track_genre tg ON tg.track_id = t.id
{join}
LEFT JOIN album al ON al.id = t.album_id
LEFT JOIN artist ar ON ar.id = t.primary_artist
LEFT JOIN artwork aw ON aw.id = al.artwork_id
WHERE tg.genre_id = ? AND t.is_available = 1
WHERE {filter} AND t.is_available = 1
ORDER BY ar.canonical_name COLLATE NAME,
al.canonical_title COLLATE NAME,
t.disc_number,
t.track_number,
t.title COLLATE FOLD
"#,
)
.bind(genre_id)
.fetch_all(&*pool)
.await?;
"#
))
}

let tracks: Vec<crate::commands::track::Track> = rows
.into_iter()
.map(|row| {
let (artwork_path, artwork_path_1x, artwork_path_2x) =
match (row.artwork_hash.as_deref(), row.artwork_format.as_deref()) {
(Some(hash), Some(format)) => {
let full = artwork_dir
.join(format!("{}.{}", hash, format))
.to_string_lossy()
.to_string();
let (p1, p2) = crate::thumbnails::thumbnail_paths_for(&artwork_dir, hash);
(Some(full), p1, p2)
}
_ => (None, None, None),
};
crate::commands::track::Track {
id: row.id,
library_id: row.library_id,
title: row.title,
album_id: row.album_id,
album_title: row.album_title,
artist_id: row.artist_id,
artist_name: row.artist_name,
artist_ids: row.artist_ids,
duration_ms: row.duration_ms,
track_number: row.track_number,
disc_number: row.disc_number,
year: row.year,
bitrate: row.bitrate,
sample_rate: row.sample_rate,
channels: row.channels,
bit_depth: row.bit_depth,
codec: row.codec,
musical_key: row.musical_key,
file_path: row.file_path,
file_size: row.file_size,
added_at: row.added_at,
artwork_path,
artwork_path_1x,
artwork_path_2x,
rating: row.rating,
/// A [`DetailTrackRaw`] as the `Track` the frontend plays, its cover
/// resolved under `artwork_dir`.
pub(crate) fn detail_track(
row: DetailTrackRaw,
artwork_dir: &std::path::Path,
) -> crate::commands::track::Track {
let (artwork_path, artwork_path_1x, artwork_path_2x) =
match (row.artwork_hash.as_deref(), row.artwork_format.as_deref()) {
(Some(hash), Some(format)) => {
let full = artwork_dir
.join(format!("{}.{}", hash, format))
.to_string_lossy()
.to_string();
let (p1, p2) = crate::thumbnails::thumbnail_paths_for(artwork_dir, hash);
(Some(full), p1, p2)
}
})
.collect();

let track_count = tracks.len() as i64;
let total_duration_ms = tracks.iter().map(|t| t.duration_ms).sum();

Ok(GenreDetail {
id: header.id,
name: header.name,
track_count,
total_duration_ms,
_ => (None, None, None),
};
crate::commands::track::Track {
id: row.id,
library_id: row.library_id,
title: row.title,
album_id: row.album_id,
album_title: row.album_title,
artist_id: row.artist_id,
artist_name: row.artist_name,
artist_ids: row.artist_ids,
duration_ms: row.duration_ms,
track_number: row.track_number,
disc_number: row.disc_number,
year: row.year,
bitrate: row.bitrate,
sample_rate: row.sample_rate,
channels: row.channels,
bit_depth: row.bit_depth,
codec: row.codec,
musical_key: row.musical_key,
file_path: row.file_path,
file_size: row.file_size,
added_at: row.added_at,
artwork_path,
artwork_path_1x,
artwork_path_2x,
tracks,
})
rating: row.rating,
}
}

// ─── Play history (Last.fm-style chronological scrubber) ─────────
Expand Down
1 change: 1 addition & 0 deletions src-tauri/crates/app/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod edit;
pub mod integration;
pub mod library;
pub mod library_media;
pub mod years;
// Loopback HTTP listener for the OAuth callback. Its only consumer is
// the server account binding, so it carries the same gate — it used to
// stay ungated because the Spotify handshake needed it in a build
Expand Down
Loading
Loading