shared playlists: importing a share follows it, so the owner's next revision reads as "2 new tracks since you imported"
Importing a shared playlist was a one-time snapshot: the owner's next
revision could only arrive as a whole second copy. A copy now remembers
WHICH revision it was made from, and the app reads the pointer for it
every ten minutes — no new contract, the same pointer and revision reads
the share card already does.
The ledger gains `following`, keyed `owner:uuid` — the one key a database
cannot hand out twice. A follow records the local playlist, the synced
revision (hash + seq) and what the last check saw: the current revision,
the entries it has that the synced one never had (in the owner's order),
and how many synced entries are gone. The diff is by occurrence id, the
proposal board's rule: a reorder is not news, a removal is counted, a
duplicate stays its own row. `import` stages the fetched revision so
`follow` records exactly what the copy was made from — a pointer read of
its own could already name a newer one.
Update appends the new rows through the same resolve path an import uses
(library first, Soulseek stream otherwise) and never removes or reorders:
the copy is the follower's to edit, and a track the owner dropped is
reported, not taken away. It runs under the ledger lock end to end, so a
check landing meanwhile merges against the new synced revision or not at
all. Checks read the network WITHOUT the lock — a pointer read is a ring
search — and merge each result under it afterwards, dropped if the follow
moved on.
Deleting the local copy ends the follow, in `forget_playlist` and the
startup reconcile both: the row id goes back to SQLite, and the next
playlist created must not start taking someone else's updates. Seen live:
the re-import after a delete took row id 1 again and followed cleanly.
Surfaces: an Inbox card when a check finds news ("2 new tracks since you
imported it · 1 removed", Add 2 tracks / stop following), and a Following
row in the playlist's Share sheet (up to date / N new / withdrawn by the
owner, Check now, Stop following). The poll rides the inbox watch: its own
ten-minute interval, plus one pass when the adapter connects.
Live, two instances on this machine: import + follow 582 ms; the owner's
revision 2 (one dropped, two added) read back as exactly "2 new, 1 removed"
in 2.7 s; Add appended two stream rows in 46 ms and left the dropped track
in place; a withdraw read as withdrawn in 57 ms; delete took the follow
with it; re-follow and unfollow both left the copy alone. One pointer read
after a republish timed out at 120 s and arrived a second later — the same
first-read-after-a-change lag the board has.
Tests: the diff (order, removal, a duplicate occurrence) and the ledger
reading files written before follows existed. App 152 · 6 ignored.
This commit is contained in:
1 parent
df06f849e0
commit
f67a0a30b4
6 files changed
+713
-49
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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<String, Published>,
|
||||
#[serde(default)]
|
||||
inbox: Vec<ShareCard>,
|
||||
/// 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<String, Follow>,
|
||||
}
|
||||
|
||||
/// 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<ShareEntry>,
|
||||
#[serde(default)]
|
||||
pub removed: u32,
|
||||
/// Why the last check could not read the pointer, if it could not.
|
||||
#[serde(default)]
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String> = 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<i64> = load(app)
|
||||
let ledger = load(app);
|
||||
let orphans: BTreeSet<i64> = 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<Mutex<HashMap<String, Staged>>> = OnceLock::new();
|
||||
fn staged() -> &'static Mutex<HashMap<String, Staged>> {
|
||||
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<Follow, String> {
|
||||
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<ShareEntry>, 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<String>,
|
||||
added: Vec<ShareEntry>,
|
||||
removed: u32,
|
||||
}
|
||||
|
||||
async fn check_one(app: &tauri::AppHandle, f: &Follow) -> Result<Option<Seen>, String> {
|
||||
let owner = bytes32(&f.owner_key)?;
|
||||
let uuid = bytes16(&f.playlist_uuid)?;
|
||||
let adapter = app.state::<AppState>().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<FollowCheck, String> {
|
||||
let follows: Vec<Follow> = 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<u32, String> {
|
||||
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<NewPlaylistItem> = 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<Follow> {
|
||||
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, String> {
|
||||
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<FollowCheck, String> {
|
||||
check_follows(&app).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn shared_playlist_follow_update(
|
||||
app: tauri::AppHandle,
|
||||
lib: State<'_, LibraryState>,
|
||||
owner: String,
|
||||
uuid: String,
|
||||
) -> Result<u32, String> {
|
||||
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::<Vec<_>>(),
|
||||
["Michael A Grammar", "America's Boy", "Black Cat"]
|
||||
);
|
||||
assert_eq!(added.iter().map(|e| e.position).collect::<Vec<_>>(), [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]
|
||||
|
||||
Reference in new issue
Block a user