diff --git a/crabidy-server/src/cli.rs b/crabidy-server/src/cli.rs index bd79739..073750b 100644 --- a/crabidy-server/src/cli.rs +++ b/crabidy-server/src/cli.rs @@ -235,6 +235,8 @@ async fn scan_file( artist: String::new(), duration: None, album: None, + // A scanned local file has no provider-internal id. + provider_item_id: None, playable, }; tokio::fs::write(&sidecar, track_file.to_toml()?).await?; diff --git a/fsdy/src/lib.rs b/fsdy/src/lib.rs index bcbcf96..443f336 100644 --- a/fsdy/src/lib.rs +++ b/fsdy/src/lib.rs @@ -81,6 +81,14 @@ pub struct TrackFile { /// Optional album metadata. #[serde(skip_serializing_if = "Option::is_none")] pub album: Option, + /// Provider-internal id of the source item (`Track.provider_item_id`), + /// preserved so it survives a link/queue/bookmark round trip. Without it + /// a later capture of a linked track cannot de-duplicate by provider id + /// *before* downloading — it falls back to hashing the fetched bytes + /// (architecture/crabidy-store.md D3/D4). Omitted when the source has + /// none (e.g. a local `/fs` file). + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_item_id: Option, /// The playable reference; exactly one of its fields must be set. pub playable: PlayableSpec, } @@ -218,9 +226,10 @@ impl TrackFile { release_date: a.release_date.clone(), }), is_skipped: matches!(playable, Ok(Playable::Skipped)), - // The provider that owns a link target sets this at its end; a - // local file/store/skipped track has no provider-internal id. - provider_item_id: String::new(), + // Restored from the toml when present (a link/queue/bookmark keeps + // the source's provider id so a later capture can de-dup by id); + // empty when the source had none. + provider_item_id: self.provider_item_id.clone().unwrap_or_default(), // A store-backed track is captured by construction; other // playables are marked (or not) by the server's store index. is_captured: matches!(playable, Ok(Playable::Store(_))), @@ -264,6 +273,10 @@ impl TrackFile { title: a.title.clone(), release_date: a.release_date.clone(), }), + // Keep the source's provider id so a capture of this saved entry + // can de-dup by id before downloading (crabidy-store.md D4). + provider_item_id: (!track.provider_item_id.is_empty()) + .then(|| track.provider_item_id.clone()), playable, } } @@ -1503,6 +1516,33 @@ mod tests { assert_eq!(restored, track); } + #[test] + fn link_files_preserve_the_provider_item_id() { + // A streamed track (e.g. Tidal) carries a provider id; a link save + // (queue/bookmark) must keep it so a later capture of that save can + // de-duplicate by id before downloading (crabidy-store.md D4). + let track = Track { + path: "/tidal/albums/1/125169484".to_string(), + artist: "Efence".to_string(), + title: "Bay of Lost Dreams".to_string(), + duration: Some(216), + album: None, + is_skipped: false, + provider_item_id: "125169484".to_string(), + is_captured: false, + }; + let toml_text = TrackFile::from_track(&track).to_toml().expect("serialize"); + assert!( + toml_text.contains("provider_item_id = \"125169484\""), + "the id is written to the link toml: {toml_text}" + ); + let restored = TrackFile::parse(&toml_text) + .expect("reparse") + .to_track("/crabidy/current/0001 Bay of Lost Dreams.cbd-track.toml"); + assert_eq!(restored.provider_item_id, "125169484"); + assert_eq!(restored, track); + } + #[tokio::test] async fn from_track_with_file_plays_the_relative_sibling() { let track = Track {