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
This commit is contained in:
vicrodh committed 2026-09-05 12:10:55 -06:00
1 parent 92c87d190f
commit f01950571d
13 files changed
+1213 -131

No files matched your search

@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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 "ラジオを作成"
@@ -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"
@@ -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"
@@ -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 "Создать радио"
+330 -18
View File
@@ -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<usize, LibraryError> {
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::<Result<Vec<_>, _>>()
.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::<Result<Vec<String>, _>>()
.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<bool, LibraryError> {
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.copy_id, 1, &path))
.unwrap();
assert!(db
.promote_complete_purchase_copy(&copy.copy_id, &[1])
.unwrap());
assert!(db.delete_purchase_copy_record(&copy.copy_id).unwrap());
assert!(path.exists(), "forgetting never deletes the user's file");
assert!(db.get_purchase_copy(&copy.copy_id).unwrap().is_none());
assert!(db.get_downloaded_purchase_formats().unwrap().is_empty());
}
}
@@ -437,6 +437,13 @@ pub fn purchase_formats(album: &Album, entitlement_ids: &[u32]) -> Vec<PurchaseF
);
return synth_formats(album);
}
// A purchase may remain downloadable after its catalog edition stops
// streaming (purchase-only, regional withdrawal, or label takedown). The
// entitlement still grants its exact master, but there is no streaming
// ladder to append in that state.
if !album.is_streamable() {
return menu;
}
let top = menu.iter().map(|f| format_rank(f.id)).max().unwrap_or(0);
for mut extra in synth_formats(album) {
let owned = menu.iter().any(|f| f.id == extra.id);
@@ -1750,7 +1757,7 @@ mod tests {
}
#[test]
fn a_dsd64_purchase_streaming_at_cd_quality_offers_only_its_entitlement() {
fn a_dsd64_purchase_streaming_at_cd_quality_adds_lower_stream_formats() {
// Megadeth, Rust In Peace on the live account: hires:false, 16/44.1
// catalog, downloadable_format_ids [55]. The synthesis would say "CD
// FLAC + MP3"; the entitlement says DSD64 and nothing else.
@@ -1792,6 +1799,15 @@ mod tests {
assert_eq!(ids, vec![(56, false), (6, true), (5, true)]);
}
#[test]
fn a_purchase_only_album_offers_only_its_entitled_master() {
let mut album = album_streaming(true, Some(192.0), true);
album.streamable = Some(false);
let menu = purchase_formats(&album, &[56]);
let ids: Vec<(u32, bool)> = 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));
+335 -96
View File
@@ -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
+35 -1
View File
@@ -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());
}
+352 -15
View File
@@ -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<i64> {
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::<Vec<_>>();
(!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.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, &reg.downloaded_ids, &reg.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<i64>)> {
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(&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(&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(&copy.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(&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(&copy.resolved_album_folder, &folders);
if coverage.in_local_library || coverage.exact_folder_id > 0 {
return Ok(None);
}
if !db.delete_purchase_copy_record(&copy.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(&copy.to_string_lossy(), &[folder(1, &parent)]);
assert!(covered.in_local_library);
assert_eq!(covered.exact_folder_id, 0);
let exact = copy_library_coverage(&copy.to_string_lossy(), &[folder(2, &copy)]);
assert!(exact.in_local_library);
assert_eq!(exact.exact_folder_id, 2);
}
}
#[cfg(test)]