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:
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 "Создать радио"
|
||||
|
||||
|
||||
@@ -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_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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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_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<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(©_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)]
|
||||
|
||||
Reference in new issue
Block a user