feat(purchases): persist copy coverage manifests
cx
This commit is contained in:
2 files changed
+246
-11
No files matched your search
@@ -121,6 +121,13 @@ pub(crate) fn init_schema(conn: &Connection) -> Result<(), LibraryError> {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_purchase_copy_tracks_track
|
CREATE INDEX IF NOT EXISTS idx_purchase_copy_tracks_track
|
||||||
ON purchase_download_copy_tracks(track_id, copy_id);
|
ON purchase_download_copy_tracks(track_id, copy_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_download_copy_expected_tracks (
|
||||||
|
copy_id TEXT NOT NULL,
|
||||||
|
track_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(copy_id, track_id),
|
||||||
|
FOREIGN KEY(copy_id) REFERENCES purchase_download_copies(copy_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS purchase_album_playback_preferences (
|
CREATE TABLE IF NOT EXISTS purchase_album_playback_preferences (
|
||||||
album_id TEXT PRIMARY KEY,
|
album_id TEXT PRIMARY KEY,
|
||||||
mode TEXT NOT NULL CHECK(mode IN ('qobuz','purchase')),
|
mode TEXT NOT NULL CHECK(mode IN ('qobuz','purchase')),
|
||||||
@@ -238,6 +245,68 @@ impl LibraryDatabase {
|
|||||||
Ok(copy)
|
Ok(copy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a copy and its immutable album coverage manifest in one
|
||||||
|
/// transaction. The manifest is the denominator used by playback after a
|
||||||
|
/// restart; without it, a directory containing one healthy track could be
|
||||||
|
/// mistaken for a complete album.
|
||||||
|
pub fn create_purchase_copy_with_expected_tracks(
|
||||||
|
&self,
|
||||||
|
album_id: &str,
|
||||||
|
format_id: i64,
|
||||||
|
resolved_album_folder: &Path,
|
||||||
|
expected_track_ids: &[i64],
|
||||||
|
) -> Result<PurchaseDownloadCopy, LibraryError> {
|
||||||
|
let expected = normalize_expected_track_ids(expected_track_ids)?;
|
||||||
|
let copy = PurchaseDownloadCopy {
|
||||||
|
copy_id: Uuid::new_v4().to_string(),
|
||||||
|
album_id: album_id.to_string(),
|
||||||
|
format_id,
|
||||||
|
resolved_album_folder: resolved_album_folder.to_string_lossy().into_owned(),
|
||||||
|
created_at: unix_now(),
|
||||||
|
updated_at: unix_now(),
|
||||||
|
};
|
||||||
|
self.with_connection(|conn| {
|
||||||
|
let tx = conn.unchecked_transaction().map_err(db_error)?;
|
||||||
|
insert_copy(&tx, ©)?;
|
||||||
|
insert_expected_tracks(&tx, ©.copy_id, &expected)?;
|
||||||
|
tx.commit().map_err(db_error)
|
||||||
|
})?;
|
||||||
|
Ok(copy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach the exact album coverage used to validate a migrated copy.
|
||||||
|
/// Existing rows are replaced atomically so a crash cannot leave a
|
||||||
|
/// half-written denominator.
|
||||||
|
pub fn set_purchase_copy_expected_tracks(
|
||||||
|
&self,
|
||||||
|
copy_id: &str,
|
||||||
|
expected_track_ids: &[i64],
|
||||||
|
) -> Result<(), LibraryError> {
|
||||||
|
let expected = normalize_expected_track_ids(expected_track_ids)?;
|
||||||
|
self.with_connection(|conn| {
|
||||||
|
if select_copy(conn, copy_id)?.is_none() {
|
||||||
|
return Err(LibraryError::Database(format!(
|
||||||
|
"purchase copy {copy_id} does not exist"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let tx = conn.unchecked_transaction().map_err(db_error)?;
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM purchase_download_copy_expected_tracks WHERE copy_id = ?1",
|
||||||
|
[copy_id],
|
||||||
|
)
|
||||||
|
.map_err(db_error)?;
|
||||||
|
insert_expected_tracks(&tx, copy_id, &expected)?;
|
||||||
|
tx.commit().map_err(db_error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_purchase_copy_expected_track_ids(
|
||||||
|
&self,
|
||||||
|
copy_id: &str,
|
||||||
|
) -> Result<Vec<i64>, LibraryError> {
|
||||||
|
self.with_connection(|conn| select_expected_tracks(conn, copy_id))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn upsert_purchase_copy_track(
|
pub fn upsert_purchase_copy_track(
|
||||||
&self,
|
&self,
|
||||||
track: &PurchaseDownloadCopyTrack,
|
track: &PurchaseDownloadCopyTrack,
|
||||||
@@ -494,6 +563,52 @@ impl LibraryDatabase {
|
|||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exact-format candidates that are complete and healthy against their
|
||||||
|
/// own persisted album manifest. Results retain copy recency order and
|
||||||
|
/// never combine coverage between copy IDs. This performs bounded file
|
||||||
|
/// probes and must run away from the UI thread.
|
||||||
|
pub fn complete_healthy_purchase_track_candidates(
|
||||||
|
&self,
|
||||||
|
album_id: &str,
|
||||||
|
format_id: i64,
|
||||||
|
track_id: i64,
|
||||||
|
) -> Result<Vec<(PurchaseDownloadCopy, PurchaseDownloadCopyTrack)>, LibraryError> {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for copy in self
|
||||||
|
.get_purchase_copies_for_album(album_id)?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|copy| copy.format_id == format_id)
|
||||||
|
{
|
||||||
|
let expected: HashSet<i64> = self
|
||||||
|
.get_purchase_copy_expected_track_ids(©.copy_id)?
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
if expected.is_empty() || !expected.contains(&track_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rows = self.get_purchase_copy_tracks(©.copy_id)?;
|
||||||
|
let mut target = None;
|
||||||
|
let mut healthy = HashSet::new();
|
||||||
|
for row in rows
|
||||||
|
.into_iter()
|
||||||
|
.filter(|row| expected.contains(&row.track_id))
|
||||||
|
{
|
||||||
|
if track_health(&row) == PurchaseTrackHealth::Healthy {
|
||||||
|
healthy.insert(row.track_id);
|
||||||
|
if row.track_id == track_id {
|
||||||
|
target = Some(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if expected.iter().all(|id| healthy.contains(id)) {
|
||||||
|
if let Some(target) = target {
|
||||||
|
candidates.push((copy, target));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(candidates)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_copy(conn: &Connection, copy: &PurchaseDownloadCopy) -> Result<(), LibraryError> {
|
fn insert_copy(conn: &Connection, copy: &PurchaseDownloadCopy) -> Result<(), LibraryError> {
|
||||||
@@ -575,6 +690,53 @@ fn select_copy_tracks(
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_expected_track_ids(expected_track_ids: &[i64]) -> Result<Vec<i64>, LibraryError> {
|
||||||
|
let expected: HashSet<i64> = expected_track_ids
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|track_id| *track_id > 0)
|
||||||
|
.collect();
|
||||||
|
if expected.is_empty() {
|
||||||
|
return Err(LibraryError::Database(
|
||||||
|
"purchase copy requires at least one expected track".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut expected: Vec<i64> = expected.into_iter().collect();
|
||||||
|
expected.sort_unstable();
|
||||||
|
Ok(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_expected_tracks(
|
||||||
|
conn: &Connection,
|
||||||
|
copy_id: &str,
|
||||||
|
expected_track_ids: &[i64],
|
||||||
|
) -> Result<(), LibraryError> {
|
||||||
|
for track_id in expected_track_ids {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO purchase_download_copy_expected_tracks (copy_id, track_id)
|
||||||
|
VALUES (?1, ?2)",
|
||||||
|
params![copy_id, track_id],
|
||||||
|
)
|
||||||
|
.map_err(db_error)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_expected_tracks(conn: &Connection, copy_id: &str) -> Result<Vec<i64>, LibraryError> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT track_id FROM purchase_download_copy_expected_tracks
|
||||||
|
WHERE copy_id = ?1 ORDER BY track_id",
|
||||||
|
)
|
||||||
|
.map_err(db_error)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([copy_id], |row| row.get(0))
|
||||||
|
.map_err(db_error)?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
fn track_health(track: &PurchaseDownloadCopyTrack) -> PurchaseTrackHealth {
|
fn track_health(track: &PurchaseDownloadCopyTrack) -> PurchaseTrackHealth {
|
||||||
match crate::probe_file_default(Path::new(&track.file_path)) {
|
match crate::probe_file_default(Path::new(&track.file_path)) {
|
||||||
FileProbe::Missing => PurchaseTrackHealth::Missing,
|
FileProbe::Missing => PurchaseTrackHealth::Missing,
|
||||||
@@ -708,6 +870,54 @@ mod tests {
|
|||||||
assert_eq!(stored.format_id, Some(55));
|
assert_eq!(stored.format_id, Some(55));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playback_candidates_require_the_persisted_complete_manifest() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let db = LibraryDatabase::open(&root.path().join("library.db")).unwrap();
|
||||||
|
|
||||||
|
let complete = db
|
||||||
|
.create_purchase_copy_with_expected_tracks("album", 55, root.path(), &[1, 2])
|
||||||
|
.unwrap();
|
||||||
|
for id in [1, 2] {
|
||||||
|
let path = root.path().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])
|
||||||
|
.unwrap();
|
||||||
|
let partial_path = root.path().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();
|
||||||
|
for id in [1, 2] {
|
||||||
|
let path = root.path().join(format!("legacy-{id}.dsf"));
|
||||||
|
std::fs::write(&path, b"dsd").unwrap();
|
||||||
|
db.upsert_purchase_copy_track(&track(&legacy.copy_id, id, &path))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = db
|
||||||
|
.complete_healthy_purchase_track_candidates("album", 55, 1)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(candidates.len(), 1);
|
||||||
|
assert_eq!(candidates[0].0.copy_id, complete.copy_id);
|
||||||
|
assert!(db
|
||||||
|
.complete_healthy_purchase_track_candidates("album", 56, 1)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
|
||||||
|
std::fs::remove_file(root.path().join("complete-2.dsf")).unwrap();
|
||||||
|
assert!(db
|
||||||
|
.complete_healthy_purchase_track_candidates("album", 55, 1)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_migration_groups_by_folder() {
|
fn legacy_migration_groups_by_folder() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -2390,16 +2390,24 @@ pub fn set_album_playback_mode(
|
|||||||
if format_id <= 0 || expected.is_empty() {
|
if format_id <= 0 || expected.is_empty() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let valid = db
|
let valid_copy_ids: Vec<String> = db
|
||||||
.inspect_purchase_copies(&album_id, &expected)?
|
.inspect_purchase_copies(&album_id, &expected)?
|
||||||
.iter()
|
.into_iter()
|
||||||
.any(|copy| {
|
.filter(|copy| {
|
||||||
copy.copy.format_id == i64::from(format_id)
|
copy.copy.format_id == i64::from(format_id)
|
||||||
&& copy.health == PurchaseCopyHealth::CompleteHealthy
|
&& copy.health == PurchaseCopyHealth::CompleteHealthy
|
||||||
});
|
})
|
||||||
if !valid {
|
.map(|copy| copy.copy.copy_id)
|
||||||
|
.collect();
|
||||||
|
if valid_copy_ids.is_empty() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
// Persist the exact denominator proven by AlbumView. This
|
||||||
|
// also upgrades healthy legacy copies so the late
|
||||||
|
// resolver can re-check completeness after a restart.
|
||||||
|
for copy_id in valid_copy_ids {
|
||||||
|
db.set_purchase_copy_expected_tracks(©_id, &expected)?;
|
||||||
|
}
|
||||||
db.set_purchase_playback_preference(
|
db.set_purchase_playback_preference(
|
||||||
&album_id,
|
&album_id,
|
||||||
PurchasePlaybackMode::Purchase,
|
PurchasePlaybackMode::Purchase,
|
||||||
@@ -2651,6 +2659,7 @@ fn reserve_new_copy(
|
|||||||
album_id: &str,
|
album_id: &str,
|
||||||
format_id: u32,
|
format_id: u32,
|
||||||
desired_folder: &Path,
|
desired_folder: &Path,
|
||||||
|
expected_track_ids: &[i64],
|
||||||
) -> Option<qbz_library::PurchaseDownloadCopy> {
|
) -> Option<qbz_library::PurchaseDownloadCopy> {
|
||||||
let db = open_owned_library_db()?;
|
let db = open_owned_library_db()?;
|
||||||
let registered: HashSet<PathBuf> = db
|
let registered: HashSet<PathBuf> = db
|
||||||
@@ -2671,7 +2680,12 @@ fn reserve_new_copy(
|
|||||||
match qbz_library::probe_default(&candidate) {
|
match qbz_library::probe_default(&candidate) {
|
||||||
Reach::Missing => {
|
Reach::Missing => {
|
||||||
return db
|
return db
|
||||||
.create_purchase_copy(album_id, i64::from(format_id), &candidate)
|
.create_purchase_copy_with_expected_tracks(
|
||||||
|
album_id,
|
||||||
|
i64::from(format_id),
|
||||||
|
&candidate,
|
||||||
|
expected_track_ids,
|
||||||
|
)
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
Reach::Present => continue,
|
Reach::Present => continue,
|
||||||
@@ -2695,11 +2709,13 @@ async fn prepare_new_copy(
|
|||||||
let desired = album_dir_for(&root, &plan.artist, &plan.album_title, &plan.quality_dir);
|
let desired = album_dir_for(&root, &plan.artist, &plan.album_title, &plan.quality_dir);
|
||||||
let album_id = plan.album_id.clone();
|
let album_id = plan.album_id.clone();
|
||||||
let format_id = plan.format_id;
|
let format_id = plan.format_id;
|
||||||
let copy =
|
let expected: Vec<i64> = plan.track_ids.iter().map(|id| *id as i64).collect();
|
||||||
tokio::task::spawn_blocking(move || reserve_new_copy(&album_id, format_id, &desired))
|
let copy = tokio::task::spawn_blocking(move || {
|
||||||
.await
|
reserve_new_copy(&album_id, format_id, &desired, &expected)
|
||||||
.ok()
|
})
|
||||||
.flatten();
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
let Some(copy) = copy else {
|
let Some(copy) = copy else {
|
||||||
crate::toast_qt::error(qbz_i18n::t("Failed to start download. Please try again."));
|
crate::toast_qt::error(qbz_i18n::t("Failed to start download. Please try again."));
|
||||||
return None;
|
return None;
|
||||||
@@ -2732,6 +2748,15 @@ async fn prepare_existing_copy(mut plan: DownloadPlan, copy_id: String) -> Optio
|
|||||||
if copy.album_id != album_id || copy.format_id != selected_format {
|
if copy.album_id != album_id || copy.format_id != selected_format {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// A pre-inventory/legacy partial has no durable denominator. The
|
||||||
|
// AlbumView tracklist supplies the exact album manifest before this
|
||||||
|
// continuation adds more rows.
|
||||||
|
if db
|
||||||
|
.set_purchase_copy_expected_tracks(©.copy_id, &expected)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if qbz_library::probe_default(Path::new(©.resolved_album_folder)) != Reach::Present {
|
if qbz_library::probe_default(Path::new(©.resolved_album_folder)) != Reach::Present {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user