mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
e5064eeb87
commit
0caf468c36
2 files changed
+294
No files matched your search
@@ -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,
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user