From e5064eeb87a0f79312d99af7a470aa6b5ef404b9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:48:49 -0400 Subject: [PATCH] =?UTF-8?q?playlists(#37):=20foundation=20=E2=80=94=20kind?= =?UTF-8?q?-30082=20schema,=20builder,=20pure=20service=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/nostr-event-schemas.md | 1 + js/nostrEventSchemas.js | 118 +++++++++++++++ js/playlists/playlistService.js | 245 ++++++++++++++++++++++++++++++++ tests/playlist-service.test.mjs | 223 +++++++++++++++++++++++++++++ 4 files changed, 587 insertions(+) create mode 100644 js/playlists/playlistService.js create mode 100644 tests/playlist-service.test.mjs diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 9c4198a5..068b6c46 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -197,6 +197,7 @@ Video posts should treat `videoRootId` as the stable series identifier that rema | Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', ]` plus optional `['e', ]`, `['a', ]`, `['amount', ]`, `['lnurl', ]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note | | Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', ]`, `['description', ]`, `['p', ]` plus optional `['e', ]` and `['a', ]` | Empty content; receipts are published by the recipient's LNURL server | | Zap tally (`NOTE_TYPES.ZAP_TALLY`) | `30081` (addressable, `d = payment_hash`) | `['d', ]`, `['p', ]`, optional `['e', ]`/`['a', ]`, `['amount', ]`, `['bolt11', ]`, `['preimage', ]`, `['description', ]`, `['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', ]`, optional `['title', ...]`/`['description', ...]`/`['image', ...]`, then **ordered** `['a', '30078:pubkey:d']` refs per video (`['e', ]` 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', ]`, `['method', ]`, `['payload', ]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) | | Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', , ]`, `['p', , ]`, and optional `['t', ]` | Plaintext report reason | diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index ff6f553b..0064d14a 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -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. * diff --git a/js/playlists/playlistService.js b/js/playlists/playlistService.js new file mode 100644 index 00000000..03ae6087 --- /dev/null +++ b/js/playlists/playlistService.js @@ -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, +}; diff --git a/tests/playlist-service.test.mjs b/tests/playlist-service.test.mjs new file mode 100644 index 00000000..4aac2cda --- /dev/null +++ b/tests/playlist-service.test.mjs @@ -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]+$/); +});