diff --git a/src-tauri/src/shared_playlists.rs b/src-tauri/src/shared_playlists.rs index 0151e27..8115710 100644 --- a/src-tauri/src/shared_playlists.rs +++ b/src-tauri/src/shared_playlists.rs @@ -149,6 +149,11 @@ pub struct ShareEntry { pub album: String, pub duration_secs: Option, pub note: Option, + /// The owner's catalog id for the recording (Deezer), when their row + /// had one. It rides the manifest already; carrying it here lets an + /// import or an Update stream the row by identity instead of by name. + #[serde(default)] + pub track_id: Option, } /// A fetched, VERIFIED shared playlist as the reader sees it. @@ -458,6 +463,25 @@ fn share_entry(e: &pl::EntryV1) -> ShareEntry { album: e.recording.album.clone(), duration_secs: e.recording.duration_ms.map(|ms| ms / 1000), note: e.note.clone(), + track_id: e.recording.deezer_id.clone().filter(|id| !id.trim().is_empty()), + } +} + +/// A shared row's catalog id, laid onto the draft row the resolve made +/// from it. `ParsedEntry` has no id column — the import doors take text +/// lines — so the id joins after the resolve, by ordinal. A row that +/// matched a library file keeps the id too: it plays from the file, and +/// the id is what the catalog knows it as. Only numeric ids qualify; the +/// row column is the catalog's integer key. +fn attach_catalog_ids(draft: &mut ImportDraft, entries: &[ShareEntry]) { + let ids: std::collections::HashMap = entries + .iter() + .filter_map(|e| Some((e.position + 1, e.track_id.as_deref()?.trim().parse::().ok()?))) + .collect(); + for row in &mut draft.entries { + if row.track_id.is_none() { + row.track_id = ids.get(&row.ordinal).copied(); + } } } @@ -688,7 +712,9 @@ pub async fn import( Staged { hash: v.content_hash.clone(), seq: v.revision_seq, name: v.name.clone() }, ); let parsed = parsed_from(&v.name, &v.description, &v.entries); - playlists::resolve_draft(lib, parsed, format!("SoulStream · {}", v.name), None).await + let mut draft = playlists::resolve_draft(lib, parsed, format!("SoulStream · {}", v.name), None).await?; + attach_catalog_ids(&mut draft, &v.entries); + Ok(draft) } // ---------------------------------------------------------------- following @@ -788,6 +814,37 @@ pub struct FollowCheck { /// Follows whose pointer moved since the previous check. pub changed: u32, pub errors: u32, + /// The moves that are news — a revision with rows the synced one never + /// had, or fewer of them. What the background poll announces; a + /// withdrawal is a state the playlist page shows, not news. + #[serde(default)] + pub news: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FollowNews { + pub owner_key: String, + pub playlist_uuid: String, + pub playlist_id: i64, + pub name: String, + pub added: u32, + pub removed: u32, +} + +/// What a check found, if it is worth a word: only a live revision that +/// differs from the synced one by rows. +fn news_of(f: &Follow, seen: &Seen) -> Option { + if seen.withdrawn || (seen.added.is_empty() && seen.removed == 0) { + return None; + } + Some(FollowNews { + owner_key: f.owner_key.clone(), + playlist_uuid: f.playlist_uuid.clone(), + playlist_id: f.playlist_id, + name: seen.name.clone().unwrap_or_else(|| f.name.clone()), + added: seen.added.len() as u32, + removed: seen.removed, + }) } /// What one check found: the pointer's revision, diffed against the synced @@ -860,6 +917,7 @@ pub async fn check_follows(app: &tauri::AppHandle) -> Result { out.changed += 1; + out.news.extend(news_of(live, &seen)); if seen.withdrawn { log::info!("shared playlists: {:?} was withdrawn by its owner", live.name); } else { @@ -922,7 +980,8 @@ pub async fn follow_update( let mut appended = 0; if !f.added.is_empty() { let parsed = parsed_from(&f.name, "", &f.added); - let draft = playlists::resolve_draft(lib, parsed, format!("SoulStream · {}", f.name), None).await?; + let mut draft = playlists::resolve_draft(lib, parsed, format!("SoulStream · {}", f.name), None).await?; + attach_catalog_ids(&mut draft, &f.added); let rows: Vec = draft.entries.into_iter().map(new_row).collect(); let l = lib.library.clone(); let pid = f.playlist_id; @@ -1222,6 +1281,108 @@ mod tests { assert_eq!(f.removed, 0); } + fn draft_row(ordinal: u32, track_id: Option) -> DraftEntry { + DraftEntry { + ordinal, + title: "Black Cat".into(), + artist: "Broadcast".into(), + album: None, + duration_secs: None, + start_ms: None, + raw: String::new(), + resolution: "stream".into(), + matched_by: None, + path: None, + art_path: None, + track_id, + art_url: None, + isrc: None, + } + } + + /// The owner's Deezer id rides the manifest already; the reader keeps + /// it, and it lands on the draft row the resolve made — by ordinal, + /// numeric only, never over an id the review already attached. + #[test] + fn a_shared_row_carries_the_owners_catalog_id_onto_the_draft() { + let mut with = entry(1, 0, "Black Cat"); + with.recording.deezer_id = Some("3135556".into()); + let mut junk = entry(2, 1, "Corporeal"); + junk.recording.deezer_id = Some("not-a-number".into()); + let mut blank = entry(3, 2, "Tears in the Typing Pool"); + blank.recording.deezer_id = Some(" ".into()); + let mut kept = entry(4, 3, "Michael A Grammar"); + kept.recording.deezer_id = Some("999".into()); + let entries: Vec = [with, junk, blank, kept].iter().map(share_entry).collect(); + assert_eq!(entries[0].track_id.as_deref(), Some("3135556")); + assert_eq!(entries[2].track_id, None); + + let mut draft = ImportDraft { + source_name: String::new(), + format: "soulstream".into(), + name: None, + description: None, + kind: "manual".into(), + entries: vec![draft_row(1, None), draft_row(2, None), draft_row(3, None), draft_row(4, Some(42))], + warnings: vec![], + resolved_count: 0, + stream_count: 4, + unresolved_count: 0, + cover_url: None, + }; + attach_catalog_ids(&mut draft, &entries); + let ids: Vec> = draft.entries.iter().map(|r| r.track_id).collect(); + assert_eq!(ids, [Some(3135556), None, None, Some(42)]); + + // An older ledger's rows have no id column and still load. + let old: ShareEntry = serde_json::from_str( + r#"{"entry_id":"x","position":0,"title":"t","artist":"a","album":"","duration_secs":null,"note":null}"#, + ) + .unwrap(); + assert_eq!(old.track_id, None); + } + + /// Only a live revision that differs by rows is news: a withdrawal is + /// a state the playlist page shows, and a pointer that moved to a + /// reorder has nothing to announce. + #[test] + fn only_a_revision_with_rows_to_report_is_news() { + let f = Follow { + owner_key: "owner".into(), + playlist_uuid: "uuid".into(), + playlist_id: 4, + name: "Tender Buttons".into(), + synced_hash: "h1".into(), + synced_seq: 1, + synced_at: 10, + followed_at: 10, + checked_at: 20, + seen_hash: "h1".into(), + seen_seq: 1, + withdrawn: false, + added: vec![], + removed: 0, + last_error: None, + }; + let quiet = Seen { hash: "h2".into(), seq: 2, withdrawn: false, name: None, added: vec![], removed: 0 }; + assert_eq!(news_of(&f, &quiet), None); + let gone = Seen { hash: String::new(), seq: 0, withdrawn: true, name: None, added: vec![], removed: 0 }; + assert_eq!(news_of(&f, &gone), None); + let (added, removed) = diff_entries(&[entry(1, 0, "Black Cat")], &[entry(2, 0, "Corporeal"), entry(3, 1, "Pendulum")]); + let moved = Seen { hash: "h2".into(), seq: 2, withdrawn: false, name: Some("Tender Buttons (2005)".into()), added, removed }; + assert_eq!( + news_of(&f, &moved), + Some(FollowNews { + owner_key: "owner".into(), + playlist_uuid: "uuid".into(), + playlist_id: 4, + name: "Tender Buttons (2005)".into(), + added: 2, + removed: 1, + }) + ); + } + /// The bundled contracts are commitments (copy-forward is disabled /// upstream): a changed wasm re-keys every published playlist. #[test] diff --git a/src/App.svelte b/src/App.svelte index e3f2073..10d8aa8 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -27,6 +27,7 @@ import ResolverToast from "./components/ResolverToast.svelte"; import UploadToast from "./components/UploadToast.svelte"; import ProposalToast from "./components/ProposalToast.svelte"; + import FollowNewsToast from "./components/FollowNewsToast.svelte"; import SetlistScanChip from "./components/SetlistScanChip.svelte"; import { ui, @@ -186,6 +187,7 @@ + diff --git a/src/components/FollowNewsToast.svelte b/src/components/FollowNewsToast.svelte new file mode 100644 index 0000000..896eb10 --- /dev/null +++ b/src/components/FollowNewsToast.svelte @@ -0,0 +1,123 @@ + + +{#if shown} +
+
+ + {line.lead}{line.rest} +
+
+{/if} + + diff --git a/src/lib/community.svelte.js b/src/lib/community.svelte.js index 8a83ad8..bbf6315 100644 --- a/src/lib/community.svelte.js +++ b/src/lib/community.svelte.js @@ -8,7 +8,7 @@ //