From 4ca9ab043b21c081f570022aa69710443fff781e Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:06:34 -0500 Subject: [PATCH] shared playlists: a deleted playlist takes its share with it, so the next one does not go out wearing its name --- src-tauri/src/lib.rs | 8 ++++ src-tauri/src/playlists.rs | 4 ++ src-tauri/src/shared_playlists.rs | 67 ++++++++++++++++++++++++++++++- 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6013688..38dc5e9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -76,6 +76,14 @@ pub fn run() { freenet::init(app.handle()); if let Err(e) = pipeline::init(app.handle()) { log::error!("library init failed: {e}"); + } else { + // After pipeline::init, which is what manages the library: + // a share record whose playlist is gone keeps a key the + // database will hand to the next playlist created. + let h = app.handle().clone(); + tauri::async_runtime::spawn(async move { + shared_playlists::reconcile_deleted(&h).await; + }); } Ok(()) }) diff --git a/src-tauri/src/playlists.rs b/src-tauri/src/playlists.rs index 9f8298c..1bfcf2f 100644 --- a/src-tauri/src/playlists.rs +++ b/src-tauri/src/playlists.rs @@ -202,6 +202,10 @@ pub async fn delete_playlist( lib: State<'_, LibraryState>, id: i64, ) -> Result<(), String> { + // Before the local truth goes, not after: a share this device can no + // longer honour is withdrawn and its ledger row dropped, or the next + // playlist to be handed this row id publishes as the one just deleted. + crate::shared_playlists::forget_playlist(&app, id).await; let l = lib.library.clone(); tauri::async_runtime::spawn_blocking(move || l.delete_playlist(id)) .await diff --git a/src-tauri/src/shared_playlists.rs b/src-tauri/src/shared_playlists.rs index 9ff0290..dd2b2b1 100644 --- a/src-tauri/src/shared_playlists.rs +++ b/src-tauri/src/shared_playlists.rs @@ -17,7 +17,7 @@ //! normal playlist-import path (the review resolves tracks against their //! library and the catalog, and unowned rows become Soulseek streams). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -485,6 +485,71 @@ pub(crate) async fn current_revision( Ok(Some((m, r.content_hash))) } +/// The local playlist behind a share was deleted, so the share goes with it: +/// the pointer is withdrawn while we can still reach it, and the ledger row +/// is dropped. +/// +/// Dropping it is the point. The row is keyed by the LOCAL playlist id, which +/// SQLite hands to the next playlist created — and `publish` reuses the uuid +/// it finds under that key. Left behind, the next playlist would silently go +/// out as this one: same uuid, same proposal board, next revision, entirely +/// different tracks. +/// +/// Best-effort on the network half. A withdraw that cannot be sent leaves the +/// last revision fetchable, which is true of every revision ever published — +/// 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 { + // 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 { + log::warn!( + "shared playlists: deleting {:?} could not withdraw its pointer ({e}) — the last revision stays fetchable", + rec.name + ); + } + } + let _guard = ledger_lock::hold(LEDGER_FILE).await; + let mut ledger = load(app); + ledger.published.remove(&key); + match save(app, &ledger) { + Ok(()) => log::info!("shared playlists: dropped the share record for deleted playlist {playlist_id} ({:?})", rec.name), + // 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}"), + } +} + +/// Drop share records whose local playlist is no longer here. Fixing +/// `delete_playlist` only helps deletions from here on — a ledger that +/// already lost its playlist (an older build, or a row removed while the app +/// was down) still holds a key SQLite is free to hand out again. Runs once at +/// startup, before anything can create a playlist. +pub(crate) async fn reconcile_deleted(app: &tauri::AppHandle) { + let lib = app.state::(); + let l = lib.library.clone(); + let alive = match tauri::async_runtime::spawn_blocking(move || l.list_playlists()).await { + Ok(Ok(rows)) => rows.into_iter().map(|p| p.id).collect::>(), + // Never guess here: an unreadable library would look like "every + // playlist was deleted" and take every share record with it. + other => { + log::warn!("shared playlists: could not read the library to reconcile share records ({other:?})"); + return; + } + }; + let orphans: Vec = load(app) + .published + .values() + .map(|p| p.playlist_id) + .filter(|id| !alive.contains(id)) + .collect(); + for id in orphans { + forget_playlist(app, id).await; + } +} + /// The local playlist behind a published uuid, when this device published it. pub(crate) fn published_for_uuid(app: &tauri::AppHandle, uuid_b58: &str) -> Option { load(app).published.into_values().find(|p| p.uuid == uuid_b58)