shared playlists: a deleted playlist takes its share with it, so the next one does not go out wearing its name

This commit is contained in:
KeelrLeek committed 2026-09-04 11:06:34 -05:00
1 parent 0ff65fd9b5
commit 4ca9ab043b
3 files changed
+78 -1

No files matched your search

+8
View File
@@ -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(())
})
+4
View File
@@ -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
+66 -1
View File
@@ -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::<LibraryState>();
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::<BTreeSet<i64>>(),
// 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<i64> = 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<Published> {
load(app).published.into_values().find(|p| p.uuid == uuid_b58)