mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
playlists(#37): foundation — kind-30082 schema, builder, pure service + tests
First slice of creator playlists. Adds a bitvid-native addressable playlist event and the pure, hermetic core that builds/parses/mutates it. No UI or network yet — those land in follow-up commits. - nostrEventSchemas.js: PLAYLIST_KIND=30082 (+ NOTE_TYPES.PLAYLIST, BASE_SCHEMAS entry) and buildPlaylistEvent(). Data lives in tags: d (id), title/description/ image, and ordered `a` refs (30078:pubkey:d) to videos; order = tag order; repeated refs deduped. Deliberately NOT NIP-51's 30005 "video curation set" — bitvid already reads 30005 as legacy interest-sets, so reuse would collide. - js/playlists/playlistService.js: parsePlaylistEvent, buildPlaylist, and immutable add/remove/reorder item helpers + videoCoordinate() (reuses the canonical buildVideoAddressPointer, same 30078 contract as the zap fix) + generatePlaylistId(). Pure — fully unit-testable with no relays/signer. - tests/playlist-service.test.mjs: 12 scenarios (build tags + order + dedup, d-tag required, parse structure/rejects/defaults, build→parse round-trip, add/remove/reorder immutability + clamping, coordinate derivation incl. the kind-0 internal-object case). - docs/nostr-event-schemas.md: catalogue row for the new kind. Schema validation, lint, and build all pass; tests 12/12. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
a36d57c8b4
commit
e5064eeb87
4 files changed
+587
No files matched your search
@@ -197,6 +197,7 @@ Video posts should treat `videoRootId` as the stable series identifier that rema
|
||||
| Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', <recipient pubkey>]` plus optional `['e', <event id>]`, `['a', <coordinate>]`, `['amount', <msats>]`, `['lnurl', <bech32>]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note |
|
||||
| Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', <invoice>]`, `['description', <zap request JSON>]`, `['p', <recipient pubkey>]` plus optional `['e', <event id>]` and `['a', <coordinate>]` | Empty content; receipts are published by the recipient's LNURL server |
|
||||
| Zap tally (`NOTE_TYPES.ZAP_TALLY`) | `30081` (addressable, `d = payment_hash`) | `['d', <payment_hash>]`, `['p', <recipient>]`, optional `['e', <event id>]`/`['a', <coordinate>]`, `['amount', <msats>]`, `['bolt11', <invoice>]`, `['preimage', <hex>]`, `['description', <zap request JSON>]`, `['client', 'bitvid']`, `['t', 'zap']` | bitvid-native, payer-signed, **preimage-verified** stand-in for a 9735 when the recipient's wallet doesn't publish one. Verified via `sha256(preimage)==payment_hash` + `description_hash==sha256(zapRequest)`. Counted alongside 9735s (deduped by `payment_hash`). See `docs/zap-tally-plan.md`. |
|
||||
| Playlist (`NOTE_TYPES.PLAYLIST`) | `PLAYLIST_KIND` (`30082`, addressable, `d = playlist id`) | `['t','playlist']`, `['d', <playlist id>]`, optional `['title', ...]`/`['description', ...]`/`['image', ...]`, then **ordered** `['a', '30078:pubkey:d']` refs per video (`['e', <event id>]` also accepted). Item order = tag order. | Creator-curated video playlist (#37). bitvid-native — deliberately NOT NIP-51's `30005` "video curation set", which bitvid already reads as legacy interest-sets (would collide). Built/parsed by `js/playlists/playlistService.js`. |
|
||||
| HTTP Auth (`NOTE_TYPES.HTTP_AUTH`) | `27235` | `['u', <url>]`, `['method', <http method>]`, `['payload', <payload hash>]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) |
|
||||
| Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', <event id>, <report type>]`, `['p', <pubkey>, <report type>]`, and optional `['t', <report type>]` | Plaintext report reason |
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export const NOTE_TYPES = Object.freeze({
|
||||
ZAP_REQUEST: "zapRequest",
|
||||
ZAP_RECEIPT: "zapReceipt",
|
||||
ZAP_TALLY: "zapTally",
|
||||
PLAYLIST: "playlist",
|
||||
WATCH_HISTORY: "watchHistory",
|
||||
SUBSCRIPTION_LIST: "subscriptionList",
|
||||
SUBSCRIPTION_BACKUP: "subscriptionBackup",
|
||||
@@ -98,6 +99,14 @@ const DEFAULT_APPEND_TAGS = [];
|
||||
// (30078 video, 30079 view). See docs/zap-tally-plan.md.
|
||||
export const ZAP_TALLY_KIND = 30081;
|
||||
|
||||
// bitvid-native creator playlist (#37). Addressable (30000–39999) so `d =
|
||||
// playlist id` yields one canonical, replaceable event per playlist. In bitvid's
|
||||
// family (30078 video, 30079 view, 30081 zap tally). Deliberately NOT NIP-51's
|
||||
// 30005 "video curation set": bitvid already reads 30005 as legacy interest-sets
|
||||
// (hashtagPreferencesService), so reusing it would collide. Ordered `a` tags
|
||||
// (30078:pubkey:d) reference the videos; order = tag order.
|
||||
export const PLAYLIST_KIND = 30082;
|
||||
|
||||
let cachedUtf8Encoder = null;
|
||||
|
||||
const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]);
|
||||
@@ -339,6 +348,19 @@ const BASE_SCHEMAS = {
|
||||
],
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.PLAYLIST]: {
|
||||
type: NOTE_TYPES.PLAYLIST,
|
||||
label: "Playlist",
|
||||
kind: PLAYLIST_KIND,
|
||||
topicTag: { name: "t", value: "playlist" },
|
||||
identifierTag: { name: "d" },
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description:
|
||||
"Reserved for future notes; playlist data lives in tags (title/description/image + ordered `a` video refs).",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.VIDEO_MIRROR]: {
|
||||
type: NOTE_TYPES.VIDEO_MIRROR,
|
||||
label: "NIP-94 mirror",
|
||||
@@ -1423,6 +1445,102 @@ export function buildVideoMirrorEvent(params) {
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a bitvid Playlist event (Kind 30082, addressable). All playlist data
|
||||
* lives in tags: `d` (id), `title`, `description`, `image`, and ordered `a`
|
||||
* refs (`30078:pubkey:d`) to the videos — order is preserved as tag order.
|
||||
* Republishing with the same `d` replaces the playlist (NIP-33).
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.pubkey
|
||||
* @param {number} params.created_at
|
||||
* @param {string} params.dTagValue stable playlist id (required)
|
||||
* @param {string} [params.title]
|
||||
* @param {string} [params.description]
|
||||
* @param {string} [params.image]
|
||||
* @param {string[]} [params.videoCoordinates] ordered "30078:pubkey:d" strings (`a`)
|
||||
* @param {string[]} [params.eventRefs] optional ordered raw event ids (`e`)
|
||||
* @param {string} [params.content]
|
||||
* @returns {Object} Unsigned event.
|
||||
*/
|
||||
export function buildPlaylistEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
dTagValue,
|
||||
title = "",
|
||||
description = "",
|
||||
image = "",
|
||||
videoCoordinates = [],
|
||||
eventRefs = [],
|
||||
content = "",
|
||||
} = params || {};
|
||||
|
||||
const identifier = typeof dTagValue === "string" ? dTagValue.trim() : "";
|
||||
if (!identifier) {
|
||||
throw new Error("A playlist requires a d-tag identifier.");
|
||||
}
|
||||
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.PLAYLIST);
|
||||
const tags = [];
|
||||
|
||||
if (schema?.topicTag?.name && schema?.topicTag?.value) {
|
||||
tags.push([schema.topicTag.name, schema.topicTag.value]);
|
||||
}
|
||||
tags.push([schema?.identifierTag?.name || "d", identifier]);
|
||||
|
||||
const trimmedTitle = typeof title === "string" ? title.trim() : "";
|
||||
if (trimmedTitle) {
|
||||
tags.push(["title", trimmedTitle]);
|
||||
}
|
||||
const trimmedDescription =
|
||||
typeof description === "string" ? description.trim() : "";
|
||||
if (trimmedDescription) {
|
||||
tags.push(["description", trimmedDescription]);
|
||||
}
|
||||
const trimmedImage = typeof image === "string" ? image.trim() : "";
|
||||
if (trimmedImage) {
|
||||
tags.push(["image", trimmedImage]);
|
||||
}
|
||||
|
||||
// Ordered video refs; dedupe so the same video can't appear twice.
|
||||
const seen = new Set();
|
||||
if (Array.isArray(videoCoordinates)) {
|
||||
videoCoordinates.forEach((coord) => {
|
||||
const value = typeof coord === "string" ? coord.trim() : "";
|
||||
if (value && !seen.has(`a:${value}`)) {
|
||||
seen.add(`a:${value}`);
|
||||
tags.push(["a", value]);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (Array.isArray(eventRefs)) {
|
||||
eventRefs.forEach((ref) => {
|
||||
const value = typeof ref === "string" ? ref.trim() : "";
|
||||
if (value && !seen.has(`e:${value}`)) {
|
||||
seen.add(`e:${value}`);
|
||||
tags.push(["e", value]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
const event = {
|
||||
kind: schema?.kind ?? PLAYLIST_KIND,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: ensureValidUtf8Content(content),
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
validateEventAgainstSchema(NOTE_TYPES.PLAYLIST, event);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Repost (Kind 6) or Generic Repost (Kind 16) event.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// js/playlists/playlistService.js
|
||||
//
|
||||
// Creator playlists (#37) — pure core. A playlist is a bitvid-native addressable
|
||||
// event (kind PLAYLIST_KIND=30082): all data lives in tags (d/title/description/
|
||||
// image + ordered `a` refs to videos). This module holds the pure, hermetic
|
||||
// logic — parse an event into a structured playlist, build a signable event from
|
||||
// one, and immutably add/remove/reorder items. Network I/O (fetch a creator's
|
||||
// playlists, publish) lives in the facade that wires this to the live client, so
|
||||
// this core stays fully unit-testable with no relays or signer.
|
||||
|
||||
import {
|
||||
buildPlaylistEvent,
|
||||
PLAYLIST_KIND,
|
||||
} from "../nostrEventSchemas.js";
|
||||
import { buildVideoAddressPointer } from "../utils/videoPointer.js";
|
||||
|
||||
export { PLAYLIST_KIND };
|
||||
|
||||
function firstTagValue(tags, name) {
|
||||
const tag = tags.find(
|
||||
(t) => Array.isArray(t) && t[0] === name && typeof t[1] === "string",
|
||||
);
|
||||
return tag ? tag[1] : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* A structured playlist item references one video, either by addressable
|
||||
* coordinate (`a` → "30078:pubkey:d") or raw event id (`e`).
|
||||
* @typedef {{ type: "a" | "e", value: string }} PlaylistItem
|
||||
*/
|
||||
|
||||
export function playlistItemKey(item) {
|
||||
return item && item.type && typeof item.value === "string" && item.value
|
||||
? `${item.type}:${item.value}`
|
||||
: "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a kind-30082 event into a structured playlist. Returns null for a
|
||||
* non-playlist event or one missing a `d` identifier. Item order follows tag
|
||||
* order; duplicate refs are collapsed.
|
||||
* @param {Object} event
|
||||
* @returns {null | {
|
||||
* id: string, pubkey: string, title: string, description: string,
|
||||
* image: string, items: PlaylistItem[], updatedAt: number,
|
||||
* address: string, eventId: string,
|
||||
* }}
|
||||
*/
|
||||
export function parsePlaylistEvent(event) {
|
||||
if (!event || typeof event !== "object" || !Array.isArray(event.tags)) {
|
||||
return null;
|
||||
}
|
||||
if (Number.isFinite(event.kind) && event.kind !== PLAYLIST_KIND) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tags = event.tags;
|
||||
const id = firstTagValue(tags, "d").trim();
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = [];
|
||||
const seen = new Set();
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || (tag[0] !== "a" && tag[0] !== "e")) {
|
||||
continue;
|
||||
}
|
||||
if (typeof tag[1] !== "string") {
|
||||
continue;
|
||||
}
|
||||
const value = tag[1].trim();
|
||||
const key = `${tag[0]}:${value}`;
|
||||
if (value && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
items.push({ type: tag[0], value });
|
||||
}
|
||||
}
|
||||
|
||||
const pubkey =
|
||||
typeof event.pubkey === "string" ? event.pubkey.trim().toLowerCase() : "";
|
||||
|
||||
return {
|
||||
id,
|
||||
pubkey,
|
||||
title: firstTagValue(tags, "title").trim() || "Untitled playlist",
|
||||
description: firstTagValue(tags, "description").trim(),
|
||||
image: firstTagValue(tags, "image").trim(),
|
||||
items,
|
||||
updatedAt: Number.isFinite(event.created_at) ? event.created_at : 0,
|
||||
address: `${PLAYLIST_KIND}:${pubkey}:${id}`,
|
||||
eventId: typeof event.id === "string" ? event.id : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably add a video (by addressable coordinate) to an item list. Deduped —
|
||||
* a video already present is a no-op. Appends unless a valid `position` is given.
|
||||
* @param {PlaylistItem[]} items
|
||||
* @param {string} coordinate "30078:pubkey:d"
|
||||
* @param {{ position?: number }} [opts]
|
||||
* @returns {PlaylistItem[]} a new array
|
||||
*/
|
||||
export function addVideoToPlaylist(items, coordinate, { position } = {}) {
|
||||
const list = Array.isArray(items) ? items.slice() : [];
|
||||
const value = typeof coordinate === "string" ? coordinate.trim() : "";
|
||||
if (!value) {
|
||||
return list;
|
||||
}
|
||||
const item = { type: "a", value };
|
||||
const key = playlistItemKey(item);
|
||||
if (list.some((existing) => playlistItemKey(existing) === key)) {
|
||||
return list;
|
||||
}
|
||||
if (Number.isInteger(position) && position >= 0 && position < list.length) {
|
||||
list.splice(position, 0, item);
|
||||
} else {
|
||||
list.push(item);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably remove a video from an item list, by coordinate string (treated as
|
||||
* an `a` ref) or by a {type,value} item.
|
||||
* @param {PlaylistItem[]} items
|
||||
* @param {string | PlaylistItem} coordinateOrItem
|
||||
* @returns {PlaylistItem[]} a new array
|
||||
*/
|
||||
export function removeVideoFromPlaylist(items, coordinateOrItem) {
|
||||
const list = Array.isArray(items) ? items.slice() : [];
|
||||
const key =
|
||||
typeof coordinateOrItem === "string"
|
||||
? `a:${coordinateOrItem.trim()}`
|
||||
: playlistItemKey(coordinateOrItem);
|
||||
if (!key) {
|
||||
return list;
|
||||
}
|
||||
return list.filter((item) => playlistItemKey(item) !== key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably move an item from one index to another (drag-to-reorder). Out-of-
|
||||
* range indices are ignored / clamped; returns a new array.
|
||||
* @param {PlaylistItem[]} items
|
||||
* @param {number} fromIndex
|
||||
* @param {number} toIndex
|
||||
* @returns {PlaylistItem[]}
|
||||
*/
|
||||
export function reorderPlaylistItems(items, fromIndex, toIndex) {
|
||||
const list = Array.isArray(items) ? items.slice() : [];
|
||||
if (!Number.isInteger(fromIndex) || !Number.isInteger(toIndex)) {
|
||||
return list;
|
||||
}
|
||||
if (fromIndex < 0 || fromIndex >= list.length) {
|
||||
return list;
|
||||
}
|
||||
const clampedTo = Math.max(0, Math.min(toIndex, list.length - 1));
|
||||
if (clampedTo === fromIndex) {
|
||||
return list;
|
||||
}
|
||||
const [moved] = list.splice(fromIndex, 1);
|
||||
list.splice(clampedTo, 0, moved);
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* The addressable coordinate ("30078:pubkey:d") for a video object/event, using
|
||||
* the app's canonical derivation (defaults invalid/missing kinds to 30078).
|
||||
* @param {Object} video
|
||||
* @returns {string} coordinate, or "" if it can't be derived
|
||||
*/
|
||||
export function videoCoordinate(video) {
|
||||
return buildVideoAddressPointer(video);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an unsigned, signable playlist event from a structured playlist.
|
||||
* @param {{
|
||||
* pubkey: string, id: string, title?: string, description?: string,
|
||||
* image?: string, items?: PlaylistItem[], content?: string, created_at?: number,
|
||||
* }} playlist
|
||||
* @returns {Object} unsigned kind-30082 event
|
||||
*/
|
||||
export function buildPlaylist({
|
||||
pubkey,
|
||||
id,
|
||||
title = "",
|
||||
description = "",
|
||||
image = "",
|
||||
items = [],
|
||||
content = "",
|
||||
created_at,
|
||||
} = {}) {
|
||||
const videoCoordinates = [];
|
||||
const eventRefs = [];
|
||||
if (Array.isArray(items)) {
|
||||
for (const item of items) {
|
||||
if (!item || typeof item.value !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (item.type === "a") {
|
||||
videoCoordinates.push(item.value);
|
||||
} else if (item.type === "e") {
|
||||
eventRefs.push(item.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return buildPlaylistEvent({
|
||||
pubkey,
|
||||
created_at: Number.isFinite(created_at)
|
||||
? created_at
|
||||
: Math.floor(Date.now() / 1000),
|
||||
dTagValue: id,
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
videoCoordinates,
|
||||
eventRefs,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a stable-ish, collision-resistant playlist id for a new playlist's
|
||||
* `d` tag (time-prefixed + random suffix, lowercase, URL/tag-safe).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generatePlaylistId() {
|
||||
const time = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).slice(2, 10);
|
||||
return `pl-${time}-${rand}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
PLAYLIST_KIND,
|
||||
parsePlaylistEvent,
|
||||
playlistItemKey,
|
||||
addVideoToPlaylist,
|
||||
removeVideoFromPlaylist,
|
||||
reorderPlaylistItems,
|
||||
videoCoordinate,
|
||||
buildPlaylist,
|
||||
generatePlaylistId,
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
PLAYLIST_KIND,
|
||||
parsePlaylistEvent,
|
||||
addVideoToPlaylist,
|
||||
removeVideoFromPlaylist,
|
||||
reorderPlaylistItems,
|
||||
videoCoordinate,
|
||||
buildPlaylist,
|
||||
generatePlaylistId,
|
||||
playlistItemKey,
|
||||
} from "../js/playlists/playlistService.js";
|
||||
import { buildPlaylistEvent } from "../js/nostrEventSchemas.js";
|
||||
|
||||
const PUBKEY = "a".repeat(64);
|
||||
const AUTHOR = "b".repeat(64);
|
||||
const coord = (dtag, author = AUTHOR) => `30078:${author}:${dtag}`;
|
||||
|
||||
test("buildPlaylistEvent emits kind, identifier, metadata, and ordered a-refs", () => {
|
||||
const event = buildPlaylistEvent({
|
||||
pubkey: PUBKEY,
|
||||
created_at: 1000,
|
||||
dTagValue: "my-list",
|
||||
title: "My List",
|
||||
description: "cool clips",
|
||||
image: "https://img.example/x.jpg",
|
||||
videoCoordinates: [coord("v1"), coord("v2"), coord("v3")],
|
||||
});
|
||||
|
||||
assert.equal(event.kind, PLAYLIST_KIND);
|
||||
assert.equal(event.tags.find((t) => t[0] === "d")?.[1], "my-list");
|
||||
assert.equal(event.tags.find((t) => t[0] === "t")?.[1], "playlist");
|
||||
assert.equal(event.tags.find((t) => t[0] === "title")?.[1], "My List");
|
||||
assert.equal(event.tags.find((t) => t[0] === "description")?.[1], "cool clips");
|
||||
assert.equal(
|
||||
event.tags.find((t) => t[0] === "image")?.[1],
|
||||
"https://img.example/x.jpg",
|
||||
);
|
||||
|
||||
// Ordered a-refs, in the given order.
|
||||
const aRefs = event.tags.filter((t) => t[0] === "a").map((t) => t[1]);
|
||||
assert.deepEqual(aRefs, [coord("v1"), coord("v2"), coord("v3")]);
|
||||
});
|
||||
|
||||
test("buildPlaylistEvent dedupes repeated video coordinates, preserving first order", () => {
|
||||
const event = buildPlaylistEvent({
|
||||
pubkey: PUBKEY,
|
||||
created_at: 1000,
|
||||
dTagValue: "d1",
|
||||
videoCoordinates: [coord("v1"), coord("v2"), coord("v1"), coord("v3")],
|
||||
});
|
||||
const aRefs = event.tags.filter((t) => t[0] === "a").map((t) => t[1]);
|
||||
assert.deepEqual(aRefs, [coord("v1"), coord("v2"), coord("v3")]);
|
||||
});
|
||||
|
||||
test("buildPlaylistEvent requires a d-tag identifier", () => {
|
||||
assert.throws(
|
||||
() => buildPlaylistEvent({ pubkey: PUBKEY, created_at: 1, dTagValue: " " }),
|
||||
/requires a d-tag/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("parsePlaylistEvent structures a playlist and preserves item order", () => {
|
||||
const event = buildPlaylistEvent({
|
||||
pubkey: PUBKEY,
|
||||
created_at: 4242,
|
||||
dTagValue: "list-7",
|
||||
title: "Seven",
|
||||
videoCoordinates: [coord("a"), coord("b")],
|
||||
eventRefs: ["e".repeat(64)],
|
||||
});
|
||||
event.id = "event-id-123";
|
||||
|
||||
const parsed = parsePlaylistEvent(event);
|
||||
assert.equal(parsed.id, "list-7");
|
||||
assert.equal(parsed.pubkey, PUBKEY);
|
||||
assert.equal(parsed.title, "Seven");
|
||||
assert.equal(parsed.updatedAt, 4242);
|
||||
assert.equal(parsed.address, `${PLAYLIST_KIND}:${PUBKEY}:list-7`);
|
||||
assert.equal(parsed.eventId, "event-id-123");
|
||||
assert.deepEqual(parsed.items, [
|
||||
{ type: "a", value: coord("a") },
|
||||
{ type: "a", value: coord("b") },
|
||||
{ type: "e", value: "e".repeat(64) },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parsePlaylistEvent rejects the wrong kind and a missing d-tag", () => {
|
||||
assert.equal(
|
||||
parsePlaylistEvent({ kind: 30078, tags: [["d", "x"]] }),
|
||||
null,
|
||||
"wrong kind → null",
|
||||
);
|
||||
assert.equal(
|
||||
parsePlaylistEvent({ kind: PLAYLIST_KIND, tags: [["title", "no id"]] }),
|
||||
null,
|
||||
"missing d → null",
|
||||
);
|
||||
assert.equal(parsePlaylistEvent(null), null);
|
||||
});
|
||||
|
||||
test("parsePlaylistEvent defaults an empty title", () => {
|
||||
const parsed = parsePlaylistEvent({
|
||||
kind: PLAYLIST_KIND,
|
||||
pubkey: PUBKEY,
|
||||
created_at: 1,
|
||||
tags: [["d", "no-title"]],
|
||||
});
|
||||
assert.equal(parsed.title, "Untitled playlist");
|
||||
});
|
||||
|
||||
test("build → parse round-trips a full playlist (order intact)", () => {
|
||||
const items = [
|
||||
{ type: "a", value: coord("v1") },
|
||||
{ type: "a", value: coord("v2") },
|
||||
{ type: "a", value: coord("v3") },
|
||||
];
|
||||
const event = buildPlaylist({
|
||||
pubkey: PUBKEY,
|
||||
id: "rt",
|
||||
title: "Round Trip",
|
||||
description: "desc",
|
||||
items,
|
||||
created_at: 55,
|
||||
});
|
||||
const parsed = parsePlaylistEvent({ ...event, id: "signed-id" });
|
||||
assert.equal(parsed.title, "Round Trip");
|
||||
assert.equal(parsed.description, "desc");
|
||||
assert.deepEqual(parsed.items, items);
|
||||
});
|
||||
|
||||
test("addVideoToPlaylist appends, dedupes, inserts by position, and is immutable", () => {
|
||||
const base = [{ type: "a", value: coord("v1") }];
|
||||
|
||||
const appended = addVideoToPlaylist(base, coord("v2"));
|
||||
assert.deepEqual(appended.map((i) => i.value), [coord("v1"), coord("v2")]);
|
||||
assert.deepEqual(base.map((i) => i.value), [coord("v1")], "input not mutated");
|
||||
|
||||
const deduped = addVideoToPlaylist(appended, coord("v1"));
|
||||
assert.equal(deduped.length, 2, "existing video is a no-op");
|
||||
|
||||
const inserted = addVideoToPlaylist(appended, coord("v3"), { position: 1 });
|
||||
assert.deepEqual(inserted.map((i) => i.value), [
|
||||
coord("v1"),
|
||||
coord("v3"),
|
||||
coord("v2"),
|
||||
]);
|
||||
|
||||
assert.deepEqual(addVideoToPlaylist(base, " "), base.slice(), "blank ignored");
|
||||
});
|
||||
|
||||
test("removeVideoFromPlaylist removes by coordinate and by item, immutably", () => {
|
||||
const base = [
|
||||
{ type: "a", value: coord("v1") },
|
||||
{ type: "a", value: coord("v2") },
|
||||
{ type: "e", value: "e".repeat(64) },
|
||||
];
|
||||
|
||||
const byCoord = removeVideoFromPlaylist(base, coord("v1"));
|
||||
assert.deepEqual(byCoord.map(playlistItemKey), [
|
||||
`a:${coord("v2")}`,
|
||||
`e:${"e".repeat(64)}`,
|
||||
]);
|
||||
assert.equal(base.length, 3, "input not mutated");
|
||||
|
||||
const byItem = removeVideoFromPlaylist(base, { type: "e", value: "e".repeat(64) });
|
||||
assert.ok(!byItem.some((i) => i.type === "e"));
|
||||
});
|
||||
|
||||
test("reorderPlaylistItems moves items, clamps, no-ops safely, immutably", () => {
|
||||
const base = [
|
||||
{ type: "a", value: coord("v1") },
|
||||
{ type: "a", value: coord("v2") },
|
||||
{ type: "a", value: coord("v3") },
|
||||
];
|
||||
|
||||
// Move first to last.
|
||||
const moved = reorderPlaylistItems(base, 0, 2);
|
||||
assert.deepEqual(moved.map((i) => i.value), [
|
||||
coord("v2"),
|
||||
coord("v3"),
|
||||
coord("v1"),
|
||||
]);
|
||||
assert.deepEqual(base.map((i) => i.value), [
|
||||
coord("v1"),
|
||||
coord("v2"),
|
||||
coord("v3"),
|
||||
], "input not mutated");
|
||||
|
||||
// toIndex beyond the end clamps to last.
|
||||
const clamped = reorderPlaylistItems(base, 0, 99);
|
||||
assert.equal(clamped[clamped.length - 1].value, coord("v1"));
|
||||
|
||||
// Out-of-range fromIndex → unchanged copy.
|
||||
assert.deepEqual(reorderPlaylistItems(base, 5, 0), base);
|
||||
// Same index → unchanged.
|
||||
assert.deepEqual(reorderPlaylistItems(base, 1, 1), base);
|
||||
});
|
||||
|
||||
test("videoCoordinate derives the 30078 coordinate from a video object", () => {
|
||||
const video = {
|
||||
kind: 30078,
|
||||
pubkey: AUTHOR,
|
||||
tags: [["d", "1700000000000-abc"]],
|
||||
};
|
||||
assert.equal(videoCoordinate(video), `30078:${AUTHOR}:1700000000000-abc`);
|
||||
|
||||
// A video object carrying kind 0 (bitvid's internal objects often do) still
|
||||
// yields the canonical 30078 coordinate — matches the zap-pointer contract.
|
||||
assert.equal(
|
||||
videoCoordinate({ kind: 0, pubkey: AUTHOR, tags: [["d", "x"]] }),
|
||||
`30078:${AUTHOR}:x`,
|
||||
);
|
||||
});
|
||||
|
||||
test("generatePlaylistId produces distinct, tag-safe ids", () => {
|
||||
const a = generatePlaylistId();
|
||||
const b = generatePlaylistId();
|
||||
assert.notEqual(a, b);
|
||||
assert.match(a, /^pl-[0-9a-z]+-[0-9a-z]+$/);
|
||||
});
|
||||
Reference in new issue
Block a user