From 838023b95a5dc1761e601d5985bb85b1dc4891bc Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 11 Oct 2026 21:00:39 +0200 Subject: [PATCH 1/2] feat(library): a years tab grouped by decade list_years groups available tracks by the year their tags give, newest first with an unknown-year bucket last, each lent the cover of the album holding most of it; get_year_detail lists one year through the track query the genre page now shares. The tab shows a section per decade and opens a year page. --- docs/features/library.md | 5 +- src-tauri/crates/app/src/commands/browse.rs | 149 +++++---- src-tauri/crates/app/src/commands/mod.rs | 1 + src-tauri/crates/app/src/commands/years.rs | 326 ++++++++++++++++++++ src-tauri/crates/app/src/lib.rs | 2 + src/components/layout/AppLayout.tsx | 26 ++ src/components/layout/Sidebar.tsx | 9 + src/components/views/GenreDetailView.tsx | 6 +- src/components/views/LibraryView.tsx | 128 +++++++- src/components/views/YearDetailView.tsx | 234 ++++++++++++++ src/i18n/locales/ar.json | 23 ++ src/i18n/locales/de.json | 20 ++ src/i18n/locales/en.json | 20 ++ src/i18n/locales/es.json | 21 ++ src/i18n/locales/fr.json | 21 ++ src/i18n/locales/hi.json | 20 ++ src/i18n/locales/id.json | 20 ++ src/i18n/locales/it.json | 21 ++ src/i18n/locales/ja.json | 20 ++ src/i18n/locales/ko.json | 20 ++ src/i18n/locales/nl.json | 20 ++ src/i18n/locales/pt-BR.json | 21 ++ src/i18n/locales/pt.json | 21 ++ src/i18n/locales/ru.json | 22 ++ src/i18n/locales/tr.json | 20 ++ src/i18n/locales/zh-CN.json | 20 ++ src/i18n/locales/zh-TW.json | 20 ++ src/lib/tauri/browse.ts | 50 +++ src/lib/tauri/detail.ts | 17 + src/types/index.ts | 2 + 30 files changed, 1231 insertions(+), 74 deletions(-) create mode 100644 src-tauri/crates/app/src/commands/years.rs create mode 100644 src/components/views/YearDetailView.tsx diff --git a/docs/features/library.md b/docs/features/library.md index db5ae11fa..5af0e6004 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -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: [] }` 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. @@ -224,7 +225,7 @@ An id of the form `tag:` 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. diff --git a/src-tauri/crates/app/src/commands/browse.rs b/src-tauri/crates/app/src/commands/browse.rs index d1149b1bd..44015ba85 100644 --- a/src-tauri/crates/app/src/commands/browse.rs +++ b/src-tauri/crates/app/src/commands/browse.rs @@ -2871,9 +2871,11 @@ struct GenreHeaderRaw { artwork_format: Option, } +/// 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, @@ -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 = 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 { + sqlx::AssertSqlSafe(format!( r#" SELECT t.id, t.library_id, t.title, t.album_id, @@ -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 = 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) ───────── diff --git a/src-tauri/crates/app/src/commands/mod.rs b/src-tauri/crates/app/src/commands/mod.rs index 307b8e2f0..3e6ab2310 100644 --- a/src-tauri/crates/app/src/commands/mod.rs +++ b/src-tauri/crates/app/src/commands/mod.rs @@ -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 diff --git a/src-tauri/crates/app/src/commands/years.rs b/src-tauri/crates/app/src/commands/years.rs new file mode 100644 index 000000000..469a3af87 --- /dev/null +++ b/src-tauri/crates/app/src/commands/years.rs @@ -0,0 +1,326 @@ +//! The library's Years tab: tracks grouped by the year their tags give, +//! and the tracks of one year. +//! +//! A year outside 1–9999 is a tag nobody meant (`0`, a full date pasted +//! into the field) and is counted with the tracks that have none, under +//! "Unknown year", rather than as a year of its own. + +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::Serialize; +use sqlx::FromRow; + +use crate::commands::browse::{detail_track, detail_tracks_sql, DetailTrackRaw}; +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +/// The year a track is filed under, or `NULL` for "Unknown year". +const TRACK_YEAR: &str = "CASE WHEN t.year BETWEEN 1 AND 9999 THEN t.year END"; + +/// One year of the Years tab: how much it holds, and the cover of the +/// album with the most tracks in it to stand for it. +#[derive(Debug, Clone, Serialize)] +pub struct YearRow { + /// `None` gathers the tracks without a usable year. + pub year: Option, + pub track_count: i64, + pub album_count: i64, + pub artwork_hash: Option, + pub artwork_format: Option, + pub artwork_has_1x: bool, + pub artwork_has_2x: bool, +} + +/// Same `artwork_base` economy as `ListGenresResponse`. +#[derive(Debug, Clone, Serialize)] +pub struct ListYearsResponse { + pub artwork_base: String, + pub items: Vec, +} + +#[derive(FromRow)] +struct YearRawRow { + year: Option, + track_count: i64, + album_count: i64, + artwork_hash: Option, + artwork_format: Option, +} + +/// Newest first, "Unknown year" last. The cover is the album with the most +/// of the year's tracks, the lowest id breaking a tie so it does not change +/// between two loads. Built from constants only. +fn list_years_sql() -> sqlx::AssertSqlSafe { + sqlx::AssertSqlSafe(format!( + r#" + WITH dated AS ( + SELECT t.album_id, {TRACK_YEAR} AS year + FROM track t + WHERE (? IS NULL OR t.library_id = ?) AND t.is_available = 1 + ), + years AS ( + SELECT year, + COUNT(*) AS track_count, + COUNT(DISTINCT album_id) AS album_count + FROM dated + GROUP BY year + ), + covers AS ( + SELECT d.year, al.artwork_id, + ROW_NUMBER() OVER ( + PARTITION BY d.year ORDER BY COUNT(*) DESC, al.id + ) AS rank + FROM dated d + JOIN album al ON al.id = d.album_id + WHERE al.artwork_id IS NOT NULL + GROUP BY d.year, al.id + ) + SELECT y.year, y.track_count, y.album_count, + aw.hash AS artwork_hash, + aw.format AS artwork_format + FROM years y + LEFT JOIN covers c ON c.year IS y.year AND c.rank = 1 + LEFT JOIN artwork aw ON aw.id = c.artwork_id + ORDER BY y.year IS NULL, y.year DESC + "# + )) +} + +/// Every year that has at least one available track in the given library +/// (all of them when `None`). +#[tauri::command] +pub async fn list_years( + state: tauri::State<'_, AppState>, + library_id: Option, +) -> AppResult { + let pool = state.require_profile_pool().await?; + let profile_id = state.require_profile_id().await?; + let artwork_dir = state.paths.profile_artwork_dir(profile_id); + + let raw = sqlx::query_as::<_, YearRawRow>(list_years_sql()) + .bind(library_id) + .bind(library_id) + .fetch_all(&*pool) + .await?; + + let items = expand_year_rows(raw, artwork_dir.clone()).await?; + Ok(ListYearsResponse { + artwork_base: artwork_dir.to_string_lossy().into_owned(), + items, + }) +} + +/// Thumbnail-existence flags, off the async thread like `expand_genre_rows`. +async fn expand_year_rows(raw: Vec, artwork_dir: PathBuf) -> AppResult> { + tokio::task::spawn_blocking(move || { + raw.into_iter() + .map(|row| { + let (artwork_has_1x, artwork_has_2x) = match row.artwork_hash.as_deref() { + Some(hash) => { + let (p1, p2) = crate::thumbnails::thumbnail_paths_for(&artwork_dir, hash); + (p1.is_some(), p2.is_some()) + } + None => (false, false), + }; + YearRow { + year: row.year, + track_count: row.track_count, + album_count: row.album_count, + artwork_hash: row.artwork_hash, + artwork_format: row.artwork_format, + artwork_has_1x, + artwork_has_2x, + } + }) + .collect() + }) + .await + .map_err(|e| AppError::Other(format!("year row expand join: {e}"))) +} + +#[derive(Debug, Clone, Serialize)] +pub struct YearDetail { + pub year: Option, + pub track_count: i64, + pub total_duration_ms: i64, + pub artwork_path: Option, + pub artwork_path_1x: Option, + pub artwork_path_2x: Option, + pub tracks: Vec, +} + +/// The tracks of one year across the active profile (`None`: those with +/// no usable year), with the cover the album holding most of them carries. +#[tauri::command] +pub async fn get_year_detail( + state: tauri::State<'_, AppState>, + year: Option, +) -> AppResult { + let pool = state.require_profile_pool().await?; + let profile_id = state.require_profile_id().await?; + let artwork_dir = state.paths.profile_artwork_dir(profile_id); + + let rows = + sqlx::query_as::<_, DetailTrackRaw>(detail_tracks_sql("", &format!("({TRACK_YEAR}) IS ?"))) + .bind(year) + .fetch_all(&*pool) + .await?; + + let tracks: Vec = rows + .into_iter() + .map(|row| detail_track(row, &artwork_dir)) + .collect(); + + let cover = most_common_cover(&tracks); + Ok(YearDetail { + year, + track_count: tracks.len() as i64, + total_duration_ms: tracks.iter().map(|t| t.duration_ms).sum(), + artwork_path: cover.and_then(|t| t.artwork_path.clone()), + artwork_path_1x: cover.and_then(|t| t.artwork_path_1x.clone()), + artwork_path_2x: cover.and_then(|t| t.artwork_path_2x.clone()), + tracks, + }) +} + +/// The first track of the album with the most tracks that has a cover, the +/// same choice `list_years` makes, so the page shows the tile's picture. +fn most_common_cover( + tracks: &[crate::commands::track::Track], +) -> Option<&crate::commands::track::Track> { + let mut counts: HashMap = HashMap::new(); + for track in tracks { + if let (Some(album), Some(_)) = (track.album_id, track.artwork_path.as_ref()) { + counts.entry(album).or_insert((0, track)).0 += 1; + } + } + counts + .into_iter() + .max_by(|(a_id, (a_n, _)), (b_id, (b_n, _))| a_n.cmp(b_n).then(b_id.cmp(a_id))) + .map(|(_, (_, track))| track) +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use sqlx::SqlitePool; + use std::str::FromStr; + + /// The real migrator, `foreign_keys` on, as the browse tests do. + async fn pool() -> SqlitePool { + let options = waveflow_core::repository::sqlite::collation::register( + 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 + } + + /// Two albums in 1997, the second with more tracks and a cover; one + /// track in 2003; one with no year, one tagged `0`, one unavailable. + async fn seed(pool: &SqlitePool) { + let mut statements = vec![ + "INSERT INTO library (id, name, color_id, icon_id, created_at, updated_at, + hlc_wall, hlc_logical) + VALUES (1, 'L', 1, 1, 0, 0, 0, 0)" + .to_string(), + "INSERT INTO artist (id, name, canonical_name) VALUES (1, 'A', 'a')".to_string(), + "INSERT INTO artwork (id, hash, format, width, height, source, created_at) + VALUES (1, 'c0ffee', 'jpg', 1, 1, 'embedded', 0)" + .to_string(), + "INSERT INTO album (id, title, canonical_title, artist_id, is_compilation) + VALUES (1, 'One', 'one', 1, 0), (2, 'Two', 'two', 1, 0)" + .to_string(), + "UPDATE album SET artwork_id = 1 WHERE id = 2".to_string(), + ]; + let tracks: [(i64, Option, Option, i64); 7] = [ + (1, Some(1997), Some(1), 1), + (2, Some(1997), Some(2), 1), + (3, Some(1997), Some(2), 1), + (4, Some(2003), Some(1), 1), + (5, None, None, 1), + (6, Some(0), None, 1), + (7, Some(1997), Some(1), 0), + ]; + for (id, year, album, available) in tracks { + let year = year.map_or("NULL".into(), |y| y.to_string()); + let album = album.map_or("NULL".into(), |a| a.to_string()); + statements.push(format!( + "INSERT INTO track (id, library_id, file_path, file_hash, file_size, file_modified, + title, album_id, year, duration_ms, added_at, is_available, + hlc_wall, hlc_logical, rating_hlc_wall, rating_hlc_logical, + primary_artist) + VALUES ({id}, 1, '/m/{id}.flac', 'h{id}', 1, 0, 'T{id}', {album}, {year}, + 1000, 0, {available}, 0, 0, 0, 0, 1)" + )); + } + for statement in statements { + sqlx::raw_sql(sqlx::AssertSqlSafe(statement)) + .execute(pool) + .await + .unwrap(); + } + } + + #[tokio::test] + async fn years_newest_first_unknown_last() { + let pool = pool().await; + seed(&pool).await; + let rows = sqlx::query_as::<_, YearRawRow>(list_years_sql()) + .bind(None::) + .bind(None::) + .fetch_all(&pool) + .await + .unwrap(); + let summary: Vec<_> = rows + .iter() + .map(|r| (r.year, r.track_count, r.album_count, r.artwork_hash.clone())) + .collect(); + assert_eq!( + summary, + [ + (Some(2003), 1, 1, None), + // The unavailable track is not counted; album 2 holds most + // of the year and lends its cover. + (Some(1997), 3, 2, Some("c0ffee".to_string())), + // No year and a year of 0 are the same bucket. + (None, 2, 0, None), + ] + ); + } + + #[tokio::test] + async fn one_year_and_the_unknown_bucket() { + let pool = pool().await; + seed(&pool).await; + let sql = || detail_tracks_sql("", &format!("({TRACK_YEAR}) IS ?")); + let ids = |rows: Vec| rows.into_iter().map(|r| r.id).collect::>(); + + let mut in_1997 = ids(sqlx::query_as::<_, DetailTrackRaw>(sql()) + .bind(Some(1997_i64)) + .fetch_all(&pool) + .await + .unwrap()); + in_1997.sort(); + assert_eq!(in_1997, [1, 2, 3]); + + let mut unknown = ids(sqlx::query_as::<_, DetailTrackRaw>(sql()) + .bind(None::) + .fetch_all(&pool) + .await + .unwrap()); + unknown.sort(); + assert_eq!(unknown, [5, 6]); + } +} diff --git a/src-tauri/crates/app/src/lib.rs b/src-tauri/crates/app/src/lib.rs index ed83d78a8..7702bd948 100644 --- a/src-tauri/crates/app/src/lib.rs +++ b/src-tauri/crates/app/src/lib.rs @@ -889,6 +889,8 @@ pub fn run() { commands::browse::get_album_detail, commands::browse::get_artist_detail, commands::browse::get_genre_detail, + commands::years::list_years, + commands::years::get_year_detail, commands::browse::set_genre_artwork_from_file, commands::browse::clear_genre_artwork, commands::deezer::enrich_album_deezer, diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 2acd36e5e..814fa3502 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -123,6 +123,11 @@ const GenreDetailView = lazy(() => default: module.GenreDetailView, })), ); +const YearDetailView = lazy(() => + import("../views/YearDetailView").then((module) => ({ + default: module.YearDetailView, + })), +); // Each entry in the navigation history pairs a view id with its payload // (when relevant) so back/forward can restore the exact target the user // visited. Payload fields are optional so callers without a target (e.g. @@ -157,6 +162,8 @@ type HistoryEntry = | { id: "album-detail"; albumId?: number | null } | { id: "artist-detail"; artistId?: number | null } | { id: "genre-detail"; genreId?: number | null } + // `year: null` is the "Unknown year" bucket, not a missing payload. + | { id: "year-detail"; year: number | null } | { id: "plugin-ui"; pluginId: string; @@ -279,6 +286,7 @@ export function AppLayout() { void import("../views/AlbumDetailView"); void import("../views/ArtistDetailView"); void import("../views/GenreDetailView"); + void import("../views/YearDetailView"); void import("../views/StatisticsView"); void import("../views/WrappedView"); void import("../views/SettingsView"); @@ -369,6 +377,8 @@ export function AppLayout() { : null; const activeGenreId = currentEntry.id === "genre-detail" ? (currentEntry.genreId ?? null) : null; + const activeYear = + currentEntry.id === "year-detail" ? currentEntry.year : undefined; const activePlaylistId = currentEntry.id === "playlist" ? (currentEntry.playlistId ?? null) : null; const activeRemotePlaylistId = @@ -492,6 +502,13 @@ export function AppLayout() { [pushEntry], ); + const navigateToYear = useCallback( + (year: number | null) => { + pushEntry({ id: "year-detail", year }); + }, + [pushEntry], + ); + const navigateToPlaylist = useCallback( (playlistId: number) => { pushEntry({ id: "playlist", playlistId }); @@ -567,6 +584,7 @@ export function AppLayout() { onNavigateToRemoteArtist={navigateToRemoteArtist} onNavigateToRemotePlaylist={navigateToRemotePlaylist} onNavigateToGenre={navigateToGenre} + onNavigateToYear={navigateToYear} onNavigateToPlaylist={navigateToPlaylist} /> ); @@ -683,6 +701,14 @@ export function AppLayout() { onNavigateToArtist={navigateToArtist} /> ); + case "year-detail": + return ( + + ); } } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 581e03cc0..d9ace48dd 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -8,6 +8,7 @@ import { Disc, Mic2, Tags, + CalendarDays, Heart, Clock, Plus, @@ -398,6 +399,14 @@ export function Sidebar({ navigateToLibraryTab("genres"); }} /> + } + label={t("sidebar.myMusic.years")} + active={activeView === "library" && libraryTab === "years"} + onClick={() => { + navigateToLibraryTab("years"); + }} + /> } label={t("sidebar.myMusic.folders")} diff --git a/src/components/views/GenreDetailView.tsx b/src/components/views/GenreDetailView.tsx index fe599f05b..d1d292586 100644 --- a/src/components/views/GenreDetailView.tsx +++ b/src/components/views/GenreDetailView.tsx @@ -210,7 +210,7 @@ export function GenreDetailView({ {/* Tracks */} {tracks.length > 0 ? ( - void; onNavigateToArtist: (artistId: number) => void; onNavigateToGenre: (genreId: number) => void; + /** `null` opens the tracks with no usable year. */ + onNavigateToYear: (year: number | null) => void; onNavigateToPlaylist: (playlistId: number) => void; } @@ -182,6 +187,7 @@ const tabConfig: { id: LibraryTab; icon: typeof Music2 }[] = [ { id: "albums", icon: Disc }, { id: "artists", icon: Mic2 }, { id: "genres", icon: Tags }, + { id: "years", icon: CalendarDays }, { id: "playlists", icon: ListMusic }, { id: "folders", icon: Folder }, { id: "needs-attention", icon: AlertTriangle }, @@ -192,6 +198,7 @@ const emptyStateIcons: Record = { albums: Disc, artists: Mic2, genres: Tags, + years: CalendarDays, playlists: ListMusic, folders: Folder, "needs-attention": AlertTriangle, @@ -202,6 +209,7 @@ const headerIcons: Record = { albums: Disc, artists: Mic2, genres: Tags, + years: CalendarDays, playlists: ListMusic, folders: Folder, "needs-attention": AlertTriangle, @@ -216,6 +224,7 @@ export function LibraryView({ onNavigateToRemoteArtist, onNavigateToRemotePlaylist, onNavigateToGenre, + onNavigateToYear, onNavigateToPlaylist, }: LibraryViewProps) { const { t, i18n } = useTranslation(); @@ -291,6 +300,7 @@ export function LibraryView({ const librarySource = useLibrarySource(); const [artists, setArtists] = useState([]); const [genres, setGenres] = useState([]); + const [years, setYears] = useState([]); const [folders, setFolders] = useState([]); // Folder browsing (#578). `null` is the list of configured roots — // the tab's original content, which stays reachable because it is @@ -319,6 +329,7 @@ export function LibraryView({ albums: true, artists: true, genres: true, + years: true, // Playlists come from PlaylistContext, already loaded — there is no // fetch of our own to wait on, so this tab never shows a skeleton. playlists: false, @@ -326,11 +337,12 @@ export function LibraryView({ // The only tab that does NOT prefetch. Its counts are album-level // aggregates plus a walk over every track to chain probable // duplicates, so paying for them on every mount of the library -- - // which is what the other six do -- would tax people who never open - // it. That is about the *fetch*, not about this flag: `true`, - // because an inventory nobody has asked for yet is an empty list, - // and an empty list is what "nothing to fix" looks like. The first - // paint would congratulate the user before a single count existed. + // which is what the six other tabs with a query of their own do -- + // would tax people who never open it. That is about the *fetch*, + // not about this flag: `true`, because an inventory nobody has + // asked for yet is an empty list, and an empty list is what + // "nothing to fix" looks like. The first paint would congratulate + // the user before a single count existed. "needs-attention": true, }); // Which columns the track table shows, in what order and how wide @@ -834,6 +846,25 @@ export function LibraryView({ }; }, [librariesSignature, editRefetch]); + useEffect(() => { + let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect + setLoading((p) => ({ ...p, years: true })); + listYears(null) + .then((list) => { + if (!cancelled) setYears(list); + }) + .catch((err) => { + if (!cancelled) console.error("[LibraryView] listYears failed", err); + }) + .finally(() => { + if (!cancelled) setLoading((p) => ({ ...p, years: false })); + }); + return () => { + cancelled = true; + }; + }, [librariesSignature, editRefetch]); + useEffect(() => { let cancelled = false; // eslint-disable-next-line react-hooks/set-state-in-effect @@ -960,6 +991,8 @@ export function LibraryView({ return artists.length; case "genres": return genres.length; + case "years": + return years.length; case "playlists": // The merged list, not the local one: the header would otherwise // count a different set from the grid right below it. @@ -1158,6 +1191,7 @@ export function LibraryView({ (activeTab === "albums" && albums.length > 0) || (activeTab === "artists" && artists.length > 0) || (activeTab === "genres" && genres.length > 0) || + (activeTab === "years" && years.length > 0) || // Playlists is renderable even when empty: `PlaylistGrid` owns its // own empty state ("playlists you create appear here"), which is the // right message. The generic one below is built for a library with @@ -1623,6 +1657,14 @@ export function LibraryView({ onSelect={onNavigateToGenre} /> )} + {activeTab === "years" && ( + + )} {activeTab === "playlists" && ( <> void; +} + +/** A section per decade, newest first, then the tracks with no usable + * year, which the backend already lists last. */ +function YearList({ years, isLoading, t, onSelect }: YearListProps) { + const sections: { key: string; label: string; rows: YearRow[] }[] = []; + for (const row of years) { + const decade = row.year == null ? null : Math.floor(row.year / 10) * 10; + const key = decade == null ? "unknown" : String(decade); + let section = sections[sections.length - 1]; + if (!section || section.key !== key) { + section = { + key, + label: + decade == null + ? t("library.yearList.unknown") + : t("library.yearList.decade", { decade }), + rows: [], + }; + sections.push(section); + } + section.rows.push(row); + } + return ( +
+ {sections.map((section) => ( +
+

+ {section.label} +

+
+ {section.rows.map((row) => ( + + ))} +
+
+ ))} +
+ ); +} + interface GenreListProps { genres: GenreRow[]; isLoading: boolean; @@ -4767,7 +4882,8 @@ function LibraryTabSkeleton({ tab, t }: { tab: LibraryTab; t: Translator }) { ); } - if (tab === "genres") { + // Years lay out as the same grid of tiles. + if (tab === "genres" || tab === "years") { return (
void; + onNavigateToArtist: (artistId: number) => void; +} + +/** + * Every track of one year, opened from the library's Years tab. The same + * page as a genre's, without the picture picker: a year's cover is the + * album that holds most of it. + */ +export function YearDetailView({ + year, + onNavigateToAlbum, + onNavigateToArtist, +}: YearDetailViewProps) { + const { t } = useTranslation(); + const createFromModal = useCreatePlaylistFromModal(); + const { playTracks, currentTrack, toggleShuffle, isShuffled, isPlaying } = + usePlayer(); + + const [detail, setDetail] = useState(null); + // Starts true so the skeleton paints first, not a "not found" flash. + const [isLoading, setIsLoading] = useState(true); + const [likedIds, setLikedIds] = useState>(new Set()); + const [isCreatePlaylistModalOpen, setIsCreatePlaylistModalOpen] = + useState(false); + + const trackContextMenu = useTrackContextMenu({ + likedIds, + onLikedChanged: (trackId, nowLiked) => + setLikedIds((prev) => { + const next = new Set(prev); + if (nowLiked) next.add(trackId); + else next.delete(trackId); + return next; + }), + onCreatePlaylist: () => setIsCreatePlaylistModalOpen(true), + onNavigateToAlbum, + onNavigateToArtist, + }); + + const [editRefetch, setEditRefetch] = useState(0); + useTrackUpdated(useCallback(() => setEditRefetch((k) => k + 1), [])); + + useEffect(() => { + if (year === undefined) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setDetail(null); + setIsLoading(false); + return; + } + let cancelled = false; + (async () => { + setIsLoading(true); + try { + const loaded = await getYearDetail(year); + if (!cancelled) setDetail(loaded); + } catch (err) { + console.error("[YearDetailView] load failed", err); + if (!cancelled) setDetail(null); + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [year, editRefetch]); + + useEffect(() => { + listLikedTrackIds() + .then((ids) => setLikedIds(new Set(ids))) + .catch(() => {}); + }, [year]); + + const handleToggleLike = async (trackId: number) => { + const nowLiked = await toggleLikeTrack(trackId); + setLikedIds((prev) => { + const next = new Set(prev); + if (nowLiked) next.add(trackId); + else next.delete(trackId); + return next; + }); + }; + + const label = + year == null ? t("library.yearList.unknown") : String(year); + + if (year === undefined || (!detail && !isLoading)) { + return ( + } + title={t("yearDetail.emptyTitle")} + description={t("yearDetail.emptyDescription")} + className="py-20" + /> + ); + } + + if (!detail) return ; + + const tracks = detail.tracks; + + const handlePlayAll = async () => { + if (tracks.length === 0) return; + await playTracks(tracks, 0, { type: "library", id: null }); + }; + + const handleShufflePlay = async () => { + if (tracks.length === 0) return; + await playTracks(tracks, 0, { type: "library", id: null }); + // Never turn shuffle *off* from the Shuffle button. + if (!isShuffled) await toggleShuffle(); + }; + + return ( +
+
+ + +
+
+ {t("yearDetail.badge")} +
+

+ {label} +

+ +
+ + {t("genreDetail.trackCount", { count: detail.track_count })} + + · + {formatDuration(detail.total_duration_ms)} +
+ +
+ + +
+
+
+ + {tracks.length > 0 ? ( + + playTracks(tracks, index, { type: "library", id: null }) + } + onNavigateToAlbum={onNavigateToAlbum} + onNavigateToArtist={onNavigateToArtist} + onContextMenuRow={trackContextMenu.open} + onRowMenuKey={trackContextMenu.openFromKeyboard} + t={t} + /> + ) : ( + } + title={t("yearDetail.emptyTracksTitle")} + description={t("yearDetail.emptyTracksDescription")} + className="py-20" + /> + )} + + setIsCreatePlaylistModalOpen(false)} + onCreate={async (data) => { + try { + return await createFromModal(data); + } catch (err) { + console.error("[YearDetailView] create playlist failed", err); + throw err; + } + }} + /> + + {trackContextMenu.render()} +
+ ); +} diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 820d7803c..de2c22b53 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -240,6 +240,7 @@ "albums": "الألبومات", "artists": "الفنانون", "genres": "الأنواع", + "years": "السنوات", "folders": "المجلدات" }, "playlistSection": { @@ -433,6 +434,12 @@ "genres_two": "نوعان", "genres_few": "{{count}} أنواع", "genres_many": "{{count}} نوعًا", + "years_zero": "0 سنة", + "years_one": "سنة واحدة", + "years_two": "سنتان", + "years_few": "{{count}} سنوات", + "years_many": "{{count}} سنة", + "years_other": "{{count}} سنة", "tracks_two": "مقطعان", "tracks_few": "{{count}} مقاطع", "tracks_many": "{{count}} مقطعًا", @@ -455,6 +462,7 @@ "albums": "الألبومات", "artists": "الفنانون", "genres": "الأنواع", + "years": "السنوات", "folders": "المجلدات", "playlists": "قوائم التشغيل", "needs-attention": "بحاجة إلى مراجعة" @@ -476,6 +484,10 @@ "title": "لم يُعثر على أي نوع", "description": "استورد ملفات صوتية لاستكشاف الأنواع المختلفة." }, + "years": { + "title": "لم يُعثر على سنوات", + "description": "تظهر المقاطع هنا مجمّعة حسب السنة في وسومها." + }, "folders": { "title": "لم يتم استيراد أي مجلد", "description": "استورد مجلدًا من شريط الإجراءات لتصفحه هنا." @@ -559,6 +571,10 @@ "trackCount_few": "{{count}} مقاطع", "trackCount_many": "{{count}} مقطعًا" }, + "yearList": { + "unknown": "سنة غير معروفة", + "decade": "عقد {{decade}}" + }, "folderList": { "trackCount_zero": "0 مقطع", "trackCount_one": "مقطع واحد", @@ -1444,6 +1460,13 @@ "trackCount_few": "{{count}} مقاطع", "trackCount_many": "{{count}} مقطعًا" }, + "yearDetail": { + "badge": "سنة", + "emptyTitle": "السنة غير موجودة", + "emptyDescription": "لم تعد لهذه السنة مقاطع في الملف الشخصي النشط.", + "emptyTracksTitle": "لا مقاطع", + "emptyTracksDescription": "لا يحمل أي مقطع متاح هذه السنة." + }, "genreImagePicker": { "title": "تغيير صورة النوع", "subtitle": "اختر ملف صورة محلي (JPG أو PNG أو WEBP) لاستخدامه كصورة لهذا النوع.", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 68d83a2f2..bf6e0e7bc 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -228,6 +228,7 @@ "albums": "Alben", "artists": "Künstler", "genres": "Genres", + "years": "Jahre", "folders": "Ordner" }, "playlistSection": { @@ -385,6 +386,9 @@ "genres_zero": "0 Genres", "genres_one": "{{count}} Genre", "genres_other": "{{count}} Genres", + "years_zero": "0 Jahre", + "years_one": "{{count}} Jahr", + "years_other": "{{count}} Jahre", "folders_zero": "0 Ordner", "folders_one": "{{count}} Ordner", "folders_other": "{{count}} Ordner", @@ -401,6 +405,7 @@ "albums": "Alben", "artists": "Künstler", "genres": "Genres", + "years": "Jahre", "folders": "Ordner", "playlists": "Playlists", "needs-attention": "Zu korrigieren" @@ -422,6 +427,10 @@ "title": "Keine Genres gefunden", "description": "Importiere Audiodateien, um verschiedene Genres zu erkunden." }, + "years": { + "title": "Keine Jahre gefunden", + "description": "Titel erscheinen hier, gruppiert nach dem Jahr in ihren Tags." + }, "folders": { "title": "Keine Ordner importiert", "description": "Importiere einen Ordner über die Aktionsleiste, um ihn hier zu durchsuchen." @@ -489,6 +498,10 @@ "trackCount_one": "{{count}} Titel", "trackCount_other": "{{count}} Titel" }, + "yearList": { + "unknown": "Unbekanntes Jahr", + "decade": "{{decade}}er" + }, "folderList": { "trackCount_zero": "0 Titel", "trackCount_one": "{{count}} Titel", @@ -1284,6 +1297,13 @@ "emptyTracksTitle": "Keine Titel", "emptyTracksDescription": "Dieses Genre enthält keine verfügbaren Titel." }, + "yearDetail": { + "badge": "Jahr", + "emptyTitle": "Jahr nicht gefunden", + "emptyDescription": "Dieses Jahr hat im aktiven Profil keine Titel mehr.", + "emptyTracksTitle": "Keine Titel", + "emptyTracksDescription": "Kein verfügbarer Titel trägt dieses Jahr." + }, "genreImagePicker": { "title": "Genre-Bild ändern", "subtitle": "Wähle eine lokale Bilddatei (JPG, PNG oder WEBP) als Bild für dieses Genre.", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 50d68b135..1746cb284 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -228,6 +228,7 @@ "albums": "Albums", "artists": "Artists", "genres": "Genres", + "years": "Years", "folders": "Folders" }, "playlistSection": { @@ -385,6 +386,9 @@ "genres_zero": "0 genres", "genres_one": "{{count}} genre", "genres_other": "{{count}} genres", + "years_zero": "0 years", + "years_one": "{{count}} year", + "years_other": "{{count}} years", "folders_zero": "0 folders", "folders_one": "{{count}} folder", "folders_other": "{{count}} folders", @@ -401,6 +405,7 @@ "albums": "Albums", "artists": "Artists", "genres": "Genres", + "years": "Years", "folders": "Folders", "playlists": "Playlists", "needs-attention": "Needs attention" @@ -422,6 +427,10 @@ "title": "No genres found", "description": "Import audio files to explore different genres." }, + "years": { + "title": "No years found", + "description": "Tracks appear here grouped by the year their tags give." + }, "folders": { "title": "No folders imported", "description": "Import a folder from the action bar to browse it here." @@ -489,6 +498,10 @@ "trackCount_one": "{{count}} track", "trackCount_other": "{{count}} tracks" }, + "yearList": { + "unknown": "Unknown year", + "decade": "{{decade}}s" + }, "folderList": { "trackCount_zero": "0 tracks", "trackCount_one": "{{count}} track", @@ -1284,6 +1297,13 @@ "emptyTracksTitle": "No tracks", "emptyTracksDescription": "This genre contains no available tracks." }, + "yearDetail": { + "badge": "Year", + "emptyTitle": "Year not found", + "emptyDescription": "This year no longer has tracks in the active profile.", + "emptyTracksTitle": "No tracks", + "emptyTracksDescription": "No available track carries this year." + }, "genreImagePicker": { "title": "Change genre image", "subtitle": "Choose a local image file (JPG, PNG or WEBP) to use as this genre's picture.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 43a8b014f..d72739a9b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -232,6 +232,7 @@ "albums": "Álbumes", "artists": "Artistas", "genres": "Géneros", + "years": "Años", "folders": "Carpetas" }, "playlistSection": { @@ -399,6 +400,10 @@ "genres_one": "{{count}} género", "genres_other": "{{count}} géneros", "genres_many": "{{count}} géneros", + "years_zero": "0 años", + "years_one": "{{count}} año", + "years_many": "{{count}} años", + "years_other": "{{count}} años", "folders_zero": "0 carpetas", "folders_one": "{{count}} carpeta", "folders_other": "{{count}} carpetas", @@ -418,6 +423,7 @@ "albums": "Álbumes", "artists": "Artistas", "genres": "Géneros", + "years": "Años", "folders": "Carpetas", "playlists": "Listas", "needs-attention": "Por corregir" @@ -439,6 +445,10 @@ "title": "No se han encontrado géneros", "description": "Importa archivos de audio para explorar distintos géneros." }, + "years": { + "title": "No se encontraron años", + "description": "Las canciones aparecen aquí agrupadas por el año de sus etiquetas." + }, "folders": { "title": "No hay carpetas importadas", "description": "Importa una carpeta desde la barra de acciones para explorarla aquí." @@ -511,6 +521,10 @@ "trackCount_other": "{{count}} pistas", "trackCount_many": "{{count}} pistas" }, + "yearList": { + "unknown": "Año desconocido", + "decade": "Años {{decade}}" + }, "folderList": { "trackCount_zero": "0 pistas", "trackCount_one": "{{count}} pista", @@ -1333,6 +1347,13 @@ "emptyTracksTitle": "Sin pistas", "emptyTracksDescription": "Este género no contiene pistas disponibles." }, + "yearDetail": { + "badge": "Año", + "emptyTitle": "Año no encontrado", + "emptyDescription": "Este año ya no tiene canciones en el perfil activo.", + "emptyTracksTitle": "Sin canciones", + "emptyTracksDescription": "Ninguna canción disponible tiene este año." + }, "genreImagePicker": { "title": "Cambiar imagen del género", "subtitle": "Elige un archivo de imagen local (JPG, PNG o WEBP) para usar como imagen de este género.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 570adc193..a85685f49 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -232,6 +232,7 @@ "albums": "Albums", "artists": "Artistes", "genres": "Genres", + "years": "Années", "folders": "Dossiers" }, "playlistSection": { @@ -399,6 +400,10 @@ "genres_one": "{{count}} genre", "genres_other": "{{count}} genres", "genres_many": "{{count}} genres", + "years_zero": "0 année", + "years_one": "{{count}} année", + "years_many": "{{count}} années", + "years_other": "{{count}} années", "folders_zero": "0 dossiers", "folders_one": "{{count}} dossier", "folders_other": "{{count}} dossiers", @@ -418,6 +423,7 @@ "albums": "Albums", "artists": "Artistes", "genres": "Genres", + "years": "Années", "folders": "Dossiers", "playlists": "Playlists", "needs-attention": "À corriger" @@ -439,6 +445,10 @@ "title": "Aucun genre trouvé", "description": "Importez des fichiers audio pour découvrir les genres." }, + "years": { + "title": "Aucune année trouvée", + "description": "Les titres apparaissent ici, regroupés par l'année de leurs tags." + }, "folders": { "title": "Aucun dossier importé", "description": "Importez un dossier depuis la barre d'action pour l'explorer ici." @@ -511,6 +521,10 @@ "trackCount_other": "{{count}} titres", "trackCount_many": "{{count}} titres" }, + "yearList": { + "unknown": "Année inconnue", + "decade": "Années {{decade}}" + }, "folderList": { "trackCount_zero": "0 titres", "trackCount_one": "{{count}} titre", @@ -1333,6 +1347,13 @@ "emptyTracksTitle": "Aucun titre", "emptyTracksDescription": "Ce genre ne contient aucun titre disponible." }, + "yearDetail": { + "badge": "Année", + "emptyTitle": "Année introuvable", + "emptyDescription": "Cette année n'a plus de titres dans le profil actif.", + "emptyTracksTitle": "Aucun titre", + "emptyTracksDescription": "Aucun titre disponible ne porte cette année." + }, "genreImagePicker": { "title": "Modifier l'image du genre", "subtitle": "Choisissez un fichier image local (JPG, PNG ou WEBP) à utiliser comme illustration de ce genre.", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index ef03f842a..aef35623f 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -228,6 +228,7 @@ "albums": "एल्बम", "artists": "कलाकार", "genres": "शैलियाँ", + "years": "वर्ष", "folders": "फ़ोल्डर" }, "playlistSection": { @@ -385,6 +386,9 @@ "genres_zero": "0 शैली", "genres_one": "{{count}} शैली", "genres_other": "{{count}} शैलियाँ", + "years_zero": "0 वर्ष", + "years_one": "{{count}} वर्ष", + "years_other": "{{count}} वर्ष", "folders_zero": "0 फ़ोल्डर", "folders_one": "{{count}} फ़ोल्डर", "folders_other": "{{count}} फ़ोल्डर", @@ -401,6 +405,7 @@ "albums": "एल्बम", "artists": "कलाकार", "genres": "शैलियाँ", + "years": "वर्ष", "folders": "फ़ोल्डर", "playlists": "प्लेलिस्ट", "needs-attention": "ध्यान चाहिए" @@ -422,6 +427,10 @@ "title": "कोई शैली नहीं मिली", "description": "विभिन्न शैलियों का अन्वेषण करने के लिए ऑडियो फ़ाइलें आयात करें।" }, + "years": { + "title": "कोई वर्ष नहीं मिला", + "description": "ट्रैक यहाँ उनके टैग के वर्ष के अनुसार समूहित दिखते हैं।" + }, "folders": { "title": "कोई फ़ोल्डर आयात नहीं हुआ", "description": "इसे यहाँ ब्राउज़ करने के लिए एक्शन बार से एक फ़ोल्डर आयात करें।" @@ -489,6 +498,10 @@ "trackCount_one": "{{count}} ट्रैक", "trackCount_other": "{{count}} ट्रैक" }, + "yearList": { + "unknown": "अज्ञात वर्ष", + "decade": "{{decade}} का दशक" + }, "folderList": { "trackCount_zero": "0 ट्रैक", "trackCount_one": "{{count}} ट्रैक", @@ -1284,6 +1297,13 @@ "emptyTracksTitle": "कोई ट्रैक नहीं", "emptyTracksDescription": "इस शैली में कोई उपलब्ध ट्रैक नहीं है।" }, + "yearDetail": { + "badge": "वर्ष", + "emptyTitle": "वर्ष नहीं मिला", + "emptyDescription": "सक्रिय प्रोफ़ाइल में इस वर्ष के अब कोई ट्रैक नहीं हैं।", + "emptyTracksTitle": "कोई ट्रैक नहीं", + "emptyTracksDescription": "कोई उपलब्ध ट्रैक इस वर्ष का नहीं है।" + }, "genreImagePicker": { "title": "शैली की छवि बदलें", "subtitle": "इस शैली की छवि के रूप में उपयोग करने के लिए एक स्थानीय छवि फ़ाइल (JPG, PNG या WEBP) चुनें।", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 353411a94..45989d165 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -228,6 +228,7 @@ "albums": "Album", "artists": "Artis", "genres": "Genre", + "years": "Tahun", "folders": "Folder" }, "playlistSection": { @@ -385,6 +386,9 @@ "genres_zero": "0 genre", "genres_one": "{{count}} genre", "genres_other": "{{count}} genre", + "years_zero": "0 tahun", + "years_one": "{{count}} tahun", + "years_other": "{{count}} tahun", "folders_zero": "0 folder", "folders_one": "{{count}} folder", "folders_other": "{{count}} folder", @@ -401,6 +405,7 @@ "albums": "Album", "artists": "Artis", "genres": "Genre", + "years": "Tahun", "folders": "Folder", "playlists": "Daftar putar", "needs-attention": "Perlu diperbaiki" @@ -422,6 +427,10 @@ "title": "Tidak ada genre ditemukan", "description": "Impor berkas audio untuk menjelajahi berbagai genre." }, + "years": { + "title": "Tidak ada tahun", + "description": "Lagu tampil di sini, dikelompokkan menurut tahun pada tag-nya." + }, "folders": { "title": "Belum ada folder yang diimpor", "description": "Impor folder dari bilah aksi untuk menjelajahinya di sini." @@ -489,6 +498,10 @@ "trackCount_one": "{{count}} lagu", "trackCount_other": "{{count}} lagu" }, + "yearList": { + "unknown": "Tahun tidak diketahui", + "decade": "Tahun {{decade}}-an" + }, "folderList": { "trackCount_zero": "0 lagu", "trackCount_one": "{{count}} lagu", @@ -1284,6 +1297,13 @@ "emptyTracksTitle": "Tidak ada lagu", "emptyTracksDescription": "Genre ini tidak berisi lagu yang tersedia." }, + "yearDetail": { + "badge": "Tahun", + "emptyTitle": "Tahun tidak ditemukan", + "emptyDescription": "Tahun ini tidak lagi memiliki lagu di profil aktif.", + "emptyTracksTitle": "Tidak ada lagu", + "emptyTracksDescription": "Tidak ada lagu tersedia yang bertahun ini." + }, "genreImagePicker": { "title": "Ubah gambar genre", "subtitle": "Pilih berkas gambar lokal (JPG, PNG, atau WEBP) untuk digunakan sebagai gambar genre ini.", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index c65b3b32e..42f8f94a8 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -232,6 +232,7 @@ "albums": "Album", "artists": "Artisti", "genres": "Generi", + "years": "Anni", "folders": "Cartelle" }, "playlistSection": { @@ -399,6 +400,10 @@ "genres_one": "{{count}} genere", "genres_other": "{{count}} generi", "genres_many": "{{count}} generi", + "years_zero": "0 anni", + "years_one": "{{count}} anno", + "years_many": "{{count}} anni", + "years_other": "{{count}} anni", "folders_zero": "0 cartelle", "folders_one": "{{count}} cartella", "folders_other": "{{count}} cartelle", @@ -418,6 +423,7 @@ "albums": "Album", "artists": "Artisti", "genres": "Generi", + "years": "Anni", "folders": "Cartelle", "playlists": "Playlist", "needs-attention": "Da correggere" @@ -439,6 +445,10 @@ "title": "Nessun genere trovato", "description": "Importa file audio per esplorare i diversi generi." }, + "years": { + "title": "Nessun anno trovato", + "description": "I brani appaiono qui raggruppati per l'anno dei loro tag." + }, "folders": { "title": "Nessuna cartella importata", "description": "Importa una cartella dalla barra degli strumenti per sfogliarla qui." @@ -511,6 +521,10 @@ "trackCount_other": "{{count}} brani", "trackCount_many": "{{count}} brani" }, + "yearList": { + "unknown": "Anno sconosciuto", + "decade": "Anni {{decade}}" + }, "folderList": { "trackCount_zero": "0 brani", "trackCount_one": "{{count}} brano", @@ -1333,6 +1347,13 @@ "emptyTracksTitle": "Nessun brano", "emptyTracksDescription": "Questo genere non contiene brani disponibili." }, + "yearDetail": { + "badge": "Anno", + "emptyTitle": "Anno non trovato", + "emptyDescription": "Quest'anno non ha più brani nel profilo attivo.", + "emptyTracksTitle": "Nessun brano", + "emptyTracksDescription": "Nessun brano disponibile riporta quest'anno." + }, "genreImagePicker": { "title": "Cambia immagine del genere", "subtitle": "Scegli un file immagine locale (JPG, PNG o WEBP) da usare come immagine di questo genere.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 6ec7d5672..62339b96a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -228,6 +228,7 @@ "albums": "アルバム", "artists": "アーティスト", "genres": "ジャンル", + "years": "年", "folders": "フォルダ" }, "playlistSection": { @@ -385,6 +386,9 @@ "genres_zero": "0ジャンル", "genres_one": "{{count}}ジャンル", "genres_other": "{{count}}ジャンル", + "years_zero": "0 年分", + "years_one": "{{count}} 年分", + "years_other": "{{count}} 年分", "folders_zero": "0個", "folders_one": "{{count}}個", "folders_other": "{{count}}個", @@ -401,6 +405,7 @@ "albums": "アルバム", "artists": "アーティスト", "genres": "ジャンル", + "years": "年", "folders": "フォルダ", "playlists": "プレイリスト", "needs-attention": "要確認" @@ -422,6 +427,10 @@ "title": "該当するジャンルが見つかりませんでした", "description": "オーディオファイルをインポートして、ジャンルを確認しましょう。" }, + "years": { + "title": "年が見つかりません", + "description": "トラックはタグの年ごとにここにまとめて表示されます。" + }, "folders": { "title": "インポートされたフォルダはありません", "description": "アクションバーからフォルダをインポートして、ここでその中を確認してください。" @@ -489,6 +498,10 @@ "trackCount_one": "{{count}}曲", "trackCount_other": "{{count}}曲" }, + "yearList": { + "unknown": "年不明", + "decade": "{{decade}}年代" + }, "folderList": { "trackCount_zero": "0曲", "trackCount_one": "{{count}}曲", @@ -1284,6 +1297,13 @@ "emptyTracksTitle": "曲がありません", "emptyTracksDescription": "このジャンルには利用可能な曲がありません。" }, + "yearDetail": { + "badge": "年", + "emptyTitle": "年が見つかりません", + "emptyDescription": "この年のトラックはアクティブなプロフィールにもうありません。", + "emptyTracksTitle": "トラックがありません", + "emptyTracksDescription": "この年の利用可能なトラックはありません。" + }, "genreImagePicker": { "title": "ジャンル画像を変更", "subtitle": "このジャンルの画像として使用するローカルの画像ファイル(JPG、PNG、WEBP)を選択してください。", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index f0372ccfe..1e0259e76 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -228,6 +228,7 @@ "albums": "앨범", "artists": "아티스트", "genres": "장르", + "years": "연도", "folders": "폴더" }, "playlistSection": { @@ -385,6 +386,9 @@ "genres_zero": "장르 0개", "genres_one": "장르 {{count}}개", "genres_other": "장르 {{count}}개", + "years_zero": "연도 0개", + "years_one": "연도 {{count}}개", + "years_other": "연도 {{count}}개", "folders_zero": "폴더 0개", "folders_one": "폴더 {{count}}개", "folders_other": "폴더 {{count}}개", @@ -401,6 +405,7 @@ "albums": "앨범", "artists": "아티스트", "genres": "장르", + "years": "연도", "folders": "폴더", "playlists": "재생 목록", "needs-attention": "확인 필요" @@ -422,6 +427,10 @@ "title": "장르가 없습니다", "description": "오디오 파일을 가져와 장르를 확인해 보세요." }, + "years": { + "title": "연도가 없습니다", + "description": "트랙이 태그의 연도별로 여기에 묶여 표시됩니다." + }, "folders": { "title": "가져온 폴더가 없습니다", "description": "도구 모음에서 폴더를 가져와 이곳에서 내용을 살펴보세요." @@ -489,6 +498,10 @@ "trackCount_one": "{{count}}곡", "trackCount_other": "{{count}}곡" }, + "yearList": { + "unknown": "알 수 없는 연도", + "decade": "{{decade}}년대" + }, "folderList": { "trackCount_zero": "0곡", "trackCount_one": "{{count}}곡", @@ -1284,6 +1297,13 @@ "emptyTracksTitle": "곡 없음", "emptyTracksDescription": "이 장르에는 사용 가능한 곡이 없습니다." }, + "yearDetail": { + "badge": "연도", + "emptyTitle": "연도를 찾을 수 없음", + "emptyDescription": "이 연도의 트랙이 활성 프로필에 더 이상 없습니다.", + "emptyTracksTitle": "트랙 없음", + "emptyTracksDescription": "이 연도의 사용 가능한 트랙이 없습니다." + }, "genreImagePicker": { "title": "장르 이미지 변경", "subtitle": "이 장르의 이미지로 사용할 로컬 이미지 파일(JPG, PNG 또는 WEBP)을 선택하세요.", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index a1eda28ef..0ee71fa12 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -228,6 +228,7 @@ "albums": "Albums", "artists": "Artiesten", "genres": "Genres", + "years": "Jaren", "folders": "Mappen" }, "playlistSection": { @@ -385,6 +386,9 @@ "genres_zero": "0 genres", "genres_one": "{{count}} genre", "genres_other": "{{count}} genres", + "years_zero": "0 jaar", + "years_one": "{{count}} jaar", + "years_other": "{{count}} jaar", "folders_zero": "0 mappen", "folders_one": "{{count}} map", "folders_other": "{{count}} mappen", @@ -401,6 +405,7 @@ "albums": "Albums", "artists": "Artiesten", "genres": "Genres", + "years": "Jaren", "folders": "Mappen", "playlists": "Afspeellijsten", "needs-attention": "Te verbeteren" @@ -422,6 +427,10 @@ "title": "Geen genres gevonden", "description": "Importeer audiobestanden om verschillende genres te ontdekken." }, + "years": { + "title": "Geen jaren gevonden", + "description": "Nummers verschijnen hier, gegroepeerd op het jaar uit hun tags." + }, "folders": { "title": "Geen mappen geïmporteerd", "description": "Importeer een map vanuit de actiebalk om die hier te bekijken." @@ -489,6 +498,10 @@ "trackCount_one": "{{count}} nummer", "trackCount_other": "{{count}} nummers" }, + "yearList": { + "unknown": "Onbekend jaar", + "decade": "Jaren {{decade}}" + }, "folderList": { "trackCount_zero": "0 nummers", "trackCount_one": "{{count}} nummer", @@ -1284,6 +1297,13 @@ "emptyTracksTitle": "Geen nummers", "emptyTracksDescription": "Dit genre bevat geen beschikbare nummers." }, + "yearDetail": { + "badge": "Jaar", + "emptyTitle": "Jaar niet gevonden", + "emptyDescription": "Dit jaar heeft geen nummers meer in het actieve profiel.", + "emptyTracksTitle": "Geen nummers", + "emptyTracksDescription": "Geen beschikbaar nummer heeft dit jaar." + }, "genreImagePicker": { "title": "Genre-afbeelding wijzigen", "subtitle": "Kies een lokaal afbeeldingsbestand (JPG, PNG of WEBP) als afbeelding voor dit genre.", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index e8c3128bd..903d917c2 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -232,6 +232,7 @@ "albums": "Álbuns", "artists": "Artistas", "genres": "Gêneros", + "years": "Anos", "folders": "Pastas" }, "playlistSection": { @@ -399,6 +400,10 @@ "genres_one": "{{count}} gênero", "genres_other": "{{count}} gêneros", "genres_many": "{{count}} gêneros", + "years_zero": "0 anos", + "years_one": "{{count}} ano", + "years_many": "{{count}} anos", + "years_other": "{{count}} anos", "folders_zero": "0 pastas", "folders_one": "{{count}} pasta", "folders_other": "{{count}} pastas", @@ -418,6 +423,7 @@ "albums": "Álbuns", "artists": "Artistas", "genres": "Gêneros", + "years": "Anos", "folders": "Pastas", "playlists": "Playlists", "needs-attention": "A corrigir" @@ -439,6 +445,10 @@ "title": "Nenhum gênero encontrado", "description": "Importe arquivos de áudio para descobrir os gêneros." }, + "years": { + "title": "Nenhum ano encontrado", + "description": "As faixas aparecem aqui agrupadas pelo ano das suas tags." + }, "folders": { "title": "Nenhuma pasta importada", "description": "Importe uma pasta a partir da barra de ações para explorá-la aqui." @@ -511,6 +521,10 @@ "trackCount_other": "{{count}} faixas", "trackCount_many": "{{count}} faixas" }, + "yearList": { + "unknown": "Ano desconhecido", + "decade": "Anos {{decade}}" + }, "folderList": { "trackCount_zero": "0 faixas", "trackCount_one": "{{count}} faixa", @@ -1334,6 +1348,13 @@ "emptyTracksTitle": "Sem faixas", "emptyTracksDescription": "Este gênero não contém faixas disponíveis." }, + "yearDetail": { + "badge": "Ano", + "emptyTitle": "Ano não encontrado", + "emptyDescription": "Este ano não tem mais faixas no perfil ativo.", + "emptyTracksTitle": "Nenhuma faixa", + "emptyTracksDescription": "Nenhuma faixa disponível tem este ano." + }, "genreImagePicker": { "title": "Alterar imagem do gênero", "subtitle": "Escolha um arquivo de imagem local (JPG, PNG ou WEBP) para usar como imagem deste gênero.", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index f7dbe4958..a26479aa7 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -232,6 +232,7 @@ "albums": "Álbuns", "artists": "Artistas", "genres": "Géneros", + "years": "Anos", "folders": "Pastas" }, "playlistSection": { @@ -399,6 +400,10 @@ "genres_one": "{{count}} género", "genres_other": "{{count}} géneros", "genres_many": "{{count}} géneros", + "years_zero": "0 anos", + "years_one": "{{count}} ano", + "years_many": "{{count}} anos", + "years_other": "{{count}} anos", "folders_zero": "0 pastas", "folders_one": "{{count}} pasta", "folders_other": "{{count}} pastas", @@ -418,6 +423,7 @@ "albums": "Álbuns", "artists": "Artistas", "genres": "Géneros", + "years": "Anos", "folders": "Pastas", "playlists": "Listas de reprodução", "needs-attention": "A corrigir" @@ -439,6 +445,10 @@ "title": "Não foi encontrado nenhum género", "description": "Importa ficheiros de áudio para descobrir os géneros." }, + "years": { + "title": "Nenhum ano encontrado", + "description": "As faixas aparecem aqui agrupadas pelo ano das suas etiquetas." + }, "folders": { "title": "Não há pastas importadas", "description": "Importa uma pasta a partir da barra de ações para a explorares aqui." @@ -511,6 +521,10 @@ "trackCount_other": "{{count}} faixas", "trackCount_many": "{{count}} faixas" }, + "yearList": { + "unknown": "Ano desconhecido", + "decade": "Anos {{decade}}" + }, "folderList": { "trackCount_zero": "0 faixas", "trackCount_one": "{{count}} faixa", @@ -1334,6 +1348,13 @@ "emptyTracksTitle": "Sem faixas", "emptyTracksDescription": "Este género não contém faixas disponíveis." }, + "yearDetail": { + "badge": "Ano", + "emptyTitle": "Ano não encontrado", + "emptyDescription": "Este ano já não tem faixas no perfil ativo.", + "emptyTracksTitle": "Sem faixas", + "emptyTracksDescription": "Nenhuma faixa disponível tem este ano." + }, "genreImagePicker": { "title": "Alterar imagem do género", "subtitle": "Escolhe um ficheiro de imagem local (JPG, PNG ou WEBP) para usar como imagem deste género.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 565bee57b..59b8db44d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -236,6 +236,7 @@ "albums": "Альбомы", "artists": "Исполнители", "genres": "Жанры", + "years": "Годы", "folders": "Папки" }, "playlistSection": { @@ -417,6 +418,11 @@ "albums_many": "{{count}} альбомов", "artists_many": "{{count}} исполнителей", "genres_many": "{{count}} жанров", + "years_zero": "0 лет", + "years_one": "{{count}} год", + "years_few": "{{count}} года", + "years_many": "{{count}} лет", + "years_other": "{{count}} года", "folders_many": "{{count}} папок", "playlists_zero": "0 плейлистов", "playlists_one": "{{count}} плейлист", @@ -435,6 +441,7 @@ "albums": "Альбомы", "artists": "Исполнители", "genres": "Жанры", + "years": "Годы", "folders": "Папки", "playlists": "Плейлисты", "needs-attention": "Требует внимания" @@ -456,6 +463,10 @@ "title": "Жанры не найдены", "description": "Импортируйте аудиофайлы, чтобы изучить разные жанры." }, + "years": { + "title": "Годы не найдены", + "description": "Здесь треки сгруппированы по году из их тегов." + }, "folders": { "title": "Папки не импортированы", "description": "Импортируйте папку с помощью панели действий, чтобы просматривать её здесь." @@ -533,6 +544,10 @@ "trackCount_other": "{{count}} треков", "trackCount_many": "{{count}} треков" }, + "yearList": { + "unknown": "Год неизвестен", + "decade": "{{decade}}-е" + }, "folderList": { "trackCount_zero": "0 треков", "trackCount_one": "{{count}} трек", @@ -1382,6 +1397,13 @@ "emptyTracksDescription": "В этом жанре нет доступных треков.", "trackCount_many": "{{count}} треков" }, + "yearDetail": { + "badge": "Год", + "emptyTitle": "Год не найден", + "emptyDescription": "В активном профиле больше нет треков этого года.", + "emptyTracksTitle": "Нет треков", + "emptyTracksDescription": "Ни у одного доступного трека нет этого года." + }, "genreImagePicker": { "title": "Изменить изображение жанра", "subtitle": "Выберите локальный файл изображения (JPG, PNG или WEBP), чтобы использовать его как изображение этого жанра.", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 6ffb6a706..53bb505f9 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -228,6 +228,7 @@ "albums": "Albümler", "artists": "Sanatçılar", "genres": "Türler", + "years": "Yıllar", "folders": "Klasörler" }, "playlistSection": { @@ -385,6 +386,9 @@ "genres_zero": "0 tür", "genres_one": "{{count}} tür", "genres_other": "{{count}} tür", + "years_zero": "0 yıl", + "years_one": "{{count}} yıl", + "years_other": "{{count}} yıl", "folders_zero": "0 klasör", "folders_one": "{{count}} klasör", "folders_other": "{{count}} klasör", @@ -401,6 +405,7 @@ "albums": "Albümler", "artists": "Sanatçılar", "genres": "Türler", + "years": "Yıllar", "folders": "Klasörler", "playlists": "Çalma listeleri", "needs-attention": "Düzeltilecekler" @@ -422,6 +427,10 @@ "title": "Tür bulunamadı", "description": "Farklı türleri keşfetmek için ses dosyaları içe aktar." }, + "years": { + "title": "Yıl bulunamadı", + "description": "Parçalar burada etiketlerindeki yıla göre gruplanır." + }, "folders": { "title": "İçe aktarılmış klasör yok", "description": "Burada gezinmek için eylem çubuğundan bir klasör içe aktar." @@ -489,6 +498,10 @@ "trackCount_one": "{{count}} parça", "trackCount_other": "{{count}} parça" }, + "yearList": { + "unknown": "Bilinmeyen yıl", + "decade": "{{decade}} yılları" + }, "folderList": { "trackCount_zero": "0 parça", "trackCount_one": "{{count}} parça", @@ -1284,6 +1297,13 @@ "emptyTracksTitle": "Parça yok", "emptyTracksDescription": "Bu tür kullanılabilir parça içermiyor." }, + "yearDetail": { + "badge": "Yıl", + "emptyTitle": "Yıl bulunamadı", + "emptyDescription": "Bu yılın etkin profilde artık parçası yok.", + "emptyTracksTitle": "Parça yok", + "emptyTracksDescription": "Bu yıla sahip kullanılabilir parça yok." + }, "genreImagePicker": { "title": "Tür görselini değiştir", "subtitle": "Bu türün görseli olarak kullanmak üzere yerel bir görsel dosyası (JPG, PNG veya WEBP) seç.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0c3fa6c36..46065eabb 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -228,6 +228,7 @@ "albums": "专辑", "artists": "艺人", "genres": "流派", + "years": "年份", "folders": "文件夹" }, "playlistSection": { @@ -385,6 +386,9 @@ "genres_zero": "0 种流派", "genres_one": "{{count}} 种流派", "genres_other": "{{count}} 种流派", + "years_zero": "0 个年份", + "years_one": "{{count}} 个年份", + "years_other": "{{count}} 个年份", "folders_zero": "0 个文件夹", "folders_one": "{{count}} 个文件夹", "folders_other": "{{count}} 个文件夹", @@ -401,6 +405,7 @@ "albums": "专辑", "artists": "艺人", "genres": "流派", + "years": "年份", "folders": "文件夹", "playlists": "播放列表", "needs-attention": "待修正" @@ -422,6 +427,10 @@ "title": "未找到任何流派", "description": "导入音频文件,探索不同音乐流派。" }, + "years": { + "title": "未找到年份", + "description": "歌曲会按标签中的年份分组显示在这里。" + }, "folders": { "title": "未导入任何文件夹", "description": "从操作栏导入一个文件夹,以便在此处浏览。" @@ -489,6 +498,10 @@ "trackCount_one": "{{count}} 首曲目", "trackCount_other": "{{count}} 首曲目" }, + "yearList": { + "unknown": "未知年份", + "decade": "{{decade}} 年代" + }, "folderList": { "trackCount_zero": "0 首曲目", "trackCount_one": "{{count}} 首曲目", @@ -1284,6 +1297,13 @@ "emptyTracksTitle": "无曲目", "emptyTracksDescription": "此流派没有可用曲目。" }, + "yearDetail": { + "badge": "年份", + "emptyTitle": "未找到年份", + "emptyDescription": "当前资料中已没有该年份的歌曲。", + "emptyTracksTitle": "没有歌曲", + "emptyTracksDescription": "没有可用的歌曲标注为该年份。" + }, "genreImagePicker": { "title": "更改流派图片", "subtitle": "选择一个本地图片文件(JPG、PNG 或 WEBP)作为该流派的图片。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 89c80111b..2dc516791 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -228,6 +228,7 @@ "albums": "專輯", "artists": "藝人", "genres": "曲風", + "years": "年份", "folders": "資料夾" }, "playlistSection": { @@ -385,6 +386,9 @@ "genres_zero": "0 種曲風", "genres_one": "{{count}} 種曲風", "genres_other": "{{count}} 種曲風", + "years_zero": "0 個年份", + "years_one": "{{count}} 個年份", + "years_other": "{{count}} 個年份", "folders_zero": "0 個資料夾", "folders_one": "{{count}} 個資料夾", "folders_other": "{{count}} 個資料夾", @@ -401,6 +405,7 @@ "albums": "專輯", "artists": "藝人", "genres": "曲風", + "years": "年份", "folders": "資料夾", "playlists": "播放清單", "needs-attention": "待修正" @@ -422,6 +427,10 @@ "title": "未找到任何曲風", "description": "匯入音訊檔案,探索各種曲風。" }, + "years": { + "title": "找不到年份", + "description": "歌曲會依標籤中的年份分組顯示在這裡。" + }, "folders": { "title": "未匯入任何資料夾", "description": "從操作列匯入一個資料夾,以便在此處瀏覽。" @@ -489,6 +498,10 @@ "trackCount_one": "{{count}} 首曲目", "trackCount_other": "{{count}} 首曲目" }, + "yearList": { + "unknown": "未知年份", + "decade": "{{decade}} 年代" + }, "folderList": { "trackCount_zero": "0 首曲目", "trackCount_one": "{{count}} 首曲目", @@ -1284,6 +1297,13 @@ "emptyTracksTitle": "無曲目", "emptyTracksDescription": "此曲風沒有可用的曲目。" }, + "yearDetail": { + "badge": "年份", + "emptyTitle": "找不到年份", + "emptyDescription": "目前的設定檔中已沒有該年份的歌曲。", + "emptyTracksTitle": "沒有歌曲", + "emptyTracksDescription": "沒有可用的歌曲標註為該年份。" + }, "genreImagePicker": { "title": "更改曲風圖片", "subtitle": "選擇一個本機圖片檔案(JPG、PNG 或 WEBP)作為此曲風的圖片。", diff --git a/src/lib/tauri/browse.ts b/src/lib/tauri/browse.ts index 24626d9ae..034d8441f 100644 --- a/src/lib/tauri/browse.ts +++ b/src/lib/tauri/browse.ts @@ -758,6 +758,56 @@ export async function listGenres( return resp.items.map((item) => expandGenreRow(item, resp.artwork_base, sep)); } +/** One year of the Years tab, from `list_years`. `year` is `null` for + * the tracks with no usable year ("Unknown year"). */ +export interface YearRow { + year: number | null; + track_count: number; + album_count: number; + /** Cover of the album with the most tracks that year. */ + artwork_path: string | null; + artwork_path_1x: string | null; + artwork_path_2x: string | null; +} + +interface YearRowSlim { + year: number | null; + track_count: number; + album_count: number; + artwork_hash: string | null; + artwork_format: string | null; + artwork_has_1x: boolean; + artwork_has_2x: boolean; +} + +interface ListYearsResponse { + artwork_base: string; + items: YearRowSlim[]; +} + +export async function listYears(libraryId: number | null): Promise { + const resp = await invoke("list_years", { libraryId }); + const base = resp.artwork_base; + const sep = pathSep(base); + return resp.items.map((item) => ({ + year: item.year, + track_count: item.track_count, + album_count: item.album_count, + artwork_path: + item.artwork_hash && item.artwork_format + ? `${base}${sep}${item.artwork_hash}.${item.artwork_format}` + : null, + artwork_path_1x: + item.artwork_hash && item.artwork_has_1x + ? `${base}${sep}${item.artwork_hash}_1x.jpg` + : null, + artwork_path_2x: + item.artwork_hash && item.artwork_has_2x + ? `${base}${sep}${item.artwork_hash}_2x.jpg` + : null, + })); +} + export function setGenreArtworkFromFile( genreId: number, filePath: string, diff --git a/src/lib/tauri/detail.ts b/src/lib/tauri/detail.ts index 7fb077c2f..3cd92a4dd 100644 --- a/src/lib/tauri/detail.ts +++ b/src/lib/tauri/detail.ts @@ -145,6 +145,23 @@ export function getGenreDetail(genreId: number): Promise { return invoke("get_genre_detail", { genreId }); } +// ── Year detail ───────────────────────────────────────────────────── + +export interface YearDetail { + /** `null`: the tracks with no usable year. */ + year: number | null; + track_count: number; + total_duration_ms: number; + artwork_path: string | null; + artwork_path_1x: string | null; + artwork_path_2x: string | null; + tracks: Track[]; +} + +export function getYearDetail(year: number | null): Promise { + return invoke("get_year_detail", { year }); +} + // ── Deezer enrichment ─────────────────────────────────────────────── export interface DeezerAlbumEnrichment { diff --git a/src/types/index.ts b/src/types/index.ts index 7a00b81e6..8ac5d1bad 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,6 +14,7 @@ export type ViewId = | "album-detail" | "artist-detail" | "genre-detail" + | "year-detail" | "web-radio" // A playlist that lives on the bound remote server (RFC-005 sync_v2). // The concrete playlist id is carried on the history entry, like the @@ -32,6 +33,7 @@ export type LibraryTab = | "albums" | "artists" | "genres" + | "years" | "playlists" | "folders" // The "needs attention" inventory (#589). Last, because it is From 5e15f7ee1114b22b01b8c3dbf9ef9a340702152c Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 11 Oct 2026 21:56:09 +0200 Subject: [PATCH 2/2] fix(library): clear a year page's tracks when it moves to another year Going back from one year to another keeps the page mounted, so the previous year's tracks stayed under the new title while it loaded. Data for another year is dropped and loading raised in the same pass, so no frame reads as not found. --- src/components/views/YearDetailView.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/views/YearDetailView.tsx b/src/components/views/YearDetailView.tsx index d37cc22e0..103ba7eee 100644 --- a/src/components/views/YearDetailView.tsx +++ b/src/components/views/YearDetailView.tsx @@ -71,9 +71,17 @@ export function YearDetailView({ setIsLoading(false); return; } + // Back from one year to another keeps this page mounted: the previous + // year's tracks must not stay under the new year's title while it + // loads. Data already for the requested year stays (a refetch). + // Loading is raised in the same pass, or the frame between would read + // "no data, not loading" and flash the not-found state. + setDetail((current) => + current != null && current.year !== year ? null : current, + ); + setIsLoading(true); let cancelled = false; (async () => { - setIsLoading(true); try { const loaded = await getYearDetail(year); if (!cancelled) setDetail(loaded);