From c72e71d4100c5d58d4420e1db19be0024285839c Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:18:53 -0600 Subject: [PATCH] =?UTF-8?q?feat(library):=20album-identity=20mode=20?= =?UTF-8?q?=E2=80=94=20header=20dropdown,=20Settings=20row,=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 2026-07-19-local-album-grouping-mode (A2-A5 + i18n). The Albums tab gains a third header dropdown ('Albums by folder' / 'Albums by metadata') and Settings > Local Library a matching 'Album grouping' row — both write the same pref: LocalLibraryState.albums-id-mode, persisted in locallibrary_ui.json (default 'folder': compilations and box sets stay whole; the metadata split stays opt-in for #411's use case). The group key changes with the mode, so switching reloads the Albums set (reload_albums) and invalidates the Artists tab (its album cache groups the same way — a folder-mode compilation cross-lists under every artist in all_artists, the owner's 'appears in all artists' ask). Threaded through spawn_albums_load, seed_counts, the Plex quality-hydration aggregate lookup, and the artists feed; myqbz_builder pins Metadata (artist-scoped candidates, not the Albums view). 5 msgids x 8 locales (msgfmt clean); slintcheck green. km --- .../translations/de/LC_MESSAGES/qbz-ui.po | 15 +++++++ .../translations/en/LC_MESSAGES/qbz-ui.po | 15 +++++++ .../translations/es/LC_MESSAGES/qbz-ui.po | 15 +++++++ .../translations/fr/LC_MESSAGES/qbz-ui.po | 15 +++++++ .../translations/ja/LC_MESSAGES/qbz-ui.po | 15 +++++++ .../translations/nl/LC_MESSAGES/qbz-ui.po | 15 +++++++ .../translations/pt/LC_MESSAGES/qbz-ui.po | 15 +++++++ .../translations/ru/LC_MESSAGES/qbz-ui.po | 15 +++++++ .../ui/locallibrary/LocalLibraryView.slint | 19 +++++++++ .../ui/settings/LocalLibrarySettings.slint | 29 +++++++++++++- crates/qbz-ui/ui/state.slint | 9 +++++ crates/qbz/src/local_library.rs | 39 +++++++++++++++++++ crates/qbz/src/locallibrary_prefs.rs | 12 ++++++ crates/qbz/src/main.rs | 19 +++++++++ crates/qbz/src/myqbz_builder.rs | 4 ++ 15 files changed, 250 insertions(+), 1 deletion(-) diff --git a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po index 47c512e20..e3cba3cff 100644 --- a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po @@ -4750,3 +4750,18 @@ msgstr "Begrenzt durch das Qualitätslimit für {}" msgid "capped for this device" msgstr "für dieses Gerät begrenzt" + +msgid "Albums by folder" +msgstr "Alben nach Ordner" + +msgid "Albums by metadata" +msgstr "Alben nach Metadaten" + +msgid "ALBUMS VIEW" +msgstr "ALBENANSICHT" + +msgid "Album grouping" +msgstr "Album-Gruppierung" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Ordner: eine Karte pro Album-Ordner — ideal für Compilations und Boxsets. Metadaten: nach Album- + Künstler-Tags aufteilen." diff --git a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po index e73750874..1303549ae 100644 --- a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po @@ -543,3 +543,18 @@ msgstr "Limited by the quality cap for {}" msgid "capped for this device" msgstr "capped for this device" + +msgid "Albums by folder" +msgstr "Albums by folder" + +msgid "Albums by metadata" +msgstr "Albums by metadata" + +msgid "ALBUMS VIEW" +msgstr "ALBUMS VIEW" + +msgid "Album grouping" +msgstr "Album grouping" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." diff --git a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po index b66a9cbc3..8fbc23248 100644 --- a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po @@ -4780,3 +4780,18 @@ msgstr "Limitado por el límite de calidad para {}" msgid "capped for this device" msgstr "limitado para este dispositivo" + +msgid "Albums by folder" +msgstr "Álbumes por carpeta" + +msgid "Albums by metadata" +msgstr "Álbumes por metadatos" + +msgid "ALBUMS VIEW" +msgstr "VISTA DE ÁLBUMES" + +msgid "Album grouping" +msgstr "Agrupación de álbumes" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Carpetas: una tarjeta por carpeta de álbum — ideal para compilaciones y box sets. Metadatos: separar por etiquetas de álbum + artista." diff --git a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po index ef3da1b14..9990a64e1 100644 --- a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po @@ -4750,3 +4750,18 @@ msgstr "Limité par le plafond de qualité pour {}" msgid "capped for this device" msgstr "plafonné pour cet appareil" + +msgid "Albums by folder" +msgstr "Albums par dossier" + +msgid "Albums by metadata" +msgstr "Albums par métadonnées" + +msgid "ALBUMS VIEW" +msgstr "VUE ALBUMS" + +msgid "Album grouping" +msgstr "Regroupement des albums" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Dossiers : une carte par dossier d'album — idéal pour les compilations et coffrets. Métadonnées : séparer par tags album + artiste." diff --git a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po index 625d6d3ab..1d7e96e64 100644 --- a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po @@ -4738,3 +4738,18 @@ msgstr "{}の音質上限により制限されています" msgid "capped for this device" msgstr "このデバイス向けに上限適用中" + +msgid "Albums by folder" +msgstr "フォルダー別アルバム" + +msgid "Albums by metadata" +msgstr "メタデータ別アルバム" + +msgid "ALBUMS VIEW" +msgstr "アルバムビュー" + +msgid "Album grouping" +msgstr "アルバムのグループ化" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "フォルダー: アルバムフォルダーごとに1枚のカード — コンピレーションやボックスセットに最適。メタデータ: アルバム+アーティストのタグで分割。" diff --git a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po index 7340529e4..6a5ae84cb 100644 --- a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po @@ -4762,3 +4762,18 @@ msgstr "Beperkt door de kwaliteitslimiet voor {}" msgid "capped for this device" msgstr "begrensd voor dit apparaat" + +msgid "Albums by folder" +msgstr "Albums per map" + +msgid "Albums by metadata" +msgstr "Albums per metadata" + +msgid "ALBUMS VIEW" +msgstr "ALBUMWEERGAVE" + +msgid "Album grouping" +msgstr "Albumgroepering" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Mappen: één kaart per albummap — ideaal voor compilaties en boxsets. Metadata: splitsen op album- + artiesttags." diff --git a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po index 1b8439c03..617f6b274 100644 --- a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po @@ -4750,3 +4750,18 @@ msgstr "Limitado pelo limite de qualidade para {}" msgid "capped for this device" msgstr "limitado para este dispositivo" + +msgid "Albums by folder" +msgstr "Álbuns por pasta" + +msgid "Albums by metadata" +msgstr "Álbuns por metadados" + +msgid "ALBUMS VIEW" +msgstr "VISTA DE ÁLBUNS" + +msgid "Album grouping" +msgstr "Agrupamento de álbuns" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Pastas: um cartão por pasta de álbum — ideal para compilações e box sets. Metadados: dividir por etiquetas de álbum + artista." diff --git a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po index 14207127c..f88faecb0 100644 --- a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po @@ -4764,3 +4764,18 @@ msgstr "Ограничено пределом качества для {}" msgid "capped for this device" msgstr "ограничено для этого устройства" + +msgid "Albums by folder" +msgstr "Альбомы по папкам" + +msgid "Albums by metadata" +msgstr "Альбомы по метаданным" + +msgid "ALBUMS VIEW" +msgstr "ВИД АЛЬБОМОВ" + +msgid "Album grouping" +msgstr "Группировка альбомов" + +msgid "Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags." +msgstr "Папки: одна карточка на папку альбома — лучше для сборников и бокс-сетов. Метаданные: разделение по тегам альбома + исполнителя." diff --git a/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint b/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint index c79a79bde..3b8e9bf64 100644 --- a/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint +++ b/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint @@ -838,6 +838,25 @@ export component LocalLibraryView inherits Rectangle { } } } + // Album identity ("Albums by") — what one album IS: a folder + // (compilations/box sets stay whole) or metadata tag pairs + // (split per album+artist, #411). Persisted; reloads the set. + VerticalLayout { + alignment: center; + QbzSelect { + menu-width: 190px; + sm: true; + options: [ + @tr("Albums by folder"), + @tr("Albums by metadata"), + ]; + current-index: LocalLibraryState.albums-id-mode == "metadata" ? 1 : 0; + selected(i) => { + LocalLibraryActions.albums-set-id-mode( + i == 1 ? "metadata" : "folder"); + } + } + } // Quality/format/source filter (popup) + active-count badge. VerticalLayout { alignment: center; diff --git a/crates/qbz-ui/ui/settings/LocalLibrarySettings.slint b/crates/qbz-ui/ui/settings/LocalLibrarySettings.slint index 74fff42ae..c96e4aadd 100644 --- a/crates/qbz-ui/ui/settings/LocalLibrarySettings.slint +++ b/crates/qbz-ui/ui/settings/LocalLibrarySettings.slint @@ -9,11 +9,12 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; -import { LibraryFoldersState, LibraryManageActions, LibraryScanState, PlexSettingsState, PlexAuthActions , UiFocusState } from "../state.slint"; +import { LibraryFoldersState, LibraryManageActions, LibraryScanState, PlexSettingsState, PlexAuthActions , UiFocusState, LocalLibraryState, LocalLibraryActions } from "../state.slint"; import { SettingRow } from "SettingRow.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzCheckbox } from "../primitives/QbzCheckbox.slint"; import { QbzToggle } from "../primitives/QbzToggle.slint"; +import { QbzSelect } from "../primitives/QbzSelect.slint"; import { ExpandableSearch } from "../primitives/ExpandableSearch.slint"; import { ScrollView, LineEdit } from "std-widgets.slint"; @@ -421,6 +422,32 @@ export component LocalLibrarySettings inherits VerticalLayout { Rectangle { height: 22px; } + // --- Albums view ------------------------------------------------------- + // Same pref as the Albums-tab header dropdown — this is the discoverable + // home, the header is the quick switch (both write albums-id-mode). + GroupHeader { text: @tr("ALBUMS VIEW"); } + SettingRow { + label: @tr("Album grouping"); + description: @tr("Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags."); + VerticalLayout { + alignment: center; + QbzSelect { + menu-width: 190px; + sm: true; + options: [ + @tr("Albums by folder"), + @tr("Albums by metadata"), + ]; + current-index: LocalLibraryState.albums-id-mode == "metadata" ? 1 : 0; + selected(i) => { + LocalLibraryActions.albums-set-id-mode(i == 1 ? "metadata" : "folder"); + } + } + } + } + + Rectangle { height: 22px; } + // --- Maintenance ----------------------------------------------------- GroupHeader { text: @tr("MAINTENANCE"); } SettingRow { diff --git a/crates/qbz-ui/ui/state.slint b/crates/qbz-ui/ui/state.slint index 090f556c7..c8191ecb2 100644 --- a/crates/qbz-ui/ui/state.slint +++ b/crates/qbz-ui/ui/state.slint @@ -1194,6 +1194,12 @@ export global LocalLibraryState { in property <[AlbumCardItem]> albums-visible: []; in property <[DiscoverSection]> albums-grouped: []; in-out property albums-group: "off"; // off | alpha | artist + // Album IDENTITY mode — what one "album" IS on the Albums tab: + // "folder" (one card per album-root folder; default — compilations/box + // sets stay whole) | "metadata" (split by album+artist tags, #411). + // Persisted in locallibrary_ui.json; switching re-queries the DB (unlike + // sort/group, which re-derive client-side). + in-out property albums-id-mode: "folder"; in property <[AlphaJump]> albums-alpha: []; // A-Z jumps (alpha group) in property albums-shown: 0; // count after filter in-out property albums-multi-select: false; // album checkboxes on @@ -1318,6 +1324,9 @@ export global LocalLibraryActions { callback albums-set-sort(string /* sort key */); // Set group mode (off|alpha|artist) + re-derive. callback albums-set-group(string /* mode */); + // Set the album identity mode (folder|metadata) + persist + reload the + // albums set (the group key changes — a derive is not enough). + callback albums-set-id-mode(string /* mode */); // Set grid|list view. callback albums-set-view(string /* mode */); // A quality/format/source filter toggle changed -> re-derive. diff --git a/crates/qbz/src/local_library.rs b/crates/qbz/src/local_library.rs index 43cbedc0d..6d9f24393 100644 --- a/crates/qbz/src/local_library.rs +++ b/crates/qbz/src/local_library.rs @@ -720,12 +720,36 @@ pub fn reset_browse_models(window: &AppWindow) { s.set_artists(ModelRc::new(VecModel::from(Vec::::new()))); } +/// Album-identity mode changed: invalidate ONLY the Artists tab (its album +/// cache + right pane depend on the group key — a folder-mode compilation +/// cross-lists under every artist). The Albums tab reloads separately via +/// `reload_albums`; tracks/folders don't depend on album identity. +pub fn invalidate_artists(window: &AppWindow) { + let s = window.global::(); + s.set_artists(ModelRc::new(VecModel::from(Vec::::new()))); + s.set_artists_loading(false); + if let Ok(mut cache) = ARTIST_ALBUMS.lock() { + cache.clear(); + } +} + /// Upper bound for the full-load page. The Albums tab loads the entire set in /// one shot (search/sort/filter/group are all derived client-side over the /// cached set in `derive_albums`), so we request a single large page rather /// than truly paginating. `total` from the page is informational here. const ALBUMS_FULL_LOAD_LIMIT: u64 = 1_000_000; +/// The user's album-identity mode (Albums tab dropdown / Settings, persisted +/// in locallibrary_ui.json). Read on the UI thread and captured into the +/// blocking DB closures — Slint state is not thread-safe. +pub fn current_group_mode(window: &AppWindow) -> qbz_library::album_grouping::AlbumGroupMode { + qbz_library::album_grouping::AlbumGroupMode::from_pref( + &window + .global::() + .get_albums_id_mode(), + ) +} + /// Full-load the metadata-grouped albums off the UI thread (mapping + cover /// fallback resolution all happen on the blocking thread), store the raw cache /// + the mapped card set, then derive + spawn covers on the UI thread. @@ -743,6 +767,7 @@ fn spawn_albums_load( let weak = window.as_weak(); let plex_path = plex_cache_db_path(); let plex = crate::plex_settings::get(); + let group_mode = current_group_mode(window); handle.spawn(async move { let loaded: Option<(Vec, Vec)> = tokio::task::spawn_blocking(move || { @@ -761,6 +786,7 @@ fn spawn_albums_load( true, exclude_network, plex_path.as_deref(), + group_mode, )?; let albums = page.albums; let cards: Vec = albums @@ -846,6 +872,10 @@ pub fn reload_albums( pub fn seed_counts(weak: slint::Weak, handle: tokio::runtime::Handle) { let plex_path = plex_cache_db_path(); let plex_enabled = crate::plex_settings::get().enabled; + let group_mode = weak + .upgrade() + .map(|w| current_group_mode(&w)) + .unwrap_or(qbz_library::album_grouping::AlbumGroupMode::Folder); handle.spawn(async move { let counts: Option<(usize, usize, usize, usize)> = tokio::task::spawn_blocking(move || { // Same include-qobuz / network flags as the Albums tab loader, so @@ -865,6 +895,7 @@ pub fn seed_counts(weak: slint::Weak, handle: tokio::runtime::Handle) true, exclude_network, plex_path.as_deref(), + group_mode, ) .map(|p| p.total as usize) .unwrap_or(0); @@ -2008,6 +2039,12 @@ fn spawn_plex_quality_hydration( handle: tokio::runtime::Handle, group_key: String, ) { + // The aggregate lookup below must run under the SAME album-identity mode + // as the Albums tab (the album id IS the mode's group key). + let group_mode = weak + .upgrade() + .map(|w| current_group_mode(&w)) + .unwrap_or(qbz_library::album_grouping::AlbumGroupMode::Folder); // Collect rating_keys whose cached quality is still incomplete. The Plex // LocalTrack carries the rating_key in `file_path` and the cached quality in // `bit_depth` / `sample_rate` (0.0 == unknown). @@ -2084,6 +2121,7 @@ fn spawn_plex_quality_hydration( true, exclude_network, Some(p), + group_mode, )?; Ok(page.albums.into_iter().find(|a| a.id == gk)) }) @@ -3505,6 +3543,7 @@ pub fn ensure_artists_loaded( true, exclude_network, plex_path.as_deref(), + group_mode, ) .map(|p| p.albums) }) diff --git a/crates/qbz/src/locallibrary_prefs.rs b/crates/qbz/src/locallibrary_prefs.rs index 85d23d1e4..385050f04 100644 --- a/crates/qbz/src/locallibrary_prefs.rs +++ b/crates/qbz/src/locallibrary_prefs.rs @@ -15,6 +15,11 @@ struct Prefs { tracks_group: String, #[serde(default = "d_default")] tracks_sort: String, + // Album identity mode ("folder" | "metadata") — what one album IS on the + // Albums tab. Default "folder": compilations/box sets stay whole (the + // metadata split is opt-in via the header dropdown or Settings). + #[serde(default = "d_folder")] + albums_id_mode: String, // Path of the last-opened ephemeral folder, re-scanned on startup. None when // no ephemeral session is active. #[serde(default)] @@ -26,6 +31,7 @@ impl Default for Prefs { Self { tracks_group: d_off(), tracks_sort: d_default(), + albums_id_mode: d_folder(), ephemeral_folder: None, } } @@ -39,6 +45,10 @@ fn d_default() -> String { "default".to_string() } +fn d_folder() -> String { + "folder".to_string() +} + fn store_path() -> Option { Some(dirs::data_dir()?.join("qbz").join("locallibrary_ui.json")) } @@ -59,6 +69,7 @@ pub fn load(window: &AppWindow) { let s = window.global::(); s.set_tracks_group_mode(p.tracks_group.into()); s.set_tracks_sort(p.tracks_sort.into()); + s.set_albums_id_mode(p.albums_id_mode.into()); } fn write(p: &Prefs) { @@ -80,6 +91,7 @@ pub fn save(window: &AppWindow) { let s = window.global::(); p.tracks_group = s.get_tracks_group_mode().into(); p.tracks_sort = s.get_tracks_sort().into(); + p.albums_id_mode = s.get_albums_id_mode().into(); write(&p); } diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index a4de2ed0a..0c4de2254 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -18060,6 +18060,25 @@ fn main() -> Result<(), Box> { } }); } + { + // Album-identity mode (folder|metadata): the group KEY changes, so a + // client-side derive is not enough — persist, reload the Albums set, + // and invalidate the Artists tab (its album cache groups the same + // way). Header dropdown + Settings row both land here. + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_albums_set_id_mode(move |mode| { + if let Some(w) = weak.upgrade() { + w.global::().set_albums_id_mode(mode.into()); + crate::locallibrary_prefs::save(&w); + local_library::invalidate_artists(&w); + local_library::reload_albums(w.as_weak(), handle.clone(), image_cache.clone()); + } + }); + } { let weak = window.as_weak(); window diff --git a/crates/qbz/src/myqbz_builder.rs b/crates/qbz/src/myqbz_builder.rs index 923a2edf6..464caabc6 100644 --- a/crates/qbz/src/myqbz_builder.rs +++ b/crates/qbz/src/myqbz_builder.rs @@ -430,6 +430,10 @@ pub fn fetch_local_and_plex(artist_name: &str) -> Vec { true, exclude_network, plex_path.as_deref(), + // My QBZ collections are artist-scoped CANDIDATES, not the Albums + // view — keep the metadata grouping regardless of the user's + // Local Library identity-mode pref. + qbz_library::album_grouping::AlbumGroupMode::Metadata, )?; let out: Vec = page .albums