feat(purchases): persist copy coverage manifests

cx
This commit is contained in:
vicrodh committed 2026-09-03 05:18:22 -06:00
1 parent c0a37e6b87
commit 8566b68411
2 files changed
+246 -11

No files matched your search

+210
View File
@@ -121,6 +121,13 @@ pub(crate) fn init_schema(conn: &Connection) -> Result<(), LibraryError> {
CREATE INDEX IF NOT EXISTS idx_purchase_copy_tracks_track
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 (
album_id TEXT PRIMARY KEY,
mode TEXT NOT NULL CHECK(mode IN ('qobuz','purchase')),
@@ -238,6 +245,68 @@ impl LibraryDatabase {
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, &copy)?;
insert_expected_tracks(&tx, &copy.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(
&self,
track: &PurchaseDownloadCopyTrack,
@@ -494,6 +563,52 @@ impl LibraryDatabase {
}
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.copy_id)?
.into_iter()
.collect();
if expected.is_empty() || !expected.contains(&track_id) {
continue;
}
let rows = self.get_purchase_copy_tracks(&copy.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> {
@@ -575,6 +690,53 @@ fn select_copy_tracks(
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 {
match crate::probe_file_default(Path::new(&track.file_path)) {
FileProbe::Missing => PurchaseTrackHealth::Missing,
@@ -708,6 +870,54 @@ mod tests {
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]
fn legacy_migration_groups_by_folder() {
let root = tempfile::tempdir().unwrap();