From f01950571dd4b413d3a9c893ca026d0a5f014490 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:10:39 -0600 Subject: [PATCH] fix(purchases): restore local copy and download semantics Restore downward stream-format downloads while keeping purchased masters on the download grant. Make Purchases playback prefer the highest complete local copy, repair duplicate copy inventory, and move copy management into a bounded modal with safe library and record actions. cx --- .../translations/de/LC_MESSAGES/qbz-ui.po | 18 + .../translations/en/LC_MESSAGES/qbz-ui.po | 18 + .../translations/es/LC_MESSAGES/qbz-ui.po | 18 + .../translations/fr/LC_MESSAGES/qbz-ui.po | 18 + .../translations/ja/LC_MESSAGES/qbz-ui.po | 18 + .../translations/nl/LC_MESSAGES/qbz-ui.po | 18 + .../translations/pt/LC_MESSAGES/qbz-ui.po | 18 + .../translations/ru/LC_MESSAGES/qbz-ui.po | 18 + crates/qbz-library/src/purchase_copies.rs | 348 +++++++++++++- .../src/purchases_service.rs | 18 +- crates/qbz-qt/qml/views/PurchaseAlbumView.qml | 431 ++++++++++++++---- crates/qbz-qt/src/purchases_bridge.rs | 36 +- crates/qbz-qt/src/purchases_qt.rs | 367 ++++++++++++++- 13 files changed, 1213 insertions(+), 131 deletions(-) diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 0725c2e42..ba819e85c 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -1425,6 +1425,24 @@ msgstr "Jetzt mit der Wiedergabe beginnen?" msgid "Not now" msgstr "Jetzt nicht" +msgid "Show location" +msgstr "Speicherort anzeigen" + +msgid "Remove from Local Library" +msgstr "Aus lokaler Bibliothek entfernen" + +msgid "Forget download record" +msgstr "Download-Eintrag vergessen" + +msgid "Could not open that folder." +msgstr "Dieser Ordner konnte nicht geöffnet werden." + +msgid "Could not remove that library folder." +msgstr "Dieser Bibliotheksordner konnte nicht entfernt werden." + +msgid "Could not forget that download." +msgstr "Dieser Download konnte nicht vergessen werden." + msgid "Create radio" msgstr "Radio erstellen" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index b3f23e1a1..0948d3bc6 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -2278,6 +2278,24 @@ msgstr "Download finished" msgid "Not now" msgstr "Not now" +msgid "Show location" +msgstr "Show location" + +msgid "Remove from Local Library" +msgstr "Remove from Local Library" + +msgid "Forget download record" +msgstr "Forget download record" + +msgid "Could not open that folder." +msgstr "Could not open that folder." + +msgid "Could not remove that library folder." +msgstr "Could not remove that library folder." + +msgid "Could not forget that download." +msgstr "Could not forget that download." + msgid "Shuffle: On" msgstr "Shuffle: On" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 1fc6e970d..9d3e71d63 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -1431,6 +1431,24 @@ msgstr "¿Iniciar la reproducción ahora?" msgid "Not now" msgstr "Ahora no" +msgid "Show location" +msgstr "Mostrar ubicación" + +msgid "Remove from Local Library" +msgstr "Quitar de la biblioteca local" + +msgid "Forget download record" +msgstr "Olvidar registro de descarga" + +msgid "Could not open that folder." +msgstr "No se pudo abrir esa carpeta." + +msgid "Could not remove that library folder." +msgstr "No se pudo quitar esa carpeta de la biblioteca." + +msgid "Could not forget that download." +msgstr "No se pudo olvidar esa descarga." + msgid "Create radio" msgstr "Crear radio" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 3658216c8..efff51ced 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -1425,6 +1425,24 @@ msgstr "Lancer la lecture maintenant ?" msgid "Not now" msgstr "Pas maintenant" +msgid "Show location" +msgstr "Afficher l’emplacement" + +msgid "Remove from Local Library" +msgstr "Retirer de la bibliothèque locale" + +msgid "Forget download record" +msgstr "Oublier l’enregistrement du téléchargement" + +msgid "Could not open that folder." +msgstr "Impossible d’ouvrir ce dossier." + +msgid "Could not remove that library folder." +msgstr "Impossible de retirer ce dossier de la bibliothèque." + +msgid "Could not forget that download." +msgstr "Impossible d’oublier ce téléchargement." + msgid "Create radio" msgstr "Créer une radio" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 5429c787e..5943c61f8 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -1386,6 +1386,24 @@ msgstr "今すぐ再生を開始しますか?" msgid "Not now" msgstr "今はしない" +msgid "Show location" +msgstr "場所を表示" + +msgid "Remove from Local Library" +msgstr "ローカルライブラリから削除" + +msgid "Forget download record" +msgstr "ダウンロード記録を消去" + +msgid "Could not open that folder." +msgstr "このフォルダーを開けませんでした。" + +msgid "Could not remove that library folder." +msgstr "このフォルダーをライブラリから削除できませんでした。" + +msgid "Could not forget that download." +msgstr "このダウンロード記録を消去できませんでした。" + msgid "Create radio" msgstr "ラジオを作成" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 13d114ddd..dfb97eba7 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -1425,6 +1425,24 @@ msgstr "Nu beginnen met afspelen?" msgid "Not now" msgstr "Niet nu" +msgid "Show location" +msgstr "Locatie tonen" + +msgid "Remove from Local Library" +msgstr "Uit lokale bibliotheek verwijderen" + +msgid "Forget download record" +msgstr "Downloadregistratie vergeten" + +msgid "Could not open that folder." +msgstr "Deze map kon niet worden geopend." + +msgid "Could not remove that library folder." +msgstr "Deze bibliotheekmap kon niet worden verwijderd." + +msgid "Could not forget that download." +msgstr "Deze download kon niet worden vergeten." + msgid "Create radio" msgstr "Radio aanmaken" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index 473318fbb..ae14cd3ab 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -1425,6 +1425,24 @@ msgstr "Iniciar a reprodução agora?" msgid "Not now" msgstr "Agora não" +msgid "Show location" +msgstr "Mostrar localização" + +msgid "Remove from Local Library" +msgstr "Remover da biblioteca local" + +msgid "Forget download record" +msgstr "Esquecer registro do download" + +msgid "Could not open that folder." +msgstr "Não foi possível abrir essa pasta." + +msgid "Could not remove that library folder." +msgstr "Não foi possível remover essa pasta da biblioteca." + +msgid "Could not forget that download." +msgstr "Não foi possível esquecer esse download." + msgid "Create radio" msgstr "Criar rádio" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index d0ffd53f0..8fef6dbb3 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -1387,6 +1387,24 @@ msgstr "Начать воспроизведение сейчас?" msgid "Not now" msgstr "Не сейчас" +msgid "Show location" +msgstr "Показать расположение" + +msgid "Remove from Local Library" +msgstr "Удалить из локальной библиотеки" + +msgid "Forget download record" +msgstr "Забыть запись о загрузке" + +msgid "Could not open that folder." +msgstr "Не удалось открыть эту папку." + +msgid "Could not remove that library folder." +msgstr "Не удалось удалить эту папку из библиотеки." + +msgid "Could not forget that download." +msgstr "Не удалось забыть эту загрузку." + msgid "Create radio" msgstr "Создать радио" diff --git a/crates/qbz-library/src/purchase_copies.rs b/crates/qbz-library/src/purchase_copies.rs index 5f88c225c..37e43ebea 100644 --- a/crates/qbz-library/src/purchase_copies.rs +++ b/crates/qbz-library/src/purchase_copies.rs @@ -5,7 +5,7 @@ //! always evaluated inside one `copy_id`, never by combining folders. use crate::{FileProbe, LibraryDatabase, LibraryError}; -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashSet}; use std::path::Path; @@ -138,14 +138,136 @@ pub(crate) fn init_schema(conn: &Connection) -> Result<(), LibraryError> { ); "#, ) - .map_err(|e| LibraryError::Database(format!("purchase copies schema: {e}"))) + .map_err(|e| LibraryError::Database(format!("purchase copies schema: {e}")))?; + + // A short-lived race in the first additive-registry migration could import + // the same physical album folder twice. Heal those rows before enforcing + // the identity invariant so old profiles do not keep rendering duplicate + // copies forever. + let repaired = deduplicate_copy_identities(conn)?; + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_purchase_copies_identity + ON purchase_download_copies(album_id, format_id, resolved_album_folder)", + [], + ) + .map_err(|e| LibraryError::Database(format!("purchase copy identity index: {e}")))?; + if repaired > 0 { + log::info!("Repaired {repaired} duplicate purchase download copy record(s)"); + } + Ok(()) +} + +/// Merge duplicate physical identities without losing coverage. The newest +/// row wins conflicts for one track; disjoint track and expected-track rows +/// from older duplicates are folded into it before those rows are removed. +fn deduplicate_copy_identities(conn: &Connection) -> Result { + let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate).map_err(db_error)?; + let identities = { + let mut stmt = tx + .prepare( + "SELECT album_id, format_id, resolved_album_folder + FROM purchase_download_copies + GROUP BY album_id, format_id, resolved_album_folder + HAVING COUNT(*) > 1", + ) + .map_err(db_error)?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + rows + }; + + let mut removed = 0; + for (album_id, format_id, folder) in identities { + let copy_ids = { + let mut stmt = tx + .prepare( + "SELECT copy_id FROM purchase_download_copies + WHERE album_id = ?1 AND format_id = ?2 AND resolved_album_folder = ?3 + ORDER BY updated_at DESC, created_at DESC, copy_id", + ) + .map_err(db_error)?; + let rows = stmt + .query_map(params![album_id, format_id, folder], |row| row.get(0)) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + rows + }; + let Some(keeper) = copy_ids.first() else { + continue; + }; + tx.execute( + "UPDATE purchase_download_copies + SET created_at = (SELECT MIN(created_at) FROM purchase_download_copies + WHERE album_id = ?2 AND format_id = ?3 + AND resolved_album_folder = ?4), + updated_at = (SELECT MAX(updated_at) FROM purchase_download_copies + WHERE album_id = ?2 AND format_id = ?3 + AND resolved_album_folder = ?4) + WHERE copy_id = ?1", + params![keeper, album_id, format_id, folder], + ) + .map_err(db_error)?; + + for duplicate in copy_ids.iter().skip(1) { + tx.execute( + "INSERT OR IGNORE INTO purchase_download_copy_expected_tracks(copy_id, track_id) + SELECT ?1, track_id FROM purchase_download_copy_expected_tracks + WHERE copy_id = ?2", + params![keeper, duplicate], + ) + .map_err(db_error)?; + tx.execute( + "INSERT OR IGNORE INTO purchase_download_copy_tracks + (copy_id, track_id, file_path, mime_type, container, sample_rate_hz, + bit_depth, channels, file_size, modified_ns, last_verified_at) + SELECT ?1, track_id, file_path, mime_type, container, sample_rate_hz, + bit_depth, channels, file_size, modified_ns, last_verified_at + FROM purchase_download_copy_tracks WHERE copy_id = ?2", + params![keeper, duplicate], + ) + .map_err(db_error)?; + // Foreign keys are intentionally not enabled on the app's SQLite + // connections, so remove children explicitly before the parent. + tx.execute( + "DELETE FROM purchase_download_copy_expected_tracks WHERE copy_id = ?1", + [duplicate], + ) + .map_err(db_error)?; + tx.execute( + "DELETE FROM purchase_download_copy_tracks WHERE copy_id = ?1", + [duplicate], + ) + .map_err(db_error)?; + removed += tx + .execute( + "DELETE FROM purchase_download_copies WHERE copy_id = ?1", + [duplicate], + ) + .map_err(db_error)?; + } + } + tx.commit().map_err(db_error)?; + Ok(removed) } /// One-time, filesystem-free import of the compatibility registry. Grouping by /// album + format + parent folder is what prevents an old split registry from /// becoming one fabricated complete copy. pub(crate) fn migrate_legacy_registry(conn: &Connection) -> Result<(), LibraryError> { - let done = conn + // Serialize the marker check with the import itself. A process-local schema + // lock cannot protect two QBZ processes opening the same profile at once. + let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate).map_err(db_error)?; + let done = tx .query_row( "SELECT value FROM library_kv WHERE key = ?1", [LEGACY_MIGRATION_KEY], @@ -155,10 +277,10 @@ pub(crate) fn migrate_legacy_registry(conn: &Connection) -> Result<(), LibraryEr .map_err(|e| LibraryError::Database(format!("read purchase migration marker: {e}")))? .is_some(); if done { - return Ok(()); + return tx.commit().map_err(db_error); } - let mut stmt = conn + let mut stmt = tx .prepare( "SELECT track_id, format_id, album_id, file_path FROM downloaded_purchases WHERE album_id IS NOT NULL @@ -191,28 +313,33 @@ pub(crate) fn migrate_legacy_registry(conn: &Connection) -> Result<(), LibraryEr } let now = unix_now(); - let tx = conn - .unchecked_transaction() - .map_err(|e| LibraryError::Database(format!("begin purchase migration: {e}")))?; for ((album_id, format_id, folder), tracks) in groups { let copy_id = Uuid::new_v4().to_string(); tx.execute( - "INSERT INTO purchase_download_copies + "INSERT OR IGNORE INTO purchase_download_copies (copy_id, album_id, format_id, resolved_album_folder, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?5)", params![copy_id, album_id, format_id, folder, now], ) .map_err(|e| LibraryError::Database(format!("migrate purchase copy: {e}")))?; + let effective_copy_id: String = tx + .query_row( + "SELECT copy_id FROM purchase_download_copies + WHERE album_id = ?1 AND format_id = ?2 AND resolved_album_folder = ?3", + params![album_id, format_id, folder], + |row| row.get(0), + ) + .map_err(|e| LibraryError::Database(format!("resolve migrated purchase copy: {e}")))?; for (track_id, file_path) in tracks { let container = Path::new(&file_path) .extension() .and_then(|value| value.to_str()) .map(|value| value.to_ascii_lowercase()); tx.execute( - "INSERT INTO purchase_download_copy_tracks + "INSERT OR IGNORE INTO purchase_download_copy_tracks (copy_id, track_id, file_path, container, file_size) VALUES (?1, ?2, ?3, ?4, 0)", - params![copy_id, track_id, file_path, container], + params![effective_copy_id, track_id, file_path, container], ) .map_err(|e| LibraryError::Database(format!("migrate purchase track: {e}")))?; } @@ -384,6 +511,61 @@ impl LibraryDatabase { self.with_connection(|conn| select_copy_tracks(conn, copy_id)) } + /// Forget one inventory row while leaving the user's files untouched. + /// Any compatibility pointers that still name paths from this exact copy + /// are removed with it; pointers promoted from another copy survive. + pub fn delete_purchase_copy_record(&self, copy_id: &str) -> Result { + self.with_connection(|conn| { + let Some(copy) = select_copy(conn, copy_id)? else { + return Ok(false); + }; + let tracks = select_copy_tracks(conn, copy_id)?; + let tx = conn.unchecked_transaction().map_err(db_error)?; + for track in &tracks { + tx.execute( + "DELETE FROM downloaded_purchases + WHERE track_id = ?1 AND format_id = ?2 AND album_id = ?3 AND file_path = ?4", + params![ + track.track_id, + copy.format_id, + copy.album_id, + track.file_path + ], + ) + .map_err(db_error)?; + } + tx.execute( + "DELETE FROM purchase_download_copy_expected_tracks WHERE copy_id = ?1", + [copy_id], + ) + .map_err(db_error)?; + tx.execute( + "DELETE FROM purchase_download_copy_tracks WHERE copy_id = ?1", + [copy_id], + ) + .map_err(db_error)?; + let removed = tx + .execute( + "DELETE FROM purchase_download_copies WHERE copy_id = ?1", + [copy_id], + ) + .map_err(db_error)? + > 0; + tx.execute( + "DELETE FROM purchase_album_playback_preferences + WHERE album_id = ?1 AND mode = 'purchase' AND format_id = ?2 + AND NOT EXISTS ( + SELECT 1 FROM purchase_download_copies + WHERE album_id = ?1 AND format_id = ?2 + )", + params![copy.album_id, copy.format_id], + ) + .map_err(db_error)?; + tx.commit().map_err(db_error)?; + Ok(removed) + }) + } + /// Atomically point the compatibility registry at `copy_id`, but only if /// that one copy covers every expected album track. pub fn promote_complete_purchase_copy( @@ -875,27 +1057,34 @@ mod tests { let root = tempfile::tempdir().unwrap(); let db = LibraryDatabase::open(&root.path().join("library.db")).unwrap(); + let complete_dir = root.path().join("complete"); + let partial_dir = root.path().join("partial"); + let legacy_dir = root.path().join("legacy"); + std::fs::create_dir_all(&complete_dir).unwrap(); + std::fs::create_dir_all(&partial_dir).unwrap(); + std::fs::create_dir_all(&legacy_dir).unwrap(); + let complete = db - .create_purchase_copy_with_expected_tracks("album", 55, root.path(), &[1, 2]) + .create_purchase_copy_with_expected_tracks("album", 55, &complete_dir, &[1, 2]) .unwrap(); for id in [1, 2] { - let path = root.path().join(format!("complete-{id}.dsf")); + let path = complete_dir.join(format!("complete-{id}.dsf")); std::fs::write(&path, b"dsd").unwrap(); db.upsert_purchase_copy_track(&track(&complete.copy_id, id, &path)) .unwrap(); } let partial = db - .create_purchase_copy_with_expected_tracks("album", 55, root.path(), &[1, 2]) + .create_purchase_copy_with_expected_tracks("album", 55, &partial_dir, &[1, 2]) .unwrap(); - let partial_path = root.path().join("partial-1.dsf"); + let partial_path = partial_dir.join("partial-1.dsf"); std::fs::write(&partial_path, b"dsd").unwrap(); db.upsert_purchase_copy_track(&track(&partial.copy_id, 1, &partial_path)) .unwrap(); - let legacy = db.create_purchase_copy("album", 55, root.path()).unwrap(); + let legacy = db.create_purchase_copy("album", 55, &legacy_dir).unwrap(); for id in [1, 2] { - let path = root.path().join(format!("legacy-{id}.dsf")); + let path = legacy_dir.join(format!("legacy-{id}.dsf")); std::fs::write(&path, b"dsd").unwrap(); db.upsert_purchase_copy_track(&track(&legacy.copy_id, id, &path)) .unwrap(); @@ -911,7 +1100,7 @@ mod tests { .unwrap() .is_empty()); - std::fs::remove_file(root.path().join("complete-2.dsf")).unwrap(); + std::fs::remove_file(complete_dir.join("complete-2.dsf")).unwrap(); assert!(db .complete_healthy_purchase_track_candidates("album", 55, 1) .unwrap() @@ -944,4 +1133,127 @@ mod tests { copies[1].resolved_album_folder ); } + + #[test] + fn concurrent_legacy_migrations_import_one_physical_copy() { + let root = tempfile::tempdir().unwrap(); + let db_path = root.path().join("library.db"); + { + let db = LibraryDatabase::open(&db_path).unwrap(); + db.mark_purchase_downloaded(1, Some("album"), "/a/01.flac", 7) + .unwrap(); + db.with_connection(|conn| { + conn.execute("DELETE FROM purchase_download_copy_tracks", []) + .unwrap(); + conn.execute("DELETE FROM purchase_download_copies", []) + .unwrap(); + conn.execute( + "DELETE FROM library_kv WHERE key = ?1", + [LEGACY_MIGRATION_KEY], + ) + .unwrap(); + }); + } + + let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); + let mut workers = Vec::new(); + for _ in 0..2 { + let path = db_path.clone(); + let barrier = barrier.clone(); + workers.push(std::thread::spawn(move || { + let conn = Connection::open(path).unwrap(); + conn.busy_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + barrier.wait(); + migrate_legacy_registry(&conn).unwrap(); + })); + } + barrier.wait(); + for worker in workers { + worker.join().unwrap(); + } + + let db = LibraryDatabase::open(&db_path).unwrap(); + assert_eq!(db.get_purchase_copies_for_album("album").unwrap().len(), 1); + } + + #[test] + fn reopening_repairs_duplicate_physical_identities_without_losing_coverage() { + let root = tempfile::tempdir().unwrap(); + let db_path = root.path().join("library.db"); + let folder = root.path().join("same-copy"); + std::fs::create_dir_all(&folder).unwrap(); + let first_path = folder.join("01.flac"); + let second_path = folder.join("02.flac"); + std::fs::write(&first_path, b"one").unwrap(); + std::fs::write(&second_path, b"two").unwrap(); + { + let db = LibraryDatabase::open(&db_path).unwrap(); + db.with_connection(|conn| { + conn.execute("DROP INDEX idx_purchase_copies_identity", []) + .unwrap(); + for (copy_id, updated_at) in [("old", 10_i64), ("new", 20_i64)] { + conn.execute( + "INSERT INTO purchase_download_copies + (copy_id, album_id, format_id, resolved_album_folder, created_at, updated_at) + VALUES (?1, 'album', 7, ?2, 1, ?3)", + params![copy_id, folder.to_string_lossy(), updated_at], + ) + .unwrap(); + } + for (copy_id, track_id, path) in [ + ("old", 1_i64, &first_path), + ("new", 2_i64, &second_path), + ] { + conn.execute( + "INSERT INTO purchase_download_copy_tracks + (copy_id, track_id, file_path, file_size) + VALUES (?1, ?2, ?3, 3)", + params![copy_id, track_id, path.to_string_lossy()], + ) + .unwrap(); + conn.execute( + "INSERT INTO purchase_download_copy_expected_tracks(copy_id, track_id) + VALUES (?1, ?2)", + params![copy_id, track_id], + ) + .unwrap(); + } + }); + } + + let db = LibraryDatabase::open(&db_path).unwrap(); + let copies = db.get_purchase_copies_for_album("album").unwrap(); + assert_eq!(copies.len(), 1); + assert_eq!(copies[0].copy_id, "new", "the newest row is the keeper"); + assert_eq!(db.get_purchase_copy_tracks("new").unwrap().len(), 2); + assert_eq!( + db.get_purchase_copy_expected_track_ids("new").unwrap(), + vec![1, 2] + ); + assert!(db.create_purchase_copy("album", 7, &folder).is_err()); + } + + #[test] + fn forgetting_a_copy_removes_only_its_registry_and_leaves_files() { + let root = tempfile::tempdir().unwrap(); + let db = LibraryDatabase::open(&root.path().join("library.db")).unwrap(); + let folder = root.path().join("copy"); + std::fs::create_dir_all(&folder).unwrap(); + let path = folder.join("01.flac"); + std::fs::write(&path, b"one").unwrap(); + let copy = db + .create_purchase_copy_with_expected_tracks("album", 7, &folder, &[1]) + .unwrap(); + db.upsert_purchase_copy_track(&track(©.copy_id, 1, &path)) + .unwrap(); + assert!(db + .promote_complete_purchase_copy(©.copy_id, &[1]) + .unwrap()); + + assert!(db.delete_purchase_copy_record(©.copy_id).unwrap()); + assert!(path.exists(), "forgetting never deletes the user's file"); + assert!(db.get_purchase_copy(©.copy_id).unwrap().is_none()); + assert!(db.get_downloaded_purchase_formats().unwrap().is_empty()); + } } diff --git a/crates/qbz-offline-cache/src/purchases_service.rs b/crates/qbz-offline-cache/src/purchases_service.rs index c6ae66df4..3455497ff 100644 --- a/crates/qbz-offline-cache/src/purchases_service.rs +++ b/crates/qbz-offline-cache/src/purchases_service.rs @@ -437,6 +437,13 @@ pub fn purchase_formats(album: &Album, entitlement_ids: &[u32]) -> Vec = menu.iter().map(|f| (f.id, f.streaming)).collect(); + assert_eq!(ids, vec![(56, false)]); + } + #[test] fn an_album_without_entitlement_ids_falls_back_to_the_catalog_synthesis() { let album = album_with(false, Some(44.1)); diff --git a/crates/qbz-qt/qml/views/PurchaseAlbumView.qml b/crates/qbz-qt/qml/views/PurchaseAlbumView.qml index 9066a6cf2..b1da9de47 100644 --- a/crates/qbz-qt/qml/views/PurchaseAlbumView.qml +++ b/crates/qbz-qt/qml/views/PurchaseAlbumView.qml @@ -93,6 +93,7 @@ Rectangle { readonly property var batchResult: doc.batchResult || ({}) readonly property bool downloadable: doc.downloadable !== false property int shownBatchSerial: 0 + property var copyMenuTarget: ({}) function copyStateLabel(state) { if (state === "complete") return root.t("Complete") @@ -109,6 +110,14 @@ Rectangle { return root.formats[i].label || ("#" + String(formatId)) return "#" + String(formatId) } + function copyTitle(copy) { + var folder = copy.folderLabel || "" + var format = root.copyFormatLabel(copy.formatId) + if (folder === "") return format + if (format === "" || folder.toLowerCase().indexOf(format.toLowerCase()) >= 0) + return folder + return folder + " · " + format + } function batchResultBody() { var result = root.batchResult var text = root.t("{} of {} tracks available in this folder.") @@ -128,8 +137,11 @@ Rectangle { var serial = Number(result.serial || 0) if (serial > 0 && serial !== root.shownBatchSerial) { root.shownBatchSerial = serial + copiesModal.close() batchModal.open() } + if (copiesModal.opened && root.copies.length === 0) + copiesModal.close() } } @@ -196,6 +208,12 @@ Rectangle { } return false } + readonly property bool anyCompleteHealthyCopy: { + for (var i = 0; i < root.copies.length; i++) + if (root.copies[i].state === "complete") return true + return false + } + readonly property bool anyPlayable: root.anyStreamable || root.anyCompleteHealthyCopy // ---- The one animation clock ---------------------------------------- // Advanced on the shared shell pulse and ONLY while something is actually @@ -509,11 +527,11 @@ Rectangle { Item { width: 1; height: 20 } // --- Action row -------------------------------------- - // Rendered ONLY for a downloadable album (recon §B.2.2-6): - // on an album the account cannot download, none of these - // has anything to act on. + // Remote download actions follow the current entitlement, + // but an existing local copy remains playable/manageable + // if that catalog edition later becomes unavailable. Row { - visible: root.downloadable + visible: root.downloadable || root.copies.length > 0 width: parent.width spacing: 12 @@ -523,14 +541,15 @@ Rectangle { // 44px primary Play of the catalog album page read as // the main event here (smoke 2026-09-02). QbzCircleAction { - visible: root.anyStreamable + visible: root.anyPlayable primary: false diameterOverride: 38 name: "play-fill" anchors.verticalCenter: parent.verticalCenter - onClicked: QbzPlayer.playAlbum(root.doc.id || "") + onClicked: QbzPurchases.playAlbum() } IconTextButton { + visible: root.downloadable label: root.t("Download another copy") iconName: "cloud-download" btnEnabled: !(root.progress.active === true) @@ -541,6 +560,13 @@ Rectangle { QbzPurchases.downloadAlbum() } } + IconTextButton { + visible: root.copies.length > 0 + label: root.t("Local purchase copies") + " (" + root.copies.length + ")" + iconName: "hard-drive" + anchors.verticalCenter: parent.verticalCenter + onClicked: copiesModal.open() + } // THE FORMAT DROPDOWN. Options in document order — // that order IS the dropdown order and index 0 is the // default. Choosing one re-scopes every downloaded @@ -548,7 +574,7 @@ Rectangle { // of that is computed here, it arrives in the next // republish. QbzSelect { - visible: root.formats.length > 0 + visible: root.downloadable && root.formats.length > 0 anchors.verticalCenter: parent.verticalCenter menuWidth: 200 options: root.formatLabels @@ -560,7 +586,7 @@ Rectangle { } } Text { - visible: root.formats.length === 0 + visible: root.downloadable && root.formats.length === 0 anchors.verticalCenter: parent.verticalCenter text: root.t("No downloadable formats available") color: theme.textMuted @@ -586,7 +612,9 @@ Rectangle { // account will never populate this, so a wrong empty // state would be a permanent wrong nobody can see. IconTextButton { - visible: root.goodies.length > 0 && !goodieCount.visible + visible: root.downloadable + && root.goodies.length > 0 + && !goodieCount.visible anchors.verticalCenter: parent.verticalCenter label: root.t("Download goodies") iconName: "cloud-download" @@ -791,93 +819,6 @@ Rectangle { } } - // Persistent copies stay separate by copy id/folder. Coverage on - // this surface never sums files from two destinations. - Item { visible: copiesBox.visible; width: 1; height: 16 } - Rectangle { - id: copiesBox - visible: root.ready && root.copies.length > 0 - width: parent.width - 64 - height: visible ? copiesColumn.implicitHeight + 28 : 0 - radius: theme.radiusSm - color: theme.surfaceCard - border.width: 1 - border.color: theme.borderSubtle - - Column { - id: copiesColumn - x: 14 - y: 14 - width: parent.width - 28 - spacing: 10 - Text { - text: root.t("Local purchase copies") - color: theme.textPrimary - font.pixelSize: theme.fontBody - font.weight: theme.weightSemibold - } - Repeater { - model: root.copies - delegate: Item { - required property var modelData - width: copiesColumn.width - height: 38 - Column { - anchors.left: parent.left - anchors.right: actionRow.left - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - spacing: 2 - Text { - width: parent.width - text: (modelData.folderLabel || "") + " · " - + root.copyFormatLabel(modelData.formatId) - color: theme.textPrimary - font.pixelSize: theme.fontLegal - elide: Text.ElideMiddle - } - Text { - text: root.copyStateLabel(modelData.state) + " · " - + root.t("{} of {} tracks") - .replace("{}", modelData.healthyTracks || 0) - .replace("{}", modelData.totalTracks || 0) - color: theme.textMuted - font.pixelSize: theme.fontLegal - } - } - Row { - id: actionRow - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 8 - IconTextButton { - visible: modelData.continuable === true - && Number(modelData.formatId) === Number(root.doc.selectedFormatId) - label: root.t("Continue copy") - iconName: "cloud-download" - btnEnabled: root.progress.active !== true - onClicked: QbzPurchases.continueCopy(modelData.copyId || "") - } - IconTextButton { - visible: modelData.inLocalLibrary !== true - && Number(modelData.downloadedTracks || 0) > 0 - label: root.t("Add to Local Library") - iconName: "hard-drive" - onClicked: QbzPurchases.addCopyToLibrary(modelData.copyId || "") - } - Text { - visible: modelData.inLocalLibrary === true - anchors.verticalCenter: parent.verticalCenter - text: root.t("In Local Library") - color: theme.success - font.pixelSize: theme.fontLegal - } - } - } - } - } - } - // --- Divider --------------------------------------------------- Item { visible: root.ready; width: 1; height: 24 } Rectangle { @@ -1173,6 +1114,304 @@ Rectangle { target: pageFlick } + Popup { + id: copiesModal + parent: Overlay.overlay + x: 0 + y: 0 + width: parent ? parent.width : 0 + height: parent ? parent.height : 0 + padding: 0 + z: 3100 + modal: true + dim: false + closePolicy: Popup.CloseOnEscape + + background: Rectangle { color: "#bf000000" } + + contentItem: Item { + MouseArea { + anchors.fill: parent + onClicked: copiesModal.close() + onWheel: function (wheel) { wheel.accepted = true } + } + + Rectangle { + id: copiesCard + width: Math.min(copiesModal.width - 48, 840) + height: Math.min(copiesModal.height - 80, + Math.max(180, 96 + root.copies.length * 84)) + anchors.centerIn: parent + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + clip: true + + MouseArea { + anchors.fill: parent + onWheel: function (wheel) { wheel.accepted = true } + } + + Text { + id: copiesTitle + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.right: copiesClose.left + anchors.rightMargin: 16 + anchors.top: parent.top + anchors.topMargin: 20 + text: root.t("Local purchase copies") + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightBold + elide: Text.ElideRight + } + + Rectangle { + id: copiesClose + width: 32 + height: 32 + radius: theme.radiusSm + anchors.right: parent.right + anchors.rightMargin: 16 + anchors.top: parent.top + anchors.topMargin: 14 + color: copiesCloseArea.containsMouse ? theme.surfaceHover : "transparent" + Accessible.role: Accessible.Button + Accessible.name: root.t("Close") + Accessible.onPressAction: copiesModal.close() + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 16 + height: 16 + tintName: "muted" + } + MouseArea { + id: copiesCloseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: copiesModal.close() + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: 64 + height: 1 + color: theme.borderSubtle + } + + ListView { + id: copiesList + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.topMargin: 76 + anchors.bottomMargin: 16 + spacing: 6 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.copies + + delegate: Rectangle { + id: copyRow + required property var modelData + width: copiesList.width - 8 + height: 78 + radius: theme.radiusSm + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + + Column { + anchors.left: parent.left + anchors.right: copyActions.left + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { + width: parent.width + text: root.copyTitle(copyRow.modelData) + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + elide: Text.ElideMiddle + } + Text { + width: parent.width + text: copyRow.modelData.folderPath + || copyRow.modelData.folderLabel || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + elide: Text.ElideMiddle + } + Text { + width: parent.width + text: root.copyStateLabel(copyRow.modelData.state) + " · " + + root.t("{} of {} tracks") + .replace("{}", copyRow.modelData.healthyTracks || 0) + .replace("{}", copyRow.modelData.totalTracks || 0) + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + } + + Row { + id: copyActions + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + Text { + visible: copyRow.modelData.inLocalLibrary === true + anchors.verticalCenter: parent.verticalCenter + text: root.t("In Local Library") + color: theme.success + font.pixelSize: theme.fontLegal + } + IconTextButton { + visible: copyRow.modelData.continuable === true + && Number(copyRow.modelData.formatId) === Number(root.doc.selectedFormatId) + label: root.t("Continue copy") + iconName: "cloud-download" + btnEnabled: root.progress.active !== true + onClicked: { + copiesModal.close() + QbzPurchases.continueCopy(copyRow.modelData.copyId || "") + } + } + IconTextButton { + visible: copyRow.modelData.inLocalLibrary !== true + && Number(copyRow.modelData.libraryFolderId || 0) <= 0 + && Number(copyRow.modelData.downloadedTracks || 0) > 0 + label: root.t("Add to Local Library") + iconName: "hard-drive" + onClicked: { + copiesModal.close() + QbzPurchases.addCopyToLibrary(copyRow.modelData.copyId || "") + } + } + Rectangle { + width: 32 + height: 32 + radius: theme.radiusSm + color: copyMoreArea.containsMouse ? theme.surfaceHover : "transparent" + Accessible.role: Accessible.Button + Accessible.name: root.t("More options") + Accessible.onPressAction: { + root.copyMenuTarget = copyRow.modelData + copyMenu.openBelowRight(copyMoreArea) + } + QbzIcon { + anchors.centerIn: parent + name: "ellipsis" + width: 16 + height: 16 + tintName: "muted" + } + MouseArea { + id: copyMoreArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.copyMenuTarget = copyRow.modelData + copyMenu.openBelowRight(copyMoreArea) + } + } + } + } + } + + } + + QbzScrollBar { + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.top: copiesList.top + anchors.bottom: copiesList.bottom + target: copiesList + } + } + } + } + + QbzContextMenu { + id: copyMenu + z: 3300 + menuWidth: 230 + + Rectangle { + width: parent.width + height: 33 + radius: theme.radiusSm + color: showLocationArea.containsMouse ? theme.surfaceHover : "transparent" + QbzIcon { x: 9; anchors.verticalCenter: parent.verticalCenter; name: "folder-open"; width: 15; height: 15; tintName: "muted" } + Text { x: 32; anchors.verticalCenter: parent.verticalCenter; text: root.t("Show location"); color: theme.textPrimary; font.pixelSize: theme.fontLegal } + MouseArea { + id: showLocationArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + copyMenu.close() + QbzPurchases.showCopyLocation(root.copyMenuTarget.copyId || "") + } + } + } + + Rectangle { + visible: Number(root.copyMenuTarget.libraryFolderId || 0) > 0 + width: parent.width + height: visible ? 33 : 0 + radius: theme.radiusSm + color: removeLibraryArea.containsMouse ? theme.dangerHover : "transparent" + QbzIcon { x: 9; anchors.verticalCenter: parent.verticalCenter; name: "hard-drive"; width: 15; height: 15; tintName: "danger" } + Text { x: 32; anchors.verticalCenter: parent.verticalCenter; text: root.t("Remove from Local Library"); color: theme.danger; font.pixelSize: theme.fontLegal } + MouseArea { + id: removeLibraryArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + copyMenu.close() + QbzPurchases.removeCopyFromLibrary(root.copyMenuTarget.copyId || "") + } + } + } + + Rectangle { + visible: root.copyMenuTarget.inLocalLibrary !== true + && Number(root.copyMenuTarget.libraryFolderId || 0) <= 0 + width: parent.width + height: visible ? 33 : 0 + radius: theme.radiusSm + color: forgetRecordArea.containsMouse ? theme.dangerHover : "transparent" + QbzIcon { x: 9; anchors.verticalCenter: parent.verticalCenter; name: "trash-2"; width: 15; height: 15; tintName: "danger" } + Text { x: 32; anchors.verticalCenter: parent.verticalCenter; text: root.t("Forget download record"); color: theme.danger; font.pixelSize: theme.fontLegal } + MouseArea { + id: forgetRecordArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + copyMenu.close() + QbzPurchases.forgetCopyRecord(root.copyMenuTarget.copyId || "") + } + } + } + } + QbzConfirmModal { id: batchModal anchors.fill: parent diff --git a/crates/qbz-qt/src/purchases_bridge.rs b/crates/qbz-qt/src/purchases_bridge.rs index e4f0c3a30..bdfacf668 100644 --- a/crates/qbz-qt/src/purchases_bridge.rs +++ b/crates/qbz-qt/src/purchases_bridge.rs @@ -1,6 +1,6 @@ //! QbzPurchases — the Purchases domain bridge (contract §G.1). //! -//! Two documents and a publish counter, twenty invokables. The controller is +//! Two documents and a publish counter. The controller is //! `purchases_qt.rs`; this file is glue and nothing else, exactly like //! `musician_bridge.rs`. //! @@ -164,6 +164,11 @@ pub mod qbz_purchases_bridge { track_ids_json: QString, ); + /// Purchase detail Play: prefer the highest complete healthy local + /// copy, then enter the ordinary album playback pipeline. + #[qinvokable] + fn play_album(self: Pin<&mut QbzPurchases>); + /// Read-only AlbumCard entitlement lookup. `revision` is a QML binding /// dependency; the controller reads the process-wide index. #[qinvokable] @@ -229,6 +234,19 @@ pub mod qbz_purchases_bridge { #[qinvokable] fn add_copy_to_library(self: Pin<&mut QbzPurchases>, copy_id: QString); + /// Open the registered copy folder in the platform file browser. + #[qinvokable] + fn show_copy_location(self: Pin<&mut QbzPurchases>, copy_id: QString); + + /// Remove this exact copy folder from Local Library, preserving files + /// and the purchase-copy inventory. + #[qinvokable] + fn remove_copy_from_library(self: Pin<&mut QbzPurchases>, copy_id: QString); + + /// Forget an unindexed download record without deleting user files. + #[qinvokable] + fn forget_copy_record(self: Pin<&mut QbzPurchases>, copy_id: QString); + /// Acknowledge the one-shot post-batch modal without mutating the copy. #[qinvokable] fn dismiss_batch_result(self: Pin<&mut QbzPurchases>, copy_id: QString); @@ -361,6 +379,10 @@ impl qbz_purchases_bridge::QbzPurchases { ); } + pub fn play_album(self: Pin<&mut Self>) { + crate::purchases_qt::play_album(); + } + pub fn is_album_purchased(&self, album_id: QString, revision: i32) -> bool { crate::purchases_qt::is_album_purchased(&album_id.to_string(), revision) } @@ -411,6 +433,18 @@ impl qbz_purchases_bridge::QbzPurchases { crate::purchases_qt::add_copy_to_library(copy_id.to_string()); } + pub fn show_copy_location(self: Pin<&mut Self>, copy_id: QString) { + crate::purchases_qt::show_copy_location(copy_id.to_string()); + } + + pub fn remove_copy_from_library(self: Pin<&mut Self>, copy_id: QString) { + crate::purchases_qt::remove_copy_from_library(copy_id.to_string()); + } + + pub fn forget_copy_record(self: Pin<&mut Self>, copy_id: QString) { + crate::purchases_qt::forget_copy_record(copy_id.to_string()); + } + pub fn dismiss_batch_result(self: Pin<&mut Self>, copy_id: QString) { crate::purchases_qt::dismiss_batch_result(copy_id.to_string()); } diff --git a/crates/qbz-qt/src/purchases_qt.rs b/crates/qbz-qt/src/purchases_qt.rs index e6bdc3cc7..353468d75 100644 --- a/crates/qbz-qt/src/purchases_qt.rs +++ b/crates/qbz-qt/src/purchases_qt.rs @@ -1604,6 +1604,10 @@ struct AlbumPurchaseCopyDoc { format_id: i64, #[serde(rename = "folderLabel")] folder_label: String, + /// Full registered location. The inventory modal needs this to + /// distinguish copies whose final directory names happen to match. + #[serde(rename = "folderPath")] + folder_path: String, state: String, #[serde(rename = "downloadedTracks")] downloaded_tracks: u32, @@ -1613,6 +1617,11 @@ struct AlbumPurchaseCopyDoc { total_tracks: u32, #[serde(rename = "inLocalLibrary")] in_local_library: bool, + /// Exact Local Library root id for this copy. A broader registered root + /// may cover the copy (`inLocalLibrary=true`) but must never be removed + /// from a per-copy menu. + #[serde(rename = "libraryFolderId")] + library_folder_id: i64, continuable: bool, } @@ -1693,13 +1702,32 @@ fn copy_health_word(health: PurchaseCopyHealth) -> &'static str { } } -fn folder_is_in_local_library(folder: &str, roots: &[qbz_library::LibraryFolder]) -> bool { +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct CopyLibraryCoverage { + in_local_library: bool, + exact_folder_id: i64, +} + +fn copy_library_coverage( + folder: &str, + roots: &[qbz_library::LibraryFolder], +) -> CopyLibraryCoverage { let candidate = Path::new(folder) .canonicalize() .unwrap_or_else(|_| PathBuf::from(folder)); - roots - .iter() - .any(|root| root.enabled && candidate.starts_with(Path::new(&root.path))) + let mut coverage = CopyLibraryCoverage::default(); + for root in roots { + let root_path = Path::new(&root.path) + .canonicalize() + .unwrap_or_else(|_| PathBuf::from(&root.path)); + if candidate == root_path { + coverage.exact_folder_id = root.id; + } + if root.enabled && candidate.starts_with(&root_path) { + coverage.in_local_library = true; + } + } + coverage } fn local_album_purchase_state( @@ -1734,15 +1762,18 @@ fn purchase_copy_doc( .and_then(|value| value.to_str()) .unwrap_or_default() .to_string(); + let coverage = copy_library_coverage(&snapshot.copy.resolved_album_folder, folders); AlbumPurchaseCopyDoc { copy_id: snapshot.copy.copy_id.clone(), format_id: snapshot.copy.format_id, folder_label, + folder_path: snapshot.copy.resolved_album_folder.clone(), state: copy_health_word(snapshot.health).to_string(), downloaded_tracks: snapshot.downloaded_tracks, healthy_tracks: snapshot.healthy_tracks, total_tracks: snapshot.total_tracks, - in_local_library: folder_is_in_local_library(&snapshot.copy.resolved_album_folder, folders), + in_local_library: coverage.in_local_library, + library_folder_id: coverage.exact_folder_id, continuable: matches!( snapshot.health, PurchaseCopyHealth::Partial @@ -2506,6 +2537,74 @@ pub fn set_album_playback_mode( }); } +fn best_complete_purchase_format(copies: &[qbz_library::PurchaseCopySnapshot]) -> Option { + copies + .iter() + .filter(|copy| copy.health == PurchaseCopyHealth::CompleteHealthy) + .max_by_key(|copy| svc::format_rank(copy.copy.format_id.max(0) as u32)) + .map(|copy| copy.copy.format_id) +} + +/// Purchase detail's Play promise is local-first: once a complete healthy copy +/// exists, select its highest format before building the ordinary Qobuz queue. +/// AlbumView remains the surface where the user may explicitly switch the +/// shared preference back to Qobuz. +pub fn play_album() { + let request = with_detail(|state| { + let album = state.album.as_ref()?; + let expected = album + .tracks + .as_ref()? + .items + .iter() + .map(|track| track.id as i64) + .collect::>(); + (!album.id.is_empty() && !expected.is_empty()).then(|| (album.id.clone(), expected)) + }); + let Some((album_id, expected)) = request else { + return; + }; + + crate::spawn(async move { + let lookup_album_id = album_id.clone(); + let selected = tokio::task::spawn_blocking(move || { + crate::library_db_qt::with_db(false, |db| { + let copies = db.inspect_purchase_copies(&lookup_album_id, &expected)?; + let Some(format_id) = best_complete_purchase_format(&copies) else { + return Ok(None); + }; + for copy in copies.iter().filter(|copy| { + copy.copy.format_id == format_id + && copy.health == PurchaseCopyHealth::CompleteHealthy + }) { + db.set_purchase_copy_expected_tracks(©.copy.copy_id, &expected)?; + } + db.set_purchase_playback_preference( + &lookup_album_id, + PurchasePlaybackMode::Purchase, + Some(format_id), + )?; + Ok(Some(format_id)) + }) + .flatten() + }) + .await + .ok() + .flatten(); + + if let Some(format_id) = selected { + log::info!( + "[qbz-qt] purchases: album {album_id} Play selected local format {format_id}" + ); + crate::album_qt::patch_purchase_playback_preference(&album_id, "purchase", format_id); + } + let runtime = crate::app(); + if let Err(error) = crate::playback_qt::play_album(&runtime, &album_id).await { + log::error!("[qbz-qt] purchases: play album {album_id} failed: {error}"); + } + }); +} + /// Opening the detail is a request GRAPH in the reference: `/album/get`, then /// ALL album and track purchase pages (to recover `downloadable` and /// `purchased_at`), and CONCURRENTLY a SECOND `/album/get` for the formats. @@ -2516,14 +2615,11 @@ pub fn set_album_playback_mode( /// status and parses the STRICT structs, so a bad payload fails the whole /// screen — and it now fails it once instead of twice. /// -/// **The format menu is the ENTITLEMENT (2026-09-01 contract §1.4), not a -/// synthesis from the catalog.** The purchases listing fetched here carries -/// `downloadable_format_ids` per album — `[55]` for a DSD64 purchase whose -/// catalog streams at 16/44.1, `[56]` for DSD128, `[7, 6, 5]` for a hi-res -/// FLAC — and those are the only ids `getFileUrl(intent=download)` will -/// honour. The old `synth_formats` ladder (27/7/6/5 from `album.hires`) stays -/// as the fallback for a listing that does not carry the field, so an older -/// wire still gets a menu instead of nothing. +/// **The format menu starts with the ENTITLEMENT and may append the catalog's +/// streaming ladder strictly below it.** Entitled ids use `intent=download`; +/// lower catalog formats use `intent=stream`, and appear only while this exact +/// edition is streamable (`hires_streamable` gates the hi-res rungs). A +/// purchase-only edition therefore exposes only its purchased master. async fn load_album(generation: u64, album_id: String) { let Some(client) = snapshot_client().await else { fail_album(generation); @@ -2582,7 +2678,10 @@ async fn load_album(generation: u64, album_id: String) { let album = svc::build_purchase_album(&catalog, &purchases, ®.downloaded_ids, ®.format_map); - let formats = svc::entitlement_formats(&svc::entitlement_format_ids(&purchases, &album_id)); + let formats = svc::purchase_formats( + &catalog, + &svc::entitlement_format_ids(&purchases, &album_id), + ); let cover_url = catalog.image.best().cloned().unwrap_or_default(); let applied = with_detail(|s| { @@ -2698,7 +2797,14 @@ async fn revalidate_download(plan: &DownloadPlan) -> bool { let valid = refresh_entitlement_index().await.is_some_and(|index| { index .downloadable_album(&plan.album_id) - .is_some_and(|album| album.downloadable_format_ids.contains(&plan.format_id)) + .is_some_and(|album| { + download_format_is_permitted( + &plan.catalog, + &album.downloadable_format_ids, + plan.format_id, + plan.streaming, + ) + }) }); if !valid { crate::toast_qt::error(qbz_i18n::t("This purchase is not available for download.")); @@ -2706,6 +2812,17 @@ async fn revalidate_download(plan: &DownloadPlan) -> bool { valid } +fn download_format_is_permitted( + catalog: &Album, + entitlement_ids: &[u32], + format_id: u32, + streaming: bool, +) -> bool { + svc::purchase_formats(catalog, entitlement_ids) + .into_iter() + .any(|format| format.id == format_id && format.streaming == streaming) +} + fn suffixed_copy_folder(base: &Path, number: u32) -> PathBuf { let parent = base.parent().unwrap_or_else(|| Path::new("")); let stem = base @@ -3559,6 +3676,141 @@ pub fn dismiss_batch_result(copy_id: String) { publish_album(); } +fn detail_inventory_request() -> Option<(String, Vec)> { + with_detail(|state| { + state.album.as_ref().map(|album| { + ( + album.id.clone(), + album + .tracks + .as_ref() + .map(|page| page.items.iter().map(|track| track.id as i64).collect()) + .unwrap_or_default(), + ) + }) + }) +} + +/// Open only the path stored for a copy owned by the currently visible album. +/// QML never supplies an arbitrary filesystem path. +pub fn show_copy_location(copy_id: String) { + let copy_id = copy_id.trim().to_string(); + let album_id = with_detail(|state| state.album_id.clone()); + if copy_id.is_empty() || album_id.is_empty() { + return; + } + crate::spawn(async move { + let opened = tokio::task::spawn_blocking(move || { + let path = crate::library_db_qt::with_db(false, |db| { + let copy = db + .get_purchase_copy(©_id)? + .filter(|copy| copy.album_id == album_id); + Ok(copy.map(|copy| copy.resolved_album_folder)) + }) + .flatten(); + path.is_some_and(|path| open::that(path).is_ok()) + }) + .await + .unwrap_or(false); + if !opened { + crate::toast_qt::error(qbz_i18n::t("Could not open that folder.")); + } + }); +} + +/// Remove only an exact Local Library root for this copy. If a broader root +/// covers the folder, the per-copy menu must not delete that unrelated root. +pub fn remove_copy_from_library(copy_id: String) { + let copy_id = copy_id.trim().to_string(); + let Some((album_id, expected)) = detail_inventory_request() else { + return; + }; + if copy_id.is_empty() { + return; + } + crate::spawn(async move { + let lookup_album_id = album_id.clone(); + let folder_id = tokio::task::spawn_blocking(move || { + crate::library_db_qt::with_db(false, |db| { + let copy = db + .get_purchase_copy(©_id)? + .filter(|copy| copy.album_id == lookup_album_id); + let Some(copy) = copy else { + return Ok(None); + }; + let folders = db.get_folders_with_metadata()?; + let coverage = copy_library_coverage(©.resolved_album_folder, &folders); + Ok((coverage.exact_folder_id > 0).then_some(coverage.exact_folder_id)) + }) + .flatten() + }) + .await + .ok() + .flatten(); + let Some(folder_id) = folder_id else { + crate::toast_qt::error(qbz_i18n::t("Could not remove that library folder.")); + return; + }; + crate::settings_qt::library::remove_folders(vec![folder_id]).await; + refresh_local_album_link().await; + refresh_detail_copy_inventory(album_id, expected).await; + }); +} + +/// Drop an unindexed copy from QBZ's inventory without touching its folder or +/// files. The operation re-checks Local Library at click time so a stale modal +/// cannot forget a copy that was just registered elsewhere. +pub fn forget_copy_record(copy_id: String) { + let copy_id = copy_id.trim().to_string(); + let Some((album_id, _)) = detail_inventory_request() else { + return; + }; + if copy_id.is_empty() { + return; + } + crate::spawn(async move { + let lookup_album_id = album_id.clone(); + let preference = tokio::task::spawn_blocking(move || { + crate::library_db_qt::with_db(false, |db| { + let copy = db + .get_purchase_copy(©_id)? + .filter(|copy| copy.album_id == lookup_album_id); + let Some(copy) = copy else { + return Ok(None); + }; + let folders = db.get_folders_with_metadata()?; + let coverage = copy_library_coverage(©.resolved_album_folder, &folders); + if coverage.in_local_library || coverage.exact_folder_id > 0 { + return Ok(None); + } + if !db.delete_purchase_copy_record(©.copy_id)? { + return Ok(None); + } + Ok(Some(db.purchase_playback_preference(&lookup_album_id)?)) + }) + .flatten() + }) + .await + .ok() + .flatten(); + let Some(preference) = preference else { + crate::toast_qt::error(qbz_i18n::t("Could not forget that download.")); + return; + }; + crate::album_qt::patch_purchase_playback_preference( + &album_id, + match preference.mode { + PurchasePlaybackMode::Qobuz => "qobuz", + PurchasePlaybackMode::Purchase => "purchase", + }, + preference.format_id.unwrap_or(0), + ); + refresh_after_download().await; + let runtime = crate::app(); + crate::playback_qt::rematerialize_current_album_source(&runtime, &album_id).await; + }); +} + /// Delegate one exact copy folder to Local Library's typed authority. Exact /// duplicates refresh, ancestors cover, disabled roots stay disabled, and the /// queued scan machinery prevents a concurrent scan from dropping this one. @@ -4341,6 +4593,91 @@ mod tests { assert_eq!(highest_entitled_quality(&[55, 56]), "DSD128 · DSF"); assert_eq!(highest_entitled_quality(&[]), ""); } + + #[test] + fn download_revalidation_preserves_the_grant_route_and_stream_availability() { + let catalog: Album = serde_json::from_str( + r#"{"id":"dsd","streamable":true,"hires":false, + "hires_streamable":false,"maximum_sampling_rate":44.1}"#, + ) + .unwrap(); + assert!(download_format_is_permitted(&catalog, &[55], 55, false)); + assert!(download_format_is_permitted(&catalog, &[55], 6, true)); + assert!(!download_format_is_permitted(&catalog, &[55], 6, false)); + assert!(!download_format_is_permitted(&catalog, &[55], 7, true)); + + let purchase_only: Album = serde_json::from_str( + r#"{"id":"store-only","streamable":false,"hires":true, + "hires_streamable":true,"maximum_sampling_rate":192.0}"#, + ) + .unwrap(); + assert!(download_format_is_permitted( + &purchase_only, + &[56], + 56, + false + )); + assert!(!download_format_is_permitted( + &purchase_only, + &[56], + 27, + true + )); + } + + #[test] + fn purchase_detail_play_prefers_the_highest_complete_healthy_copy() { + fn snapshot( + format_id: i64, + health: PurchaseCopyHealth, + ) -> qbz_library::PurchaseCopySnapshot { + qbz_library::PurchaseCopySnapshot { + copy: qbz_library::PurchaseDownloadCopy { + copy_id: format!("copy-{format_id}"), + album_id: "album".to_string(), + format_id, + resolved_album_folder: format!("/tmp/{format_id}"), + created_at: 0, + updated_at: 0, + }, + health, + downloaded_tracks: 1, + healthy_tracks: 1, + total_tracks: 1, + tracks: Vec::new(), + } + } + let copies = vec![ + snapshot(6, PurchaseCopyHealth::CompleteHealthy), + snapshot(55, PurchaseCopyHealth::CompleteHealthy), + snapshot(56, PurchaseCopyHealth::Partial), + ]; + assert_eq!(best_complete_purchase_format(&copies), Some(55)); + } + + #[test] + fn copy_library_coverage_only_exposes_an_exact_root_for_removal() { + let parent = PathBuf::from("/tmp/qbz-copy-coverage-test/Music"); + let copy = parent.join("Artist/Album"); + let folder = |id, path: &Path| qbz_library::LibraryFolder { + id, + path: path.to_string_lossy().into_owned(), + alias: None, + enabled: true, + is_network: false, + network_fs_type: None, + user_override_network: false, + last_scan: None, + }; + + let covered = copy_library_coverage(©.to_string_lossy(), &[folder(1, &parent)]); + assert!(covered.in_local_library); + assert_eq!(covered.exact_folder_id, 0); + + let exact = copy_library_coverage(©.to_string_lossy(), &[folder(2, ©)]); + assert!(exact.in_local_library); + assert_eq!(exact.exact_folder_id, 2); + } } #[cfg(test)]