From 0caf468c36eb0ff73b06c4ae84cbb0eddd1cc895 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:51:43 -0400 Subject: [PATCH] playlists(#37): fetch/publish facade over the live client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Network wiring on top of the pure playlistService core: - fetchCreatorPlaylists(pubkey) — lists kind-30082 by author via the L1 SubscriptionManager, collapses to newest-per-`d` (NIP-33 replaceable), drops empty playlists from the listing (a cleared playlist republishes empty), sorts newest-first. - fetchPlaylist(pubkey, id) — newest matching event, parsed. - publishPlaylist(playlist) — build + sign + publish via nostrClient.signAndPublishEvent (active signer), replacing the same `d`. All entry points take an injectable client for hermetic testing. tests/playlist-facade.test.mjs (5): newest-per-id + empty/other-author drop + sort, includeEmpty, blank/no-relay guards, fetch-one newest, publish emits a kind-30082 event with the right d/title/ordered a-refs under the "playlist" context. Mock client; no relays/signer. Lint clean; 5/5. Co-Authored-By: Claude Opus 4.8 --- js/playlists/playlistFacade.js | 156 +++++++++++++++++++++++++++++++++ tests/playlist-facade.test.mjs | 138 +++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 js/playlists/playlistFacade.js create mode 100644 tests/playlist-facade.test.mjs diff --git a/js/playlists/playlistFacade.js b/js/playlists/playlistFacade.js new file mode 100644 index 00000000..fcf506b5 --- /dev/null +++ b/js/playlists/playlistFacade.js @@ -0,0 +1,156 @@ +// js/playlists/playlistFacade.js +// +// Network wiring for creator playlists (#37): fetch a creator's playlists / one +// playlist, and publish (build + sign + publish) a playlist. Kept apart from the +// pure playlistService core so that core stays hermetic. Reads route through the +// L1 SubscriptionManager (relay caps + in-flight dedupe), matching the rest of +// bitvid's read paths; writes go through nostrClient.signAndPublishEvent (active +// signer). All entry points take an injectable `client` for testing. + +import { nostrClient } from "../nostrClientFacade.js"; +import { + PLAYLIST_KIND, + parsePlaylistEvent, + buildPlaylist, +} from "./playlistService.js"; +import { devLogger } from "../utils/logger.js"; + +const LIST_LIMIT = 200; + +function getManagerAndRelays(client) { + const manager = + typeof client?.getSubscriptionManager === "function" + ? client.getSubscriptionManager() + : null; + const relays = Array.isArray(client?.relays) ? client.relays : []; + return { manager, relays }; +} + +// Collapse to the newest event per `d` tag (NIP-33 replaceable): a flaky relay +// list can return an old + new copy of the same playlist, so keep only the +// freshest per id. +function newestPerDTag(events) { + const byD = new Map(); + for (const event of Array.isArray(events) ? events : []) { + if (!event || !Array.isArray(event.tags)) { + continue; + } + const dTag = event.tags.find( + (t) => Array.isArray(t) && t[0] === "d" && typeof t[1] === "string", + ); + const id = dTag ? dTag[1] : ""; + if (!id) { + continue; + } + const existing = byD.get(id); + if ( + !existing || + (Number(event.created_at) || 0) > (Number(existing.created_at) || 0) + ) { + byD.set(id, event); + } + } + return [...byD.values()]; +} + +/** + * Fetch a creator's playlists, parsed, newest-per-id, sorted newest-first. + * Empty playlists are dropped from the listing by default (a cleared playlist + * republishes as an empty replaceable event) — pass includeEmpty to keep them. + * @param {string} pubkey + * @param {{ client?: any, includeEmpty?: boolean }} [opts] + * @returns {Promise} parsed playlists + */ +export async function fetchCreatorPlaylists( + pubkey, + { client = nostrClient, includeEmpty = false } = {}, +) { + const author = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!author) { + return []; + } + const { manager, relays } = getManagerAndRelays(client); + if (!manager || typeof manager.list !== "function" || !relays.length) { + return []; + } + + let events = []; + try { + events = await manager.list({ + relays, + filters: [{ kinds: [PLAYLIST_KIND], authors: [author], limit: LIST_LIMIT }], + }); + } catch (error) { + devLogger.warn("[playlists] Failed to list creator playlists:", error); + return []; + } + + return newestPerDTag(events) + .map(parsePlaylistEvent) + .filter(Boolean) + .filter((playlist) => includeEmpty || playlist.items.length > 0) + .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); +} + +/** + * Fetch a single playlist by (pubkey, id). Returns the newest matching event + * parsed, or null. + * @param {string} pubkey + * @param {string} id + * @param {{ client?: any }} [opts] + * @returns {Promise} + */ +export async function fetchPlaylist(pubkey, id, { client = nostrClient } = {}) { + const author = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + const dTag = typeof id === "string" ? id.trim() : ""; + if (!author || !dTag) { + return null; + } + const { manager, relays } = getManagerAndRelays(client); + if (!manager || typeof manager.list !== "function" || !relays.length) { + return null; + } + + let events = []; + try { + events = await manager.list({ + relays, + filters: [ + { + kinds: [PLAYLIST_KIND], + authors: [author], + "#d": [dTag], + limit: 20, + }, + ], + }); + } catch (error) { + devLogger.warn("[playlists] Failed to fetch playlist:", error); + return null; + } + + const newest = newestPerDTag(events)[0]; + return newest ? parsePlaylistEvent(newest) : null; +} + +/** + * Build + sign + publish a playlist (replaces the same `d` on relays, NIP-33). + * @param {Object} playlist { pubkey, id, title, description, image, items } + * @param {{ client?: any }} [opts] + * @returns {Promise} the signed event + */ +export async function publishPlaylist(playlist, { client = nostrClient } = {}) { + const event = buildPlaylist(playlist); + const result = await client.signAndPublishEvent(event, { + context: "playlist", + }); + return result?.signedEvent || null; +} + +export default { + fetchCreatorPlaylists, + fetchPlaylist, + publishPlaylist, +}; diff --git a/tests/playlist-facade.test.mjs b/tests/playlist-facade.test.mjs new file mode 100644 index 00000000..3e255826 --- /dev/null +++ b/tests/playlist-facade.test.mjs @@ -0,0 +1,138 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + fetchCreatorPlaylists, + fetchPlaylist, + publishPlaylist, +} from "../js/playlists/playlistFacade.js"; +import { + buildPlaylistEvent, + PLAYLIST_KIND, +} from "../js/nostrEventSchemas.js"; + +const AUTHOR = "a".repeat(64); +const OTHER = "c".repeat(64); +const coord = (d) => `30078:${"b".repeat(64)}:${d}`; + +function playlistEvent({ pubkey = AUTHOR, id, title, coords = [], created_at }) { + const event = buildPlaylistEvent({ + pubkey, + created_at, + dTagValue: id, + title, + videoCoordinates: coords, + }); + event.id = `id-${id}-${created_at}`; + return event; +} + +// Mock client: a subscription manager whose list() filters an in-memory event +// set by kinds/authors/#d, plus a signAndPublishEvent that captures the event. +function mockClient({ events = [], onPublish } = {}) { + return { + relays: ["wss://mock.relay"], + getSubscriptionManager: () => ({ + list: async ({ filters }) => { + const f = filters[0] || {}; + return events.filter((ev) => { + if (f.kinds && !f.kinds.includes(ev.kind)) return false; + if (f.authors && !f.authors.includes(ev.pubkey)) return false; + if (f["#d"]) { + const d = ev.tags.find((t) => t[0] === "d")?.[1]; + if (!f["#d"].includes(d)) return false; + } + return true; + }); + }, + }), + signAndPublishEvent: async (event, options) => { + if (onPublish) onPublish(event, options); + return { signedEvent: { ...event, id: "signed-id", sig: "sig" } }; + }, + }; +} + +test("fetchCreatorPlaylists returns newest-per-id, drops empties, sorts newest-first", async () => { + const events = [ + // Same id "a" published twice — the newer (created_at 200) must win. + playlistEvent({ id: "a", title: "A old", coords: [coord("v1")], created_at: 100 }), + playlistEvent({ id: "a", title: "A new", coords: [coord("v1"), coord("v2")], created_at: 200 }), + playlistEvent({ id: "b", title: "B", coords: [coord("v9")], created_at: 300 }), + // Empty playlist — dropped from the listing by default. + playlistEvent({ id: "empty", title: "Empty", coords: [], created_at: 400 }), + // Another author's playlist — must not appear. + playlistEvent({ pubkey: OTHER, id: "x", title: "Other", coords: [coord("v0")], created_at: 500 }), + ]; + + const playlists = await fetchCreatorPlaylists(AUTHOR, { client: mockClient({ events }) }); + + assert.deepEqual(playlists.map((p) => p.id), ["b", "a"], "sorted newest-first, empties + other author dropped"); + const a = playlists.find((p) => p.id === "a"); + assert.equal(a.title, "A new", "newest copy wins"); + assert.equal(a.items.length, 2); +}); + +test("fetchCreatorPlaylists can include empty playlists when asked", async () => { + const events = [playlistEvent({ id: "empty", title: "Empty", coords: [], created_at: 1 })]; + const playlists = await fetchCreatorPlaylists(AUTHOR, { + client: mockClient({ events }), + includeEmpty: true, + }); + assert.deepEqual(playlists.map((p) => p.id), ["empty"]); +}); + +test("fetchCreatorPlaylists returns [] for blank pubkey or no relays", async () => { + assert.deepEqual(await fetchCreatorPlaylists("", { client: mockClient({}) }), []); + const noRelays = { ...mockClient({}), relays: [] }; + assert.deepEqual(await fetchCreatorPlaylists(AUTHOR, { client: noRelays }), []); +}); + +test("fetchPlaylist returns the newest matching playlist parsed", async () => { + const events = [ + playlistEvent({ id: "list", title: "old", coords: [coord("v1")], created_at: 10 }), + playlistEvent({ id: "list", title: "new", coords: [coord("v1"), coord("v2")], created_at: 20 }), + playlistEvent({ id: "other", title: "nope", coords: [coord("v3")], created_at: 99 }), + ]; + const parsed = await fetchPlaylist(AUTHOR, "list", { client: mockClient({ events }) }); + assert.equal(parsed.title, "new"); + assert.equal(parsed.id, "list"); + assert.equal(parsed.items.length, 2); + + assert.equal(await fetchPlaylist(AUTHOR, "missing", { client: mockClient({ events }) }), null); +}); + +test("publishPlaylist builds a kind-30082 event and publishes with the playlist context", async () => { + let published = null; + let ctx = null; + const client = mockClient({ + onPublish: (event, options) => { + published = event; + ctx = options?.context; + }, + }); + + const signed = await publishPlaylist( + { + pubkey: AUTHOR, + id: "new-list", + title: "Fresh", + items: [ + { type: "a", value: coord("v1") }, + { type: "a", value: coord("v2") }, + ], + created_at: 777, + }, + { client }, + ); + + assert.equal(published.kind, PLAYLIST_KIND); + assert.equal(ctx, "playlist"); + assert.equal(published.tags.find((t) => t[0] === "d")?.[1], "new-list"); + assert.equal(published.tags.find((t) => t[0] === "title")?.[1], "Fresh"); + assert.deepEqual( + published.tags.filter((t) => t[0] === "a").map((t) => t[1]), + [coord("v1"), coord("v2")], + ); + assert.equal(signed.id, "signed-id", "returns the signed event"); +});