diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5a9224a..0e0a8d6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -162,6 +162,11 @@ pub fn run() { shared_playlists::share_send, shared_playlists::shares_inbox, shared_playlists::share_dismiss, + shared_playlists::shared_playlists_following, + shared_playlists::shared_playlist_follow, + shared_playlists::shared_playlist_unfollow, + shared_playlists::shared_playlists_check, + shared_playlists::shared_playlist_follow_update, inbox::inbox_status, inbox::inbox_publish, inbox::inbox_check, diff --git a/src-tauri/src/shared_playlists.rs b/src-tauri/src/shared_playlists.rs index dd2b2b1..0151e27 100644 --- a/src-tauri/src/shared_playlists.rs +++ b/src-tauri/src/shared_playlists.rs @@ -17,11 +17,13 @@ //! normal playlist-import path (the review resolves tracks against their //! library and the catalog, and unowned rows become Soulseek streams). -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use freenet_adapter::FreenetAdapter; +use library::NewPlaylistItem; use persona_state as ps; use persona_state::playlist as pl; use persona_state::slsk_link::normalize_username; @@ -33,7 +35,7 @@ use crate::ledger as ledger_lock; use crate::persona; use crate::pipeline::LibraryState; use crate::playlist_formats::{ParsedEntry, ParsedPlaylist}; -use crate::playlists::{self, ImportDraft}; +use crate::playlists::{self, DraftEntry, ImportDraft}; use crate::state::AppState; const REV_WASM: &[u8] = include_bytes!("../assets/playlist_revision_contract.wasm"); @@ -86,6 +88,46 @@ struct Ledger { published: BTreeMap, #[serde(default)] inbox: Vec, + /// Shared playlists this device imported and keeps an eye on, keyed + /// `owner:uuid` — the one key a database cannot hand out twice. + #[serde(default)] + following: BTreeMap, +} + +/// A shared playlist this device imported and follows. Importing is a +/// snapshot; following is remembering WHICH snapshot, so the owner's next +/// revision reads as "3 new tracks since you imported" rather than as a +/// whole playlist again. One follow per shared playlist: importing it a +/// second time moves the follow to the new copy. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Follow { + pub owner_key: String, + pub playlist_uuid: String, + /// The local playlist the import made. Deleting it ends the follow — + /// SQLite hands the row id to the next playlist created, and nothing + /// may keep pointing at it (`forget_playlist`). + pub playlist_id: i64, + pub name: String, + /// The revision the local copy last took, by content hash. + pub synced_hash: String, + pub synced_seq: u64, + pub synced_at: u64, + pub followed_at: u64, + /// The last check: when it ran and the revision the pointer named. + /// `seen_hash` is empty while the owner has the playlist withdrawn. + pub checked_at: u64, + pub seen_hash: String, + pub seen_seq: u64, + pub withdrawn: bool, + /// Entries `seen` has that `synced` never had, in the owner's order, and + /// how many of `synced`'s entries the owner has since dropped. + #[serde(default)] + pub added: Vec, + #[serde(default)] + pub removed: u32, + /// Why the last check could not read the pointer, if it could not. + #[serde(default)] + pub last_error: Option, } /// What travels in a share: the address and a display hint. @@ -97,7 +139,7 @@ struct ShareRef { entry_count: u32, } -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ShareEntry { /// The occurrence id (b58) — what a proposal's remove or move names. pub entry_id: String, @@ -401,20 +443,67 @@ pub async fn fetch(app: &tauri::AppHandle, owner_b58: &str, uuid_b58: &str) -> R revision_seq: m.revision_seq, content_hash: b58(&r.content_hash), withdrawn: false, - entries: m - .entries - .into_iter() - .map(|e| ShareEntry { - entry_id: b58(&e.entry_id), - position: e.position, - title: e.recording.title, - artist: e.recording.artists.join(", "), - album: e.recording.album, - duration_secs: e.recording.duration_ms.map(|ms| ms / 1000), - note: e.note, + entries: m.entries.iter().map(share_entry).collect(), + }) +} + +/// A manifest entry as the reader sees it: display fields and the +/// occurrence id, never the owner's hints. +fn share_entry(e: &pl::EntryV1) -> ShareEntry { + ShareEntry { + entry_id: b58(&e.entry_id), + position: e.position, + title: e.recording.title.clone(), + artist: e.recording.artists.join(", "), + album: e.recording.album.clone(), + duration_secs: e.recording.duration_ms.map(|ms| ms / 1000), + note: e.note.clone(), + } +} + +/// Shared entries as the import doors take them, so a shared playlist — +/// or the new rows of one — resolves exactly the way a pasted list does. +fn parsed_from(name: &str, description: &str, entries: &[ShareEntry]) -> ParsedPlaylist { + ParsedPlaylist { + format: "soulstream".into(), + title: Some(name.to_string()), + description: Some(description.to_string()).filter(|d| !d.trim().is_empty()), + entries: entries + .iter() + .map(|e| ParsedEntry { + ordinal: e.position + 1, + title: Some(e.title.clone()), + artist: Some(e.artist.clone()), + album: Some(e.album.clone()).filter(|a| !a.trim().is_empty()), + duration_secs: e.duration_secs, + location: None, + start_ms: None, + raw: format!("{} - {}", e.artist, e.title), }) .collect(), - }) + warnings: vec![], + } +} + +/// A resolved draft row as a playlist row — `playlist_import_commit`'s own +/// mapping, minus the setlist segment case a shared playlist never carries. +/// An empty kind lets the insert classify: a matched path is local, a full +/// identity streams, anything weaker stays an unresolved placeholder. +fn new_row(e: DraftEntry) -> NewPlaylistItem { + NewPlaylistItem { + title: e.title, + artist: e.artist, + album: e.album, + duration_secs: e.duration_secs, + kind: String::new(), + path: e.path.filter(|p| !p.is_empty()), + art_path: e.art_path, + source_line: (!e.raw.is_empty()).then_some(e.raw), + start_ms: e.start_ms, + note: None, + track_id: e.track_id.map(|id| id.to_string()), + art_url: e.art_url, + } } /// The occurrence id a local row publishes under: derived from the playlist @@ -500,8 +589,21 @@ pub(crate) async fn current_revision( /// nothing on Freenet is erasable, and withdrawing was never a delete. pub(crate) async fn forget_playlist(app: &tauri::AppHandle, playlist_id: i64) { let key = playlist_id.to_string(); - let Some(rec) = load(app).published.get(&key).cloned() else { return }; - if !rec.withdrawn { + let ledger = load(app); + let rec = ledger.published.get(&key).cloned(); + // A follow points at the local copy by the same recyclable row id, so it + // goes the same way — or the next playlist created would start taking + // someone else's updates. + let follows: Vec = ledger + .following + .iter() + .filter(|(_, f)| f.playlist_id == playlist_id) + .map(|(k, _)| k.clone()) + .collect(); + if rec.is_none() && follows.is_empty() { + return; + } + if let Some(rec) = rec.as_ref().filter(|r| !r.withdrawn) { // withdraw() takes this ledger's lock itself, so it cannot be called // while we hold it — the lock is not reentrant. if let Err(e) = withdraw(app, playlist_id).await { @@ -514,11 +616,21 @@ pub(crate) async fn forget_playlist(app: &tauri::AppHandle, playlist_id: i64) { let _guard = ledger_lock::hold(LEDGER_FILE).await; let mut ledger = load(app); ledger.published.remove(&key); + for k in &follows { + ledger.following.remove(k); + } match save(app, &ledger) { - Ok(()) => log::info!("shared playlists: dropped the share record for deleted playlist {playlist_id} ({:?})", rec.name), + Ok(()) => { + if let Some(rec) = &rec { + log::info!("shared playlists: dropped the share record for deleted playlist {playlist_id} ({:?})", rec.name); + } + if !follows.is_empty() { + log::info!("shared playlists: stopped following {} for deleted playlist {playlist_id}", follows.join(", ")); + } + } // Worth a loud line: this is exactly the state where a new playlist - // can inherit a dead uuid. - Err(e) => log::warn!("shared playlists: could not drop the share record for playlist {playlist_id}: {e}"), + // can inherit a dead uuid, or a dead follow. + Err(e) => log::warn!("shared playlists: could not drop the records for playlist {playlist_id}: {e}"), } } @@ -539,10 +651,12 @@ pub(crate) async fn reconcile_deleted(app: &tauri::AppHandle) { return; } }; - let orphans: Vec = load(app) + let ledger = load(app); + let orphans: BTreeSet = ledger .published .values() .map(|p| p.playlist_id) + .chain(ledger.following.values().map(|f| f.playlist_id)) .filter(|id| !alive.contains(id)) .collect(); for id in orphans { @@ -566,29 +680,274 @@ pub async fn import( if v.withdrawn { return Err("the owner withdrew this playlist".into()); } - let parsed = ParsedPlaylist { - format: "soulstream".into(), - title: Some(v.name.clone()), - description: Some(v.description.clone()).filter(|d| !d.trim().is_empty()), - entries: v - .entries - .iter() - .map(|e| ParsedEntry { - ordinal: e.position + 1, - title: Some(e.title.clone()), - artist: Some(e.artist.clone()), - album: Some(e.album.clone()).filter(|a| !a.trim().is_empty()), - duration_secs: e.duration_secs, - location: None, - start_ms: None, - raw: format!("{} - {}", e.artist, e.title), - }) - .collect(), - warnings: vec![], - }; + // Remembered for `follow`, which runs once the draft has become a local + // playlist: the follow must start at the revision the copy was made + // from, and a pointer read of its own could already name a newer one. + staged().lock().unwrap().insert( + follow_key(owner_b58, uuid_b58), + 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 } +// ---------------------------------------------------------------- following + +fn follow_key(owner_b58: &str, uuid_b58: &str) -> String { + format!("{owner_b58}:{uuid_b58}") +} + +/// What `import` fetched for an (owner, uuid), until `follow` claims it. +struct Staged { + hash: String, + seq: u64, + name: String, +} +static STAGED: OnceLock>> = OnceLock::new(); +fn staged() -> &'static Mutex> { + STAGED.get_or_init(Default::default) +} + +/// Follow (owner, uuid) as local playlist `playlist_id`, from the revision +/// `import` fetched for it. With nothing staged (the app restarted between +/// import and commit) the pointer is read again; the only way that names a +/// different revision is an owner publish inside that window, and the first +/// Update then brings the copy in line. +pub async fn follow( + app: &tauri::AppHandle, + owner_b58: &str, + uuid_b58: &str, + playlist_id: i64, +) -> Result { + let key = follow_key(owner_b58, uuid_b58); + let staged = staged().lock().unwrap().remove(&key); + let (hash, seq, name) = match staged { + Some(s) => (s.hash, s.seq, s.name), + None => { + let v = fetch(app, owner_b58, uuid_b58).await?; + if v.withdrawn { + return Err("the owner withdrew this playlist".into()); + } + (v.content_hash, v.revision_seq, v.name) + } + }; + let now = now_secs(); + let rec = Follow { + owner_key: owner_b58.to_string(), + playlist_uuid: uuid_b58.to_string(), + playlist_id, + name, + synced_hash: hash.clone(), + synced_seq: seq, + synced_at: now, + followed_at: now, + checked_at: now, + seen_hash: hash, + seen_seq: seq, + withdrawn: false, + added: vec![], + removed: 0, + last_error: None, + }; + let _guard = ledger_lock::hold(LEDGER_FILE).await; + let mut ledger = load(app); + ledger.following.insert(key, rec.clone()); + save(app, &ledger)?; + log::info!( + "shared playlists: following {:?} as playlist {playlist_id} from revision {seq}", + rec.name + ); + Ok(rec) +} + +pub async fn unfollow(app: &tauri::AppHandle, owner_b58: &str, uuid_b58: &str) -> Result<(), String> { + let _guard = ledger_lock::hold(LEDGER_FILE).await; + let mut ledger = load(app); + let gone = ledger.following.remove(&follow_key(owner_b58, uuid_b58)); + let Some(gone) = gone else { return Err("not following that playlist".into()) }; + save(app, &ledger)?; + log::info!("shared playlists: stopped following {:?}", gone.name); + Ok(()) +} + +/// What the owner's current revision has that the synced one did not, in +/// the owner's order, and how many synced entries are gone. Occurrence ids +/// are the unit — the proposal board's rule: a track removed and added back +/// is a new entry, and a duplicate stays distinct. +fn diff_entries(synced: &[pl::EntryV1], current: &[pl::EntryV1]) -> (Vec, u32) { + let had: BTreeSet<[u8; 16]> = synced.iter().map(|e| e.entry_id).collect(); + let has: BTreeSet<[u8; 16]> = current.iter().map(|e| e.entry_id).collect(); + let added = current.iter().filter(|e| !had.contains(&e.entry_id)).map(share_entry).collect(); + let removed = had.iter().filter(|id| !has.contains(*id)).count() as u32; + (added, removed) +} + +#[derive(Debug, Default, Serialize)] +pub struct FollowCheck { + pub checked: u32, + /// Follows whose pointer moved since the previous check. + pub changed: u32, + pub errors: u32, +} + +/// What one check found: the pointer's revision, diffed against the synced +/// one. None when it still names the revision the previous check saw. +struct Seen { + hash: String, + seq: u64, + withdrawn: bool, + name: Option, + added: Vec, + removed: u32, +} + +async fn check_one(app: &tauri::AppHandle, f: &Follow) -> Result, String> { + let owner = bytes32(&f.owner_key)?; + let uuid = bytes16(&f.playlist_uuid)?; + let adapter = app.state::().freenet.clone(); + let ptr_ids = FreenetAdapter::contract_ids(PTR_WASM.to_vec(), &pl::make_ptr_params(&owner, &uuid)); + let pbytes = adapter.contract_get(&ptr_ids.instance, false).await.map_err(|e| e.to_string())?; + let p = pl::validate_pointer(&owner, &uuid, &pbytes).map_err(|e| format!("pointer failed verification: {e}"))?; + let Some(r) = p.current.filter(|_| !p.withdrawn) else { + let already = f.withdrawn; + return Ok((!already).then(|| Seen { + hash: String::new(), + seq: 0, + withdrawn: true, + name: None, + added: vec![], + removed: 0, + })); + }; + let hash = b58(&r.content_hash); + if hash == f.seen_hash && !f.withdrawn { + return Ok(None); + } + let (current, _) = revision_by_hash(app, &owner, &r.content_hash).await?; + let (synced, _) = revision_by_hash(app, &owner, &bytes32(&f.synced_hash)?).await?; + let (added, removed) = diff_entries(&synced.entries, ¤t.entries); + Ok(Some(Seen { + hash, + seq: current.revision_seq, + withdrawn: false, + name: Some(current.name), + added, + removed, + })) +} + +/// One pass over every follow: read each pointer, and where it names a +/// revision the previous check did not, load it and diff it against the +/// synced one. The network reads run WITHOUT the ledger lock — a pointer +/// read is a ring search that can take seconds, and a share card arriving +/// meanwhile must not queue behind it — and each result is merged under +/// the lock afterwards, dropped if the follow moved on (an Update, an +/// unfollow) while the read was out. +pub async fn check_follows(app: &tauri::AppHandle) -> Result { + let follows: Vec = load(app).following.into_values().collect(); + let mut out = FollowCheck::default(); + for f in follows { + out.checked += 1; + let result = check_one(app, &f).await; + let _guard = ledger_lock::hold(LEDGER_FILE).await; + let mut ledger = load(app); + let key = follow_key(&f.owner_key, &f.playlist_uuid); + let Some(live) = ledger.following.get_mut(&key) else { continue }; + if live.synced_hash != f.synced_hash { + continue; + } + live.checked_at = now_secs(); + match result { + Ok(Some(seen)) => { + out.changed += 1; + if seen.withdrawn { + log::info!("shared playlists: {:?} was withdrawn by its owner", live.name); + } else { + log::info!( + "shared playlists: {:?} is at revision {} — {} new, {} removed since revision {}", + live.name, + seen.seq, + seen.added.len(), + seen.removed, + live.synced_seq + ); + } + live.seen_hash = seen.hash; + live.seen_seq = seen.seq; + live.withdrawn = seen.withdrawn; + live.added = seen.added; + live.removed = seen.removed; + live.last_error = None; + if let Some(n) = seen.name { + live.name = n; + } + } + Ok(None) => live.last_error = None, + Err(e) => { + out.errors += 1; + log::info!("shared playlists: could not check {:?}: {e}", live.name); + live.last_error = Some(e); + } + } + if let Err(e) = save(app, &ledger) { + log::warn!("shared playlists: could not record the check of {:?}: {e}", f.name); + } + } + Ok(out) +} + +/// Bring the local copy up to the revision the last check saw: the new +/// entries resolve the way an import resolves them — the library first, +/// a Soulseek stream otherwise — and are appended in the owner's order. +/// Nothing is removed or reordered: the copy is the follower's to edit, and +/// a track the owner dropped is reported, never taken away. Held under the +/// ledger lock end to end (the resolve is local work), so a check landing +/// meanwhile merges against the new synced revision or not at all. +pub async fn follow_update( + app: &tauri::AppHandle, + lib: &State<'_, LibraryState>, + owner_b58: &str, + uuid_b58: &str, +) -> Result { + let key = follow_key(owner_b58, uuid_b58); + let _guard = ledger_lock::hold(LEDGER_FILE).await; + let mut ledger = load(app); + let f = ledger.following.get(&key).cloned().ok_or("not following that playlist")?; + if f.withdrawn { + return Err("the owner withdrew this playlist".into()); + } + if f.seen_hash == f.synced_hash { + return Ok(0); + } + 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 rows: Vec = draft.entries.into_iter().map(new_row).collect(); + let l = lib.library.clone(); + let pid = f.playlist_id; + appended = tauri::async_runtime::spawn_blocking(move || l.add_playlist_items(pid, &rows, None, now_secs() as i64)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + playlists::changed(app); + } + let live = ledger.following.get_mut(&key).ok_or("not following that playlist")?; + live.synced_hash = f.seen_hash.clone(); + live.synced_seq = f.seen_seq; + live.synced_at = now_secs(); + live.added.clear(); + live.removed = 0; + save(app, &ledger)?; + log::info!( + "shared playlists: {:?} updated to revision {} — {appended} rows appended to playlist {}", + f.name, + f.seen_seq, + f.playlist_id + ); + Ok(appended) +} + // ---------------------------------------------------------------- share carry pub async fn send(app: &tauri::AppHandle, username: &str, playlist_id: i64) -> Result<(), String> { @@ -742,11 +1101,127 @@ pub async fn share_dismiss(app: tauri::AppHandle, id: String) -> Result<(), Stri ledger.inbox.retain(|c| c.id != id); save(&app, &ledger) } +#[tauri::command] +pub fn shared_playlists_following(app: tauri::AppHandle) -> Vec { + let mut v: Vec<_> = load(&app).following.into_values().collect(); + v.sort_by(|a, b| b.followed_at.cmp(&a.followed_at)); + v +} +#[tauri::command] +pub async fn shared_playlist_follow( + app: tauri::AppHandle, + owner: String, + uuid: String, + playlist_id: i64, +) -> Result { + follow(&app, &owner, &uuid, playlist_id).await +} +#[tauri::command] +pub async fn shared_playlist_unfollow(app: tauri::AppHandle, owner: String, uuid: String) -> Result<(), String> { + unfollow(&app, &owner, &uuid).await +} +#[tauri::command] +pub async fn shared_playlists_check(app: tauri::AppHandle) -> Result { + check_follows(&app).await +} +#[tauri::command] +pub async fn shared_playlist_follow_update( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + owner: String, + uuid: String, +) -> Result { + follow_update(&app, &lib, &owner, &uuid).await +} #[cfg(test)] mod tests { use super::*; + fn entry(id: u8, position: u32, title: &str) -> pl::EntryV1 { + pl::EntryV1 { + entry_id: [id; 16], + position, + recording: pl::RecordingRef { + musicbrainz_recording_id: None, + isrcs: vec![], + deezer_id: None, + apple_music_id: None, + title: title.into(), + artists: vec!["Broadcast".into()], + album: "Tender Buttons".into(), + duration_ms: Some(200_000), + release_year: Some(2005), + }, + note: None, + slsk_hint: None, + } + } + + /// The diff is by occurrence id, in the owner's order: a reorder is not + /// news, a removal is counted, and a duplicate occurrence is its own row. + #[test] + fn a_follow_diff_is_by_occurrence_in_the_owners_order() { + let synced = vec![entry(1, 0, "Black Cat"), entry(2, 1, "Tears in the Typing Pool"), entry(3, 2, "Corporeal")]; + let (added, removed) = diff_entries(&synced, &synced); + assert!(added.is_empty()); + assert_eq!(removed, 0); + + // Reordered, one dropped, two appended, and Black Cat a second time + // as a new occurrence. + let current = vec![ + entry(3, 0, "Corporeal"), + entry(1, 1, "Black Cat"), + entry(9, 2, "Michael A Grammar"), + entry(8, 3, "America's Boy"), + entry(7, 4, "Black Cat"), + ]; + let (added, removed) = diff_entries(&synced, ¤t); + assert_eq!( + added.iter().map(|e| e.title.as_str()).collect::>(), + ["Michael A Grammar", "America's Boy", "Black Cat"] + ); + assert_eq!(added.iter().map(|e| e.position).collect::>(), [2, 3, 4]); + assert_eq!(removed, 1); + } + + /// A ledger written before follows existed still loads, and a follow + /// survives the round trip with its diff. + #[test] + fn the_ledger_reads_older_files_and_keeps_follows() { + let old: Ledger = serde_json::from_str(r#"{"published":{},"inbox":[]}"#).unwrap(); + assert!(old.following.is_empty()); + + let mut ledger = Ledger::default(); + let (added, removed) = diff_entries(&[entry(1, 0, "Black Cat")], &[entry(1, 0, "Black Cat"), entry(2, 1, "Corporeal")]); + ledger.following.insert( + follow_key("owner", "uuid"), + 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: "h2".into(), + seen_seq: 2, + withdrawn: false, + added, + removed, + last_error: None, + }, + ); + let back: Ledger = serde_json::from_slice(&serde_json::to_vec(&ledger).unwrap()).unwrap(); + let f = &back.following["owner:uuid"]; + assert_eq!(f.playlist_id, 4); + assert_eq!(f.added.len(), 1); + assert_eq!(f.added[0].title, "Corporeal"); + assert_eq!(f.removed, 0); + } + /// The bundled contracts are commitments (copy-forward is disabled /// upstream): a changed wasm re-keys every published playlist. #[test] diff --git a/src/lib/api.js b/src/lib/api.js index 33fb308..3ebe5b7 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -173,6 +173,11 @@ export const sharedPlaylistImport = (owner, uuid) => invoke("shared_playlist_imp export const shareSend = (username, playlistId) => invoke("share_send", { username, playlistId }); export const sharesInbox = () => invoke("shares_inbox"); export const shareDismiss = (id) => invoke("share_dismiss", { id }); +export const sharedPlaylistsFollowing = () => invoke("shared_playlists_following"); +export const sharedPlaylistFollow = (owner, uuid, playlistId) => invoke("shared_playlist_follow", { owner, uuid, playlistId }); +export const sharedPlaylistUnfollow = (owner, uuid) => invoke("shared_playlist_unfollow", { owner, uuid }); +export const sharedPlaylistsCheck = () => invoke("shared_playlists_check"); +export const sharedPlaylistFollowUpdate = (owner, uuid) => invoke("shared_playlist_follow_update", { owner, uuid }); export const inboxStatus = () => invoke("inbox_status"); export const inboxPublish = () => invoke("inbox_publish"); export const inboxCheck = () => invoke("inbox_check"); diff --git a/src/lib/community.svelte.js b/src/lib/community.svelte.js index f25c1ec..8a83ad8 100644 --- a/src/lib/community.svelte.js +++ b/src/lib/community.svelte.js @@ -74,6 +74,9 @@ export const community = $state({ sharePreviews: {}, /** shared_playlists_mine: what this device has published. */ published: [], + /** shared_playlists_following: imported shared playlists this device + * keeps an eye on, each with what the last check found. */ + following: [], /** inbox_status: the encrypted inbox's state on this device. */ inbox: null, /** What the last check found ({new_items, quarantined, pruned}). */ @@ -328,8 +331,13 @@ function wireEvents() { void refreshNodeInfo(); // A fresh connection may carry a persona we have not read yet; // a lost one invalidates what we had. - if (adapterConnected(status)) void refreshPersona(); - else if (community.persona) community.persona.available = false; + if (adapterConnected(status)) { + void refreshPersona(); + // The node usually comes up after the watch started, so the + // first follow check rides the connection rather than the + // interval. + void pollFollows(); + } else if (community.persona) community.persona.available = false; }); api.onEvent("community:friends", () => void refreshFriends()); api.onEvent("community:spaces", () => { @@ -339,6 +347,7 @@ function wireEvents() { api.onEvent("community:shares", () => { void refreshShares(); void refreshPublished(); + void refreshFollowing(); }); api.onEvent("community:overlap", () => void refreshOverlap()); api.onEvent("community:proposal", (p) => { @@ -355,7 +364,7 @@ function wireEvents() { /** Idempotent: pulls the state the Community page draws. */ export async function initCommunity() { wireEvents(); - await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox(), refreshSpaces(), refreshOverlap()]); + await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshFollowing(), refreshInbox(), refreshSpaces(), refreshOverlap()]); await refreshPersona(); } @@ -370,15 +379,43 @@ export async function initCommunity() { * that comes up later — or an inbox published later — simply starts working * without anything having to restart the timer. */ const INBOX_POLL_MS = 90_000; +/** Followed playlists are read far less often: every pointer read is a ring + * search that can take seconds, and an owner's next revision is a matter + * of days, not minutes. */ +const FOLLOW_POLL_MS = 10 * 60_000; let inboxWatch = 0; +let followWatch = 0; let polling = false; +let checkingFollows = false; export async function startInboxWatch() { if (inboxWatch) return; wireEvents(); inboxWatch = setInterval(() => void pollInbox(), INBOX_POLL_MS); + followWatch = setInterval(() => void pollFollows(), FOLLOW_POLL_MS); await refreshFreenet(); await pollInbox(); + // Already connected (a reload with the node up): check now rather than + // wait out the first interval. Otherwise the status event does it. + void pollFollows(); +} + +/** One pass over the followed playlists, quiet like the inbox poll: the + * backend logs what it found, and a card appears only when there is news. */ +async function pollFollows() { + if (checkingFollows || community.busy["followcheck"]) return; + checkingFollows = true; + try { + if (!adapterConnected(community.freenet?.status)) return; + await refreshFollowing(); + if (!community.following.length) return; + await api.sharedPlaylistsCheck(); + await refreshFollowing(); + } catch { + // Never surfaces; the Check now button is the one that reports. + } finally { + checkingFollows = false; + } } /** One tick, and quiet by design: a node that is down or an inbox that was @@ -566,7 +603,9 @@ export async function sharePreview(card) { } } /** Fetch, verify, stage through the app's import path, commit — the new - * playlist's id comes back; the card is dismissed. */ + * playlist's id comes back; the card is dismissed. The copy then follows + * the shared playlist, so the owner's next revision reads as "3 new tracks + * since you imported" instead of arriving as a second copy. */ export const shareImport = (card) => run("import:" + card.id, async () => { const draft = await api.sharedPlaylistImport(card.owner_key, card.playlist_uuid); @@ -577,11 +616,52 @@ export const shareImport = (card) => draft.entries, draft.cover_url ?? null, ); + try { + await api.sharedPlaylistFollow(card.owner_key, card.playlist_uuid, id); + } catch (e) { + // The playlist is already real; a follow that could not be + // recorded is not worth failing the import over. + console.warn("could not follow the shared playlist", e); + } await api.shareDismiss(card.id); - await refreshShares(); + await Promise.all([refreshShares(), refreshFollowing()]); return id; }); +// ---- followed playlists ------------------------------------------------------------- +export async function refreshFollowing() { + try { + community.following = await api.sharedPlaylistsFollowing(); + } catch { + // stays as it was + } +} +/** The follow behind a local playlist, when it was imported from a share. */ +export const followFor = (playlistId) => community.following.find((f) => f.playlist_id === playlistId) ?? null; +/** Follows with something to act on: tracks the owner added, or dropped. A + * withdrawal is a state, not news — the playlist page says so. */ +export const followNews = () => community.following.filter((f) => !f.withdrawn && (f.added.length || f.removed)); +async function checkFollowsNow() { + const r = await api.sharedPlaylistsCheck(); + await refreshFollowing(); + return r; +} +/** The Check now button: busy state, and a failure the page can show. */ +export const checkFollows = () => run("followcheck", checkFollowsNow); +/** Append what the owner added since the copy was last synced. Rows resolve + * the way an import does; nothing is removed from the copy. */ +export const followUpdate = (f) => + run("follow:" + f.playlist_uuid, async () => { + const added = await api.sharedPlaylistFollowUpdate(f.owner_key, f.playlist_uuid); + await refreshFollowing(); + return added; + }); +export const unfollow = (f) => + run("follow:" + f.playlist_uuid, async () => { + await api.sharedPlaylistUnfollow(f.owner_key, f.playlist_uuid); + await refreshFollowing(); + }); + // ---- the encrypted inbox -------------------------------------------------------------- export async function refreshInbox() { @@ -730,4 +810,4 @@ export const decideProposal = (owner, space, proposal, accept, reason) => // Dev handle beside `__soulstream` (state.svelte.js): probes reach the store // through this, never through a bare import (which would be a second copy). if (import.meta.env.DEV) globalThis.__community = community; -if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest }; +if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, refreshFollowing, followFor, followNews, checkFollows, followUpdate, unfollow, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest }; diff --git a/src/views/Community.svelte b/src/views/Community.svelte index 919f67f..0903e78 100644 --- a/src/views/Community.svelte +++ b/src/views/Community.svelte @@ -78,6 +78,9 @@ sharePreview, shareImport, shareDismiss, + followNews, + followUpdate, + unfollow, sharedPublish, sharedWithdraw, publishInbox, @@ -835,7 +838,40 @@ {/each} {/if} - {#if !convos.length && !community.shares.length && !pendingIn().length} + {#if followNews().length} +
+ {#each followNews() as f (f.owner_key + ":" + f.playlist_uuid)} + {@const busy = !!community.busy["follow:" + f.playlist_uuid]} +
+
+ +
+
{f.name}
+
+ {#if f.added.length}{f.added.length} new {f.added.length === 1 ? "track" : "tracks"} since you imported it{#if f.removed}{" · "}{f.removed} removed{/if}{:else}the owner removed {f.removed} {f.removed === 1 ? "track" : "tracks"} — your copy keeps {f.removed === 1 ? "it" : "them"}{/if} + {" · "}revision {f.seen_seq} · checked {fmtWhen(f.checked_at * 1000)} +
+
+ SoulStream + signed by {fpShort(f.owner_key)} + · + your copy: +
+ {#if f.added.length} +
{f.added.slice(0, 3).map((e) => `${e.artist} — ${e.title}`).join(" · ")}{#if f.added.length > 3} · and {f.added.length - 3} more{/if}
+ {/if} +
+
+
+ + + new tracks resolve against your library first; the rest become Soulseek streams — nothing is removed from your copy +
+
+ {/each} +
+ {/if} + {#if !convos.length && !community.shares.length && !pendingIn().length && !followNews().length}
No conversations yet
diff --git a/src/views/Playlist.svelte b/src/views/Playlist.svelte index f96023c..ff92131 100644 --- a/src/views/Playlist.svelte +++ b/src/views/Playlist.svelte @@ -30,7 +30,12 @@ community, initCommunity, refreshPublished, + refreshFollowing, publishedFor, + followFor, + followUpdate, + unfollow, + checkFollows, sharedPublish, sharedWithdraw, shareSend, @@ -59,7 +64,46 @@ shareOpen = true; shareNote = ""; await initCommunity(); - await refreshPublished(); + await Promise.all([refreshPublished(), refreshFollowing()]); + } + // ---- Following (an imported share keeps an eye on its source) ---- + const fpShort = (k) => (k ? `${k.slice(0, 4)}…${k.slice(-4)}` : ""); + function fmtAgo(secs) { + if (!secs) return ""; + const m = Math.round((Date.now() / 1000 - secs) / 60); + if (m < 1) return "just now"; + if (m < 60) return `${m} min ago`; + const h = Math.round(m / 60); + if (h < 24) return `${h} ${h === 1 ? "hour" : "hours"} ago`; + const d = Math.round(h / 24); + return `${d} ${d === 1 ? "day" : "days"} ago`; + } + async function doFollowUpdate(f) { + shareNote = ""; + try { + const n = await followUpdate(f); + shareNote = n ? `Added ${n} ${n === 1 ? "track" : "tracks"} from the owner's revision ${f.seen_seq}.` : `Up to date with revision ${f.seen_seq}.`; + } catch (e) { + shareNote = String(e?.message ?? e); + } + } + async function doCheckFollows() { + shareNote = ""; + try { + const r = await checkFollows(); + shareNote = r.errors ? "The check could not reach the owner's pointer — see below." : r.changed ? "The owner has published a new revision." : "Nothing new since the last check."; + } catch (e) { + shareNote = String(e?.message ?? e); + } + } + async function doUnfollow(f) { + shareNote = ""; + try { + await unfollow(f); + shareNote = "No longer following. Your copy stays as it is."; + } catch (e) { + shareNote = String(e?.message ?? e); + } } async function doPublish() { shareNote = ""; @@ -1089,11 +1133,30 @@ {#if shareOpen && playlist} {@const pub = publishedFor(playlist.id)} + {@const fol = followFor(playlist.id)}