playlists(#37): fetch/publish facade over the live client

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-06 12:51:43 -04:00
1 parent e5064eeb87
commit 0caf468c36
2 files changed
+294

No files matched your search

+156
View File
@@ -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<Array>} 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<Object|null>}
*/
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<Object>} 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,
};
+138
View File
@@ -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");
});