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:
KeelrLeek committed 2026-09-06 08:16:45 -05:00
1 parent df06f849e0
commit f67a0a30b4
6 files changed
+713 -49

No files matched your search

+5
View File
@@ -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");
+86 -6
View File
@@ -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 };