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; + } + // 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 () => { + 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