v3: api/ gets one layout rule and keeps to it, and whatever crosses the wire is defined once, in shared/ — api/model/interfaces.ts and src/model.ts declared the same 40 types by hand and had drifted: the error envelope, email required on one side and optional on the other, Invitee/InviteTreeEntry and LinkPreview/UrlPreviewResponse for one shape each. The validation limits, the role permissions, the chat length cap, the @mention pattern and the Bandcamp and YouTube player responses were copied too, behind "keep in sync" comments. shared/ now holds them once, one file per domain (api, category, chat, comment, dump, follow, mention, notification, permissions, player, playlist, search, user, validation, ws), and imports nothing from either half. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. APIErrorCode becomes a const object, since the frontend's erasableSyntaxOnly rejects an enum. The shared types showed two real gaps: the avatar upload and GET /api/users/me return the full self user and the frontend read them as public users, and a stored session may predate media tokens, so the client keeps it as its own Session type. The backend rule: each folder holds one kind of module, each file is named after its domain. routes/ parse and answer; services/ hold the logic and every SQL query (lib/auth.ts's token check moves to user-service); remote/ is the only place that fetches from other sites — the guarded fetch and the HTML scraping out of rich-content-service, public-address, the Bandcamp and YouTube resolvers, the link providers, the image proxy (fetch, checks and cache, out of routes/preview.ts) and the Twemoji glyphs (out of lib/site-icons.ts). model/ holds per table a row type, its guard and its mappers; validators/ the request guards; db/connection.ts the handle and transaction(), db/startup.ts the purges and default admin that db.ts ran on import, called at the same moment. Tests mirror the source one file per module, test/<layer>/<module>.test.ts, with shared/'s under shared/test/ (deno task test runs both); the test that covered the bandcamp and soundcloud providers together is two now, and the backend can() has its own, so 202 tests. deno lint src/ is clean too: window.* becomes globalThis.*, and test bodies and act() callbacks that never awaited lose their async. The README states the rule. No behaviour changes; the catalogues only move their source references.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 20:19:48 +00:00
parent e9f6a1f23e
commit d392be7cb8
269 changed files with 6712 additions and 7122 deletions

View File

@@ -0,0 +1,92 @@
import "../setup.ts";
import { assert, assertEquals } from "@std/assert";
import { parseTralbum } from "../../remote/bandcamp-tralbum.ts";
function escapeAttr(s: string): string {
return s.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
}
function tralbumPage(data: unknown): string {
return `<div id="player" data-tralbum="${
escapeAttr(JSON.stringify(data))
}"></div>`;
}
const STREAM = "https://t4.bcbits.com/stream/abc/mp3-128/1?p=0&ts=1&t=x";
const ALBUM = {
item_type: "album",
artist: "Artist",
art_id: 12345,
current: { title: 'The "Album" &amp; more' },
trackinfo: [
{
track_num: 1,
title: "One",
duration: 180.5,
streaming: 1,
file: { "mp3-128": STREAM },
},
{
track_num: 2,
title: "Preorder",
streaming: 1,
unreleased_track: true,
file: null,
},
{
track_num: 3,
title: "",
streaming: 1,
file: { "mp3-128": "https://evil.test/x.mp3" },
},
{
track_num: 4,
title: "Capped",
streaming: 1,
is_capped: true,
file: { "mp3-128": STREAM },
},
],
};
Deno.test("parseTralbum: tracks, streamability and art", () => {
const album = parseTralbum(
tralbumPage(ALBUM),
"https://a.bandcamp.com/album/x",
);
assert(album);
assertEquals(album.itemType, "album");
assertEquals(album.artist, "Artist");
// A title containing a literal &amp; survives the single-pass decode.
assertEquals(album.title, 'The "Album" &amp; more');
assertEquals(album.artworkUrl, "https://f4.bcbits.com/img/a12345_16.jpg");
assertEquals(album.tracks.map((t) => t.streamable), [
true,
false,
false,
true,
]);
assertEquals(album.tracks[0].streamUrl, STREAM);
assertEquals(album.tracks[0].duration, 180.5);
// A non-bcbits stream host is never relayed.
assertEquals(album.tracks[2].streamUrl, null);
assertEquals(album.tracks[2].title, "Track 3");
assert(album.tracks[3].capped);
});
Deno.test("parseTralbum: pages it can't use", () => {
assertEquals(parseTralbum("<html></html>", "u"), null);
assertEquals(parseTralbum(`<div data-tralbum="{oops"></div>`, "u"), null);
assertEquals(
parseTralbum(tralbumPage({ ...ALBUM, hasAudio: false }), "u"),
null,
);
assertEquals(
parseTralbum(
tralbumPage({ ...ALBUM, trackinfo: [ALBUM.trackinfo[1]] }),
"u",
),
null,
);
});

View File

@@ -0,0 +1,91 @@
import { stubNetwork } from "../helpers.ts";
import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert";
import {
fetchWithTimeout,
isValidHttpUrl,
readTextCapped,
} from "../../remote/fetch.ts";
Deno.test("isValidHttpUrl: http(s) to public hosts, or our own pages", () => {
assert(isValidHttpUrl("https://example.com/x"));
assert(isValidHttpUrl("http://gerbeur.test/dumps/abc"));
assertFalse(isValidHttpUrl("http://localhost:8000/"));
assertFalse(isValidHttpUrl("http://10.0.0.1/"));
assertFalse(isValidHttpUrl("file:///etc/passwd"));
assertFalse(isValidHttpUrl("nope"));
});
Deno.test("fetchWithTimeout: the deadline covers a body that never ends", async () => {
// Headers at once, then a trickle that never finishes: the read must fail
// at the deadline rather than hold the request open.
using _net = stubNetwork((_url, init) =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("<html>"));
init?.signal?.addEventListener(
"abort",
() => controller.error(new DOMException("aborted", "AbortError")),
);
},
}),
{ headers: { "content-type": "text/html" } },
)
);
const res = await fetchWithTimeout("https://example.com/slow", 50);
await assertRejects(() => res.text());
});
Deno.test("readTextCapped: stops reading at the cap", async () => {
let pulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
pulled++;
controller.enqueue(new TextEncoder().encode("abcdefghij"));
},
});
const text = await readTextCapped(new Response(body), 25);
assertEquals(text, "abcdefghijabcdefghijabcde");
assert(pulled < 10);
assertEquals(await readTextCapped(new Response("short")), "short");
});
Deno.test("fetchWithTimeout: follows redirects, checking every hop", async () => {
using net = stubNetwork((url) => {
if (url.pathname === "/start") {
return new Response(null, { status: 302, headers: { location: "/end" } });
}
if (url.pathname === "/to-private") {
return new Response(null, {
status: 301,
headers: { location: "http://169.254.169.254/latest/meta-data" },
});
}
if (url.pathname === "/loop") {
return new Response(null, {
status: 302,
headers: { location: "/loop" },
});
}
return new Response("done");
});
const res = await fetchWithTimeout("https://example.com/start");
assertEquals(await res.text(), "done");
assertEquals(net.calls.map((u) => u.pathname), ["/start", "/end"]);
await assertRejects(
() => fetchWithTimeout("https://example.com/to-private"),
Error,
"private",
);
await assertRejects(
() => fetchWithTimeout("https://example.com/loop"),
TypeError,
"Too many redirects",
);
await assertRejects(
() => fetchWithTimeout("ftp://example.com/"),
TypeError,
"Unsupported protocol",
);
});

View File

@@ -0,0 +1,163 @@
import "../setup.ts";
import { assertEquals } from "@std/assert";
import {
extractBestIcon,
extractFirstContentImage,
extractJsonLd,
extractLargeImage,
extractLinkHref,
extractMaskIconColor,
extractMetaName,
extractOgTag,
extractPageTitle,
extractThemeColor,
normalizeCssColor,
} from "../../remote/html.ts";
const BASE = "https://example.com/articles/1";
Deno.test("extractOgTag / extractMetaName: either attribute order, entities decoded", () => {
const page = `
<meta property="og:title" content="Tom &amp; Jerry &#8212; &#x2764;">
<meta content='Desc' property='og:description'>
<meta name="twitter:title" content="TW">
<meta name="og:title" content="wrong attribute">`;
assertEquals(extractOgTag(page, "title"), "Tom & Jerry — ❤");
assertEquals(extractOgTag(page, "description"), "Desc");
assertEquals(extractOgTag(page, "image"), undefined);
assertEquals(extractMetaName(page, "twitter:title"), "TW");
});
Deno.test("extractOgTag: content never spans into the next tag", () => {
const page = `<meta property="og:title"><meta name="x" content="leak">`;
assertEquals(extractOgTag(page, "title"), undefined);
});
Deno.test("extractPageTitle", () => {
assertEquals(
extractPageTitle("<title lang=en> A &lt;b&gt; </title>"),
"A <b>",
);
assertEquals(extractPageTitle("<p>none</p>"), undefined);
});
Deno.test("extractThemeColor: unscoped, then light, then any", () => {
assertEquals(
extractThemeColor(`
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff">`),
"#fff",
);
assertEquals(
extractThemeColor(`
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">
<meta name="theme-color" content="#123">`),
"#123",
);
assertEquals(
extractThemeColor(
`<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">`,
),
"#000",
);
assertEquals(
extractMaskIconColor(
`<link rel="mask-icon" href="/m.svg" color="#5bbad5">`,
),
"#5bbad5",
);
});
Deno.test("normalizeCssColor", () => {
assertEquals(normalizeCssColor("#ABC"), "#aabbcc");
assertEquals(normalizeCssColor("#abcd"), "#aabbcc");
assertEquals(normalizeCssColor("#112233"), "#112233");
assertEquals(normalizeCssColor("#11223380"), "#112233");
assertEquals(normalizeCssColor(" Navy "), "#000080");
assertEquals(normalizeCssColor("rgb(255, 0, 128)"), "#ff0080");
assertEquals(normalizeCssColor("rgba(300 -5 50% / .5)"), "#ff0080");
assertEquals(normalizeCssColor("#12345"), undefined);
assertEquals(normalizeCssColor("hsl(0 100% 50%)"), undefined);
assertEquals(normalizeCssColor("rgb(1,2)"), undefined);
assertEquals(normalizeCssColor(undefined), undefined);
});
Deno.test("extractJsonLd: @graph, arrays and image shapes", () => {
const ld = (data: unknown) =>
`<script type="application/ld+json">${JSON.stringify(data)}</script>`;
assertEquals(
extractJsonLd(
ld({
"@graph": [{ "@type": "WebSite" }, {
headline: "Head",
image: { url: "https://i/1.jpg" },
}],
}),
),
{ title: "Head", description: undefined, thumbnailUrl: "https://i/1.jpg" },
);
assertEquals(
extractJsonLd(ld({ name: ["N"], image: ["https://i/2.jpg"] })).thumbnailUrl,
"https://i/2.jpg",
);
assertEquals(
extractJsonLd(ld({ name: "N", image: "/relative.jpg" })).thumbnailUrl,
undefined,
);
// Invalid JSON is skipped for the next block.
assertEquals(
extractJsonLd(
`<script type="application/ld+json">{oops</script>${
ld({ name: "Second" })
}`,
).title,
"Second",
);
assertEquals(extractJsonLd("<p></p>"), {});
});
Deno.test("image extraction: large images, content images, relative URLs", () => {
const page = `
<img src="data:image/png;base64,xx" width="500" height="500">
<img src="/small.jpg" width="50" height="50">
<img src="/img/site-logo.png">
<img src="/icons/sprite.svg">
<img src="/photos/cat.jpg" width="640" height="480">`;
assertEquals(
extractLargeImage(page, BASE),
"https://example.com/photos/cat.jpg",
);
assertEquals(
extractFirstContentImage(page, BASE),
"https://example.com/small.jpg",
);
assertEquals(
extractFirstContentImage(`<img src="//cdn.test/a.jpg">`, BASE),
"https://cdn.test/a.jpg",
);
});
Deno.test("extractBestIcon: SVG beats everything, else the largest", () => {
assertEquals(
extractBestIcon(
`<link rel="icon" href="/16.png" sizes="16x16">
<link rel="apple-touch-icon" href="/180.png" sizes="180x180">
<link rel="stylesheet" href="/x.css">`,
BASE,
),
"https://example.com/180.png",
);
assertEquals(
extractBestIcon(
`<link rel="icon" href="/180.png" sizes="180x180">
<link rel="icon" type="image/svg+xml" href="/icon">`,
BASE,
),
"https://example.com/icon",
);
assertEquals(extractBestIcon("<p></p>", BASE), undefined);
assertEquals(
extractLinkHref(`<link href="/feed" rel="alternate">`, "alternate", BASE),
"https://example.com/feed",
);
});

View File

@@ -0,0 +1,14 @@
import { html, stubNetwork } from "../../helpers.ts";
import { assertEquals } from "@std/assert";
import { fetchRichContent } from "../../../services/rich-content-service.ts";
Deno.test("bandcamp provider", async () => {
using _ = stubNetwork(() =>
html(`<meta property="og:title" content="Album">
<meta property="og:video" content="https://bandcamp.com/EmbeddedPlayer/album=1">`)
);
const bc = await fetchRichContent("https://artist.bandcamp.com/album/a");
assertEquals(bc?.type, "bandcamp");
assertEquals(bc?.title, "Album");
assertEquals(bc?.embedUrl, "https://bandcamp.com/EmbeddedPlayer/album=1");
});

View File

@@ -0,0 +1,53 @@
import { html, stubNetwork } from "../../helpers.ts";
import { assertEquals } from "@std/assert";
import { fetchRichContent } from "../../../services/rich-content-service.ts";
const BASE = "https://example.com/articles/1";
Deno.test("generic provider: the full fallback chain", async () => {
using _ = stubNetwork(() =>
html(`<html><head>
<title>Page title</title>
<meta property="og:site_name" content="Example">
<meta property="og:title" content="OG title">
<meta property="og:image" content="https://example.com/og.jpg">
<meta name="description" content="Plain description">
<meta name="theme-color" content="rgb(0, 128, 255)">
<link rel="icon" href="/favicon-32.png" sizes="32x32">
</head></html>`)
);
const content = await fetchRichContent(BASE);
assertEquals(content, {
type: "generic",
url: BASE,
title: "OG title",
description: "Plain description",
thumbnailUrl: "https://example.com/og.jpg",
faviconUrl: "https://example.com/favicon-32.png",
accentColor: "#0080ff",
siteName: "Example",
});
});
Deno.test("generic provider: a site-wide og:url means og:title/image are ignored", async () => {
using _ = stubNetwork(() =>
html(`
<meta property="og:url" content="https://example.com/">
<meta property="og:title" content="Site name">
<meta property="og:image" content="https://example.com/site.jpg">
<meta name="twitter:title" content="Article title">
<img src="/photo.jpg">`)
);
const content = await fetchRichContent("https://www.example.com/articles/1");
assertEquals(content?.title, "Article title");
assertEquals(content?.thumbnailUrl, "https://www.example.com/photo.jpg");
assertEquals(content?.siteName, "example.com");
assertEquals(content?.faviconUrl, "https://www.example.com/favicon.ico");
});
Deno.test("generic provider: non-HTML responses carry just the URL", async () => {
using _ = stubNetwork(() =>
new Response("{}", { headers: { "content-type": "application/json" } })
);
assertEquals(await fetchRichContent(BASE), { type: "generic", url: BASE });
});

View File

@@ -0,0 +1,28 @@
import { createUser, insertDump, stubNetwork, test } from "../../helpers.ts";
import { assertEquals } from "@std/assert";
import { fetchRichContent } from "../../../services/rich-content-service.ts";
test("self provider: our own dump, user and playlist pages come from the DB", async () => {
const user = await createUser();
const dump = insertDump({
userId: user.id,
title: "Local dump",
comment: "About it",
richContent: { type: "generic", url: "x", thumbnailUrl: "https://t/x.jpg" },
});
using net = stubNetwork(() => {
throw new Error("no network for self links");
});
const content = await fetchRichContent(
`http://gerbeur.test/dumps/${dump.slug}`,
);
assertEquals(content?.title, "Local dump");
assertEquals(content?.description, "About it");
assertEquals(content?.thumbnailUrl, "https://t/x.jpg");
assertEquals(content?.siteName, "gerbeur");
const profile = await fetchRichContent(
`http://gerbeur.test/users/${user.username}`,
);
assertEquals(profile?.title, user.username);
assertEquals(net.calls.length, 0);
});

View File

@@ -0,0 +1,16 @@
import { html, stubNetwork } from "../../helpers.ts";
import { assertEquals, assertStringIncludes } from "@std/assert";
import { fetchRichContent } from "../../../services/rich-content-service.ts";
Deno.test("soundcloud provider", async () => {
using _ = stubNetwork(() =>
html(`<meta property="og:title" content="Album">
<meta property="og:video" content="https://bandcamp.com/EmbeddedPlayer/album=1">`)
);
const sc = await fetchRichContent("https://soundcloud.com/artist/track");
assertEquals(sc?.type, "soundcloud");
assertStringIncludes(
sc?.embedUrl ?? "",
encodeURIComponent("https://soundcloud.com/artist/track"),
);
});

View File

@@ -0,0 +1,57 @@
import { html, stubNetwork } from "../../helpers.ts";
import { assertEquals } from "@std/assert";
import { fetchRichContent } from "../../../services/rich-content-service.ts";
Deno.test("youtube provider: video with start time and playlist", async () => {
using net = stubNetwork((url) =>
url.pathname === "/oembed"
? Response.json({ title: "Never Gonna", thumbnail_url: "ignored" })
: new Response(null, { status: 404 })
);
const content = await fetchRichContent(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123&t=1m30s",
);
assertEquals(content?.type, "youtube");
assertEquals(content?.videoId, "dQw4w9WgXcQ");
assertEquals(content?.title, "Never Gonna");
assertEquals(
content?.thumbnailUrl,
"https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
);
assertEquals(
content?.embedUrl,
"https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0&list=PL123&start=90",
);
assertEquals(net.calls[0].hostname, "www.youtube.com");
});
Deno.test("youtube provider: youtu.be, shorts, playlists and channels", async () => {
using _ = stubNetwork((url) =>
url.pathname === "/oembed"
? Response.json({
title: "T",
thumbnail_url: "https://i.ytimg.com/x.jpg",
})
: html(`<meta property="og:title" content="Channel">
<meta property="og:image" content="https://yt3/ch.jpg">`)
);
assertEquals(
(await fetchRichContent("https://youtu.be/abc123?t=42"))?.embedUrl,
"https://www.youtube.com/embed/abc123?rel=0&start=42",
);
assertEquals(
(await fetchRichContent("https://youtube.com/shorts/xyz"))?.videoId,
"xyz",
);
const list = await fetchRichContent(
"https://www.youtube.com/playlist?list=PLabc",
);
assertEquals(
list?.embedUrl,
"https://www.youtube.com/embed/videoseries?list=PLabc&rel=0",
);
assertEquals(list?.thumbnailUrl, "https://i.ytimg.com/x.jpg");
const channel = await fetchRichContent("https://www.youtube.com/@someone");
assertEquals(channel?.title, "Channel");
assertEquals(channel?.embedUrl, undefined);
});

View File

@@ -0,0 +1,146 @@
import "../setup.ts";
import { stubNetwork } from "../helpers.ts";
import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert";
import {
isPrivateAddress,
isPrivateHostname,
resolvePublicAddress,
} from "../../remote/public-address.ts";
Deno.test("isPrivateAddress: private and reserved IPv4 ranges", () => {
for (
const ip of [
"0.0.0.0",
"10.1.2.3",
"127.0.0.1",
"100.64.0.1",
"100.127.255.255",
"169.254.169.254",
"172.16.0.1",
"172.31.255.255",
"192.0.0.8",
"192.168.1.1",
"198.18.0.1",
"224.0.0.1",
"255.255.255.255",
]
) {
assert(isPrivateAddress(ip), ip);
}
});
Deno.test("isPrivateAddress: public IPv4 addresses", () => {
for (
const ip of [
"8.8.8.8",
"93.184.216.34",
"100.63.255.255",
"100.128.0.0",
"172.15.255.255",
"172.32.0.0",
"192.169.0.1",
"223.255.255.255",
]
) {
assertFalse(isPrivateAddress(ip), ip);
}
});
Deno.test("isPrivateAddress: IPv6, including IPv4 carried inside it", () => {
for (
const ip of [
"::",
"::1",
"fc00::1",
"fd12:3456::1",
"fe80::1%eth0",
"fec0::1",
"ff02::1",
"::ffff:127.0.0.1",
"::ffff:7f00:1",
"::ffff:10.0.0.1",
"64:ff9b::a00:1", // NAT64 of 10.0.0.1
"2002:a00:1::", // 6to4 of 10.0.0.1
"::10.0.0.1",
]
) {
assert(isPrivateAddress(ip), ip);
}
for (
const ip of [
"2606:4700:4700::1111",
"::ffff:8.8.8.8",
"64:ff9b::808:808",
"2002:808:808::",
]
) {
assertFalse(isPrivateAddress(ip), ip);
}
});
Deno.test("isPrivateAddress: anything that isn't an IP is not an address", () => {
for (
const s of ["example.com", "1.2.3", "1.2.3.4.5", "256.1.1.1", "1::2::3", ""]
) {
assertFalse(isPrivateAddress(s), s);
}
});
Deno.test("isPrivateHostname: localhost names and bracketed literals", () => {
assert(isPrivateHostname(new URL("http://localhost:8000/")));
assert(isPrivateHostname(new URL("http://app.localhost/")));
assert(isPrivateHostname(new URL("http://[::1]/")));
assert(isPrivateHostname(new URL("http://127.0.0.1/")));
// WHATWG URL parsing normalises these to 127.0.0.1 before we see them.
assert(isPrivateHostname(new URL("http://0x7f000001/")));
assert(isPrivateHostname(new URL("http://2130706433/")));
assertFalse(isPrivateHostname(new URL("https://example.com/")));
});
Deno.test("resolvePublicAddress: IP literals skip DNS", async () => {
using net = stubNetwork(() => new Response());
assertEquals(
await resolvePublicAddress(new URL("https://8.8.8.8/")),
"8.8.8.8",
);
assertEquals(
await resolvePublicAddress(new URL("https://[2606:4700::1]/")),
"2606:4700::1",
);
assertEquals(net.calls.length, 0);
});
Deno.test("resolvePublicAddress: refuses private literals without resolving", async () => {
await assertRejects(
() => resolvePublicAddress(new URL("http://169.254.169.254/latest")),
Error,
"private",
);
});
Deno.test("resolvePublicAddress: a name resolving anywhere private is refused", async () => {
using _ = stubNetwork(() => new Response(), {
"internal.example": ["10.0.0.5"],
"mixed.example": ["93.184.216.34", "127.0.0.1"],
"nowhere.example": [],
});
await assertRejects(
() => resolvePublicAddress(new URL("http://internal.example/")),
Error,
"resolves to 10.0.0.5",
);
await assertRejects(
() => resolvePublicAddress(new URL("http://mixed.example/")),
Error,
"resolves to 127.0.0.1",
);
await assertRejects(
() => resolvePublicAddress(new URL("http://nowhere.example/")),
Error,
"Could not resolve",
);
assertEquals(
await resolvePublicAddress(new URL("http://public.example/")),
"93.184.216.34",
);
});

View File

@@ -0,0 +1,53 @@
import "../setup.ts";
import { assertEquals } from "@std/assert";
import { parsePlaylistPage } from "../../remote/youtube-playlist.ts";
function ytPage(data: unknown): string {
return `<script>var ytInitialData = ${JSON.stringify(data)};</script>`;
}
const YT_DATA = {
metadata: { playlistMetadataRenderer: { title: "My mix" } },
contents: {
list: [
{
lockupViewModel: {
contentType: "LOCKUP_CONTENT_TYPE_VIDEO",
contentId: "vid1",
metadata: {
lockupMetadataViewModel: { title: { content: "First" } },
},
contentImage: {
overlays: [{ thumbnailBadgeViewModel: { text: "1:02:03" } }],
},
},
},
{
lockupViewModel: {
contentType: "LOCKUP_CONTENT_TYPE_PLAYLIST",
contentId: "ignored",
},
},
{
playlistVideoRenderer: {
videoId: "vid2",
title: { runs: [{ text: "Second" }] },
lengthSeconds: "235",
},
},
],
},
};
Deno.test("parsePlaylistPage: current and legacy rows", () => {
assertEquals(parsePlaylistPage(ytPage(YT_DATA), "PLx"), {
listId: "PLx",
title: "My mix",
tracks: [
{ videoId: "vid1", title: "First", duration: 3723 },
{ videoId: "vid2", title: "Second", duration: 235 },
],
});
assertEquals(parsePlaylistPage("<html></html>", "PLx"), null);
assertEquals(parsePlaylistPage(ytPage({ contents: {} }), "PLx"), null);
});