v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Build and deploy docs / build-and-push (push) Successful in 26s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 21:07:34 +00:00
parent 1702370447
commit d62e4a9883
119 changed files with 2967 additions and 611 deletions

View File

@@ -1,9 +1,3 @@
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts";
import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts";
import type { Tralbum } from "../../shared/player.ts";
/**
* Resolves a Bandcamp page to its streamable tracks, at play time.
*
@@ -11,16 +5,31 @@ import type { Tralbum } from "../../shared/player.ts";
* never be baked into `rich_content` at dump-creation time the way `embedUrl`
* is. This service is the play-time counterpart: the frontend asks for a page,
* gets a whole tracklist back, and plays the mp3s directly from bcbits.
*
* @module
*/
/** Comfortably inside the 24h signature life, so a cache hit is never a URL
* that is about to expire mid-listen. */
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts";
import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts";
import type { Tralbum } from "../../shared/player.ts";
/**
* How long a resolved page is cached: comfortably inside the 24h signature
* life, so a cache hit is never a URL that is about to expire mid-listen.
*/
const TTL_MS = 12 * 60 * 60 * 1000;
/** The most pages cached; the oldest write goes first. */
const MAX_ENTRIES = 200;
/** Resolved pages, by {@link cacheKey}. */
const cache = new Map<string, Tralbum>();
/** Cache key: a page is the same page regardless of query, fragment or case. */
/**
* Returns the cache key of `url`: a page is the same page regardless of query,
* fragment or case.
*/
function cacheKey(url: string): string {
try {
const u = new URL(url);
@@ -52,10 +61,14 @@ function writeCache(key: string, value: Tralbum): void {
}
/**
* Fetch and parse a Bandcamp page.
* Fetches a Bandcamp page and returns its tracklist, cached.
*
* `force` bypasses the cache — used by the client's re-resolve path, so a
* cached entry whose URLs have started 403ing can't keep being handed back.
*
* @throws {APIException} 400 when `pageUrl` isn't a public bandcamp.com URL,
* 502 when Bandcamp can't be reached or doesn't answer with a page, 404 when
* the page has nothing streamable.
*/
export async function resolveBandcamp(
pageUrl: string,

View File

@@ -13,17 +13,20 @@
* The shape is undocumented, so every field is treated as optional and the
* whole parse is best-effort: a `null` return means "not a Bandcamp page",
* which callers turn into a fallback to the iframe embed.
*
* @module
*/
import type { Tralbum, TralbumTrack } from "../../shared/player.ts";
/** Hostnames Bandcamp serves its own sites from. Anchored at the end so that
* `bandcamp.com.example.org` cannot pass. Artists on custom domains do not
* match this and keep using the embed. */
/**
* The hostnames Bandcamp serves its own sites from, anchored at the end so that `bandcamp.com.example.org` cannot pass. Artists on custom
* domains do not match this and keep using the embed.
*/
export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/;
/**
* Decode HTML entities in a single pass.
* Decodes HTML entities in a single pass.
*
* The single pass is the point: `decodeHtmlEntities` in remote/html.ts
* replaces `&amp;` before `&quot;`, so an escaped-ampersand-then-quot sequence
@@ -50,7 +53,7 @@ function decodeEntitiesOnce(input: string): string {
);
}
/** Resolve Bandcamp's protocol-relative and root-relative art URLs. */
/** Returns the URL of the large square artwork numbered `artId`. */
function artworkUrl(artId: unknown): string | undefined {
if (typeof artId !== "number" && typeof artId !== "string") return undefined;
const id = String(artId).trim();
@@ -60,7 +63,7 @@ function artworkUrl(artId: unknown): string | undefined {
}
/**
* Pull the `data-tralbum` blob out of a Bandcamp page and normalise it.
* Pulls the `data-tralbum` blob out of a Bandcamp page and normalises it.
* Returns null when the page has no blob, no audio, or nothing parseable.
*/
export function parseTralbum(html: string, sourceUrl: string): Tralbum | null {
@@ -114,6 +117,7 @@ export function parseTralbum(html: string, sourceUrl: string): Tralbum | null {
};
}
/** Checks whether `url` is an https URL on Bandcamp's bcbits.com CDN. */
function isBcbitsUrl(url: string): boolean {
try {
const { protocol, hostname } = new URL(url);

View File

@@ -1,3 +1,11 @@
/**
* The guarded fetch every request to another site goes through: each hop must
* resolve to a public address, the whole exchange has a deadline, and pages are
* read up to a cap.
*
* @module
*/
import { isPrivateHostname, resolvePublicAddress } from "./public-address.ts";
import { isSelfUrl } from "../lib/self-url.ts";
@@ -12,7 +20,8 @@ const FETCH_HEADERS = {
const MAX_REDIRECTS = 5;
/**
* Fetch `url` via a `curl --insecure` subprocess and return a minimal Response.
* Fetches `url` through a `curl --insecure` subprocess and returns a minimal
* Response.
* Used as a last resort when Deno's rustls rejects the server's TLS certificate
* due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm).
* Returns null if curl is unavailable or exits non-zero.
@@ -91,7 +100,7 @@ async function fetchViaCurl(
}
/**
* Most bytes of a fetched page that are read. Metadata lives in the <head>,
* The most bytes of a fetched page that are read. Metadata lives in the <head>,
* and a "page" past this is a download, not something to scrape.
*/
export const MAX_PAGE_BYTES = 4 * 1024 * 1024;
@@ -161,7 +170,7 @@ export async function readTextCapped(
return new TextDecoder().decode(bytes);
}
/** One request, no redirects followed. The timeout covers the body too. */
/** Makes one request, following no redirects. The timeout covers the body too. */
async function fetchOnce(
url: URL,
address: string,
@@ -203,9 +212,14 @@ async function fetchOnce(
}
/**
* Fetch a user-supplied URL. Every hop — the URL itself and each redirect —
* must resolve to a public address (see resolvePublicAddress), so a link or a
* redirect can't point the server at its own network.
* Fetches a user-supplied URL, following up to five redirects. Every hop — the
* URL itself and each redirect — must resolve to a public address (see
* {@link resolvePublicAddress}), so a link or a redirect can't point the
* server at its own network.
*
* @throws {TypeError} When a hop isn't http(s), there are too many redirects,
* or the request fails.
* @throws {Error} When a hop's host is private or can't be resolved.
*/
export async function fetchWithTimeout(
url: string,
@@ -227,6 +241,10 @@ export async function fetchWithTimeout(
}
}
/**
* Checks whether `raw` is an http(s) URL a dump may link to: not on a private
* host, unless it is one of this site's own pages.
*/
export function isValidHttpUrl(raw: string): boolean {
try {
const u = new URL(raw);

View File

@@ -1,3 +1,11 @@
/**
* Scraping helpers: the metadata a link preview reads from a page's HTML —
* meta and OG tags, the title, JSON-LD, images, icons and brand colors.
*
* @module
*/
/** Decodes the named and numeric HTML entities in `str`. */
function decodeHtmlEntities(str: string): string {
return str
.replace(/&amp;/gi, "&")
@@ -13,8 +21,8 @@ function decodeHtmlEntities(str: string): string {
}
/**
* Find the first `<meta>` tag whose `attr` equals `value` and return its
* `content` (either attribute ordering). Each tag is matched in isolation so
* Returns the `content` of the first `<meta>` tag whose `attr` equals `value`
* (either attribute ordering). Each tag is matched in isolation so
* the `content` capture can never span across tag boundaries.
*/
function extractMetaContent(
@@ -36,6 +44,7 @@ function extractMetaContent(
return undefined;
}
/** Returns the content of `<meta property="og:<tag>">`. */
export function extractOgTag(
html: string,
tag: string,
@@ -43,7 +52,10 @@ export function extractOgTag(
return extractMetaContent(html, "property", `og:${tag}`);
}
/** Extract content from `<meta name="…" content="…">` (both attribute orderings). */
/**
* Returns the content of `<meta name="…" content="…">` (both attribute
* orderings).
*/
export function extractMetaName(
html: string,
name: string,
@@ -51,7 +63,7 @@ export function extractMetaName(
return extractMetaContent(html, "name", name);
}
/** Extract the text content of the `<title>` element. */
/** Returns the text content of the `<title>` element. */
export function extractPageTitle(html: string): string | undefined {
const match = html.match(/<title[^>]*>([^<]+)<\/title>/i);
return match ? decodeHtmlEntities(match[1].trim()) : undefined;
@@ -60,7 +72,7 @@ export function extractPageTitle(html: string): string | undefined {
// ── Brand color helpers ───────────────────────────────────────────────────────
/**
* Find the page's `theme-color`.
* Returns the page's `theme-color`.
*
* Sites routinely ship the tag twice, scoped by `media`, and often list the
* dark-scheme one first — so unlike `extractMetaName` this can't just take the
@@ -88,7 +100,7 @@ export function extractThemeColor(html: string): string | undefined {
return unscoped ?? light ?? any;
}
/** Extract the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */
/** Returns the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */
export function extractMaskIconColor(html: string): string | undefined {
const linkRe = /<link[^>]+>/gi;
let m: RegExpExecArray | null;
@@ -101,8 +113,10 @@ export function extractMaskIconColor(html: string): string | undefined {
return undefined;
}
// The handful of named colors that actually show up in `theme-color`. The full
// 148-name table isn't worth carrying for the long tail.
/**
* The handful of named colors that actually show up in `theme-color`. The full
* 148-name table isn't worth carrying for the long tail.
*/
const NAMED_COLORS: Record<string, string> = {
black: "#000000",
white: "#ffffff",
@@ -131,7 +145,7 @@ function clampByte(n: number): number {
}
/**
* Normalize a CSS color to canonical lowercase `#rrggbb`, or `undefined` when
* Normalizes a CSS color to canonical lowercase `#rrggbb`, or `undefined` when
* it isn't one of the forms sites actually use. Alpha is dropped — the color is
* only ever used as a tint over the app's own surface.
*/
@@ -168,13 +182,20 @@ export function normalizeCssColor(raw: string | undefined): string | undefined {
return undefined;
}
// ── JSON-LD helpers (file-private) ────────────────────────────────────────────
// ── JSON-LD helpers ───────────────────────────────────────────────────────────
type JsonLdResult = {
/** What a page's JSON-LD says about it. */
export interface JsonLdResult {
/** The node's `name` or `headline`. */
title?: string;
/** The node's `description`. */
description?: string;
/**
* The node's `image`, `thumbnailUrl` or `thumbnail`, when it is an absolute
* URL.
*/
thumbnailUrl?: string;
};
}
function ldString(v: unknown): string | undefined {
if (typeof v === "string" && v.trim()) return v.trim();
@@ -216,7 +237,7 @@ function ldExtractNode(data: unknown): JsonLdResult {
}
/**
* Parse every `<script type="application/ld+json">` block and return the first
* Parses every `<script type="application/ld+json">` block and returns the first
* node that yields a title or image. Handles `@graph`, arrays, and the common
* `image` shapes (string, string[], ImageObject).
*/
@@ -234,7 +255,7 @@ export function extractJsonLd(html: string): JsonLdResult {
}
/**
* Return the `src` of the first `<img>` whose declared width or height is at
* Returns the `src` of the first `<img>` whose declared width or height is at
* least `minSize` pixels (default 200). Skips data URIs. Resolves relative URLs.
*/
export function extractLargeImage(
@@ -262,8 +283,8 @@ export function extractLargeImage(
}
/**
* Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank
* them by declared size (largest wins), and return the best resolved URL.
* Collects all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, ranks
* them by declared size (largest wins), and returns the best resolved URL.
* Falls back to the first match when no `sizes` attribute is present.
*
* SVG icons are ranked above everything: they carry no `sizes` attribute, so
@@ -309,12 +330,7 @@ export function extractBestIcon(
return candidates[0].href;
}
/**
* Return the `src` of the first `<img>` that looks like content rather than UI
* chrome. Skips SVGs, data URIs, and images whose filename matches common
* icon/logo/nav patterns (logo, icon, sprite, favicon, avatar, banner, etc.).
* Resolves relative and protocol-relative URLs.
*/
/** Filename tokens that mark an image as UI chrome rather than content. */
const UI_IMAGE_KEYWORDS = new Set([
"logo",
"icon",
@@ -333,6 +349,10 @@ const UI_IMAGE_KEYWORDS = new Set([
"background",
]);
/**
* Checks whether `src` looks like UI chrome: an SVG, or a filename with one of
* the {@link UI_IMAGE_KEYWORDS}.
*/
function isUiImage(src: string): boolean {
if (/\.svg(\?|$)/i.test(src)) return true;
const filename = src.split("?")[0].split("/").pop() ?? "";
@@ -344,7 +364,7 @@ function isUiImage(src: string): boolean {
}
/**
* Return the `src` of the first `<img>` that looks like content rather than UI
* Returns the `src` of the first `<img>` that looks like content rather than UI
* chrome. Skips SVGs, data URIs, and images whose filename tokens match common
* icon/logo/nav patterns (logo, icon, sprite, etc.).
* Resolves relative and protocol-relative URLs.
@@ -370,8 +390,8 @@ export function extractFirstContentImage(
}
/**
* Extract `href` from the first `<link rel="…">` whose rel contains `relFragment`,
* resolved to an absolute URL using `baseUrl`.
* Returns the `href` of the first `<link rel="…">` whose rel contains
* `relFragment`, resolved to an absolute URL using `baseUrl`.
*/
export function extractLinkHref(
html: string,

View File

@@ -1,7 +1,17 @@
/**
* The image proxy's fetch and cache: remote thumbnails are served through
* this site, so viewers' browsers never contact the hosts.
*
* @module
*/
import { fetchWithTimeout } from "./fetch.ts";
/** An image fetched for the proxy. */
export interface ProxiedImage {
/** The image's bytes. */
bytes: Uint8Array;
/** The image's MIME type, as its host sent it. */
contentType: string;
}
@@ -16,11 +26,15 @@ const PROXYABLE_IMAGE_TYPES = new Set([
"image/vnd.microsoft.icon",
]);
// Proxied images, by URL: every viewer of a feed asks for the same
// thumbnails, which were fetched from their hosts again for each one. Bounded
// by total size; the least recently used go first.
/** How long a proxied image is served from the cache. */
const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/** The cache's size bound, in image bytes. */
const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024;
/**
* Proxied images, by URL: every viewer of a feed asks for the same thumbnails,
* which were fetched from their hosts again for each one. Bounded by total
* size; the least recently used go first. Map order is recency order.
*/
const imageCache = new Map<
string,
{ bytes: Uint8Array; contentType: string; at: number }
@@ -63,16 +77,17 @@ function writeImageCache(
imageCacheBytes += bytes.byteLength;
}
/** Forget every proxied image (tests). */
/** Forgets every proxied image (for the tests). */
export function clearImageCache(): void {
imageCache.clear();
imageCacheBytes = 0;
}
/**
* The image at `url`, from the cache or else from its host; null when the host
* answers with something that isn't a raster image of 5 MB at most. Throws when
* the host can't be reached.
* Returns the image at `url`, from the cache or else from its host; null when
* the host answers with something that isn't a raster image of 5 MB at most.
*
* @throws {Error} When the host can't be reached (see {@link fetchWithTimeout}).
*/
export async function fetchProxiedImage(
url: string,

View File

@@ -1,3 +1,9 @@
/**
* The Bandcamp link provider: previews from a release page's OG tags.
*
* @module
*/
import type { RichContent } from "../../../shared/dump.ts";
import type { RichContentProvider } from "./provider.ts";
import { extractOgTag } from "../html.ts";
@@ -5,6 +11,7 @@ import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/;
/** The provider for bandcamp.com pages, with the embed player from `og:video`. */
export const bandcampProvider: RichContentProvider = {
name: "bandcamp",

View File

@@ -1,3 +1,9 @@
/**
* The fallback link provider, for any page no other provider claims.
*
* @module
*/
import type { RichContent } from "../../../shared/dump.ts";
import type { RichContentProvider } from "./provider.ts";
import {
@@ -14,6 +20,11 @@ import {
} from "../html.ts";
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
/**
* The provider for any HTML page, previewed from its OG and Twitter tags, JSON-LD, and markup:
* title, description, artwork, icon and brand color. Matches every URL, so it
* must come last.
*/
export const genericProvider: RichContentProvider = {
name: "generic",

View File

@@ -1,8 +1,20 @@
/**
* The interface every link provider implements.
*
* @module
*/
import type { RichContent } from "../../../shared/dump.ts";
/** Metadata for the links one kind of site serves. */
export interface RichContentProvider {
/** The provider's name, for logs. */
name: string;
/** Checks whether this provider handles `url`. */
matches(url: string): boolean;
/**
* Fetches `url`'s preview. Rejects when the page can't be fetched; a page
* that isn't HTML yields a preview with no metadata.
*/
fetch(url: string): Promise<RichContent>;
}

View File

@@ -1,3 +1,10 @@
/**
* The link provider for this site's own pages, read from the database
* instead of fetched.
*
* @module
*/
import type { RichContent } from "../../../shared/dump.ts";
import type { RichContentProvider } from "./provider.ts";
import { getDump } from "../../services/dump-service.ts";
@@ -10,14 +17,21 @@ const DUMP_RE = /^\/dumps\/([^/]+)$/;
const USER_RE = /^\/users\/([^/]+)$/;
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
// Internal links carry gerbeur's own branding, so an art-less one still gets a
// recognizable placeholder instead of a generic tint.
/**
* Internal links carry gerbeur's own branding, so an art-less one still gets a
* recognizable placeholder instead of a generic tint.
*/
const SELF_BRANDING = {
siteName: "gerbeur",
faviconUrl: `${PUBLIC_URL}/apple-touch-icon.png`,
accentColor: THEME_COLOR,
} as const;
/**
* The provider for links to this site's dump, user and playlist pages, read
* from the database. Its `fetch` rejects with the services' `APIException` (404) for a
* page that doesn't exist or is private.
*/
export const selfProvider: RichContentProvider = {
name: "self",

View File

@@ -1,8 +1,15 @@
/**
* The SoundCloud link provider: previews from a track page's OG tags.
*
* @module
*/
import type { RichContent } from "../../../shared/dump.ts";
import type { RichContentProvider } from "./provider.ts";
import { extractOgTag } from "../html.ts";
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
/** The provider for soundcloud.com pages, with SoundCloud's embed player. */
export const soundcloudProvider: RichContentProvider = {
name: "soundcloud",

View File

@@ -1,8 +1,15 @@
/**
* The YouTube link provider: videos, playlists and channels.
*
* @module
*/
import type { RichContent } from "../../../shared/dump.ts";
import type { RichContentProvider } from "./provider.ts";
import { extractOgTag } from "../html.ts";
import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
/** Returns the id of the video a youtu.be or youtube.com URL points at. */
function extractVideoId(url: string): string | null {
try {
const u = new URL(url);
@@ -25,6 +32,7 @@ function extractVideoId(url: string): string | null {
return null;
}
/** Returns the `list` parameter of a URL: the playlist it plays. */
function extractPlaylistId(url: string): string | null {
try {
return new URL(url).searchParams.get("list");
@@ -34,7 +42,7 @@ function extractPlaylistId(url: string): string | null {
}
/**
* Extract the playback offset in seconds from a YouTube URL.
* Returns the playback offset in seconds of a YouTube URL.
* Accepts `t` (plain seconds, `90s`, or `1h2m3s` / `20m5s` forms) and `start`
* (plain seconds). Returns null when absent or unparseable.
*/
@@ -57,7 +65,10 @@ function extractStartSeconds(url: string): number | null {
Number(hms[3] ?? 0);
}
/** Matches /channel/UC…, /@handle, /c/name, /user/name */
/**
* Returns the path of a channel URL: /channel/UC…, /@handle, /c/name or
* /user/name.
*/
function extractChannelPath(url: string): string | null {
try {
const u = new URL(url);
@@ -77,6 +88,10 @@ function extractChannelPath(url: string): string | null {
return null;
}
/**
* Fetches the title and thumbnail of `url` from YouTube's oEmbed endpoint;
* empty when that fails.
*/
async function fetchOEmbed(
url: string,
): Promise<{ title?: string; thumbnailUrl?: string }> {
@@ -99,6 +114,10 @@ async function fetchOEmbed(
return {};
}
/**
* The provider for YouTube videos (with their playlist and start time in the embed),
* playlists (as a `videoseries` embed) and channels (not embeddable).
*/
export const youtubeProvider: RichContentProvider = {
name: "youtube",

View File

@@ -3,6 +3,8 @@
* image proxy, Bandcamp resolution): the server must not become a way to reach
* addresses only it can see — loopback, the LAN, container networks, or the
* cloud metadata endpoint at 169.254.169.254.
*
* @module
*/
function parseIPv4(s: string): number[] | null {
@@ -25,7 +27,7 @@ function isPrivateIPv4([a, b]: number[]): boolean {
a >= 224; // multicast, reserved, broadcast
}
/** 16 bytes, or null when `s` isn't an IPv6 address. */
/** Parses `s` into its 16 bytes, or null when it isn't an IPv6 address. */
function parseIPv6(s: string): number[] | null {
let addr = s.toLowerCase().replace(/%.*$/, ""); // drop a zone id
// An embedded dotted quad (::ffff:10.0.0.1) becomes the last two groups.
@@ -80,7 +82,10 @@ function isPrivateIPv6(b: number[]): boolean {
b[0] === 0xff; // multicast
}
/** True for an IP literal outside the public internet. Non-IPs are false. */
/**
* Checks whether `ip` is an IP literal outside the public internet. Non-IPs
* are false.
*/
export function isPrivateAddress(ip: string): boolean {
const v4 = parseIPv4(ip);
if (v4) return isPrivateIPv4(v4);
@@ -92,13 +97,16 @@ function isIpLiteral(host: string): boolean {
return parseIPv4(host) !== null || parseIPv6(host) !== null;
}
/** `URL.hostname` keeps the brackets around an IPv6 literal. */
/**
* Returns `url`'s hostname without the brackets `URL.hostname` keeps around
* an IPv6 literal.
*/
function bareHostname(url: URL): string {
return url.hostname.replace(/^\[(.*)\]$/, "$1");
}
/**
* The synchronous part of the check: a hostname that is a private IP literal,
* Checks whether `url`'s host is private without DNS: a private IP literal,
* or a name that is local by definition. Names still need resolving (see
* resolvePublicAddress) — this only rejects what is obvious without DNS.
*/
@@ -116,6 +124,9 @@ export function isPrivateHostname(url: URL): boolean {
* The fetch itself resolves again, so a DNS server answering differently the
* second time (rebinding) isn't fully excluded; the curl fallback pins the
* address this returns.
*
* @throws {Error} When the host is private, can't be resolved, or resolves to
* a private address.
*/
export async function resolvePublicAddress(url: URL): Promise<string> {
if (isPrivateHostname(url)) {

View File

@@ -1,12 +1,20 @@
// Pinned Twemoji release.
/**
* Twemoji glyphs, fetched from the jsDelivr CDN.
*
* @module
*/
/** The pinned Twemoji release. */
const TWEMOJI_VERSION = "16.0.1";
// Cache the fetched glyph per emoji so repeated runs don't re-hit the network.
/** Fetched glyphs by codepoint, so repeated runs don't re-hit the network. */
const glyphCache = new Map<string, string>();
/**
* Twemoji's plain vector-path SVG for `emoji`, whose file is named `cp` (see
* emojiToCodepoint in lib/site-icons.ts).
* Fetches Twemoji's plain vector-path SVG for `emoji`, whose file is named
* `cp` (see `emojiToCodepoint` in lib/site-icons.ts).
*
* @throws {Error} When the glyph can't be fetched.
*/
export async function fetchTwemojiSvg(
emoji: string,

View File

@@ -1,11 +1,3 @@
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { readTextCapped } from "./fetch.ts";
import type {
YouTubePlaylist,
YouTubePlaylistTrack,
} from "../../shared/player.ts";
/**
* Resolves a YouTube playlist to its tracklist, at play time.
*
@@ -20,17 +12,33 @@ import type {
* both the current `lockupViewModel` rows and the older `playlistVideoRenderer`
* ones. Only the first page (100 entries) is read; the client appends any
* further IDs the player reports, untitled.
*
* @module
*/
/** Titles and lengths rarely change; a stale entry only mislabels a row. */
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { readTextCapped } from "./fetch.ts";
import type {
YouTubePlaylist,
YouTubePlaylistTrack,
} from "../../shared/player.ts";
/**
* How long a resolved playlist is cached. Titles and lengths rarely change; a
* stale entry only mislabels a row.
*/
const TTL_MS = 6 * 60 * 60 * 1000;
/** The most playlists cached; the oldest write goes first. */
const MAX_ENTRIES = 200;
/** Resolved playlists, by list id. */
const cache = new Map<string, { value: YouTubePlaylist; at: number }>();
/** Playlist IDs are URL-safe base64-ish: PL…, OLAK5uy_…, RD… (mixes). */
/** A playlist id: URL-safe base64-ish, PL…, OLAK5uy_…, RD… (mixes). */
const LIST_ID_RE = /^[\w-]{2,64}$/;
/** The headers the playlist page is requested with. */
const HEADERS = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
@@ -41,13 +49,13 @@ const HEADERS = {
"Cookie": "SOCS=CAI",
};
/** "3:55", "1:02:03" → seconds. */
/** Converts a clock like "3:55" or "1:02:03" to seconds. */
function parseClock(text: string): number | undefined {
if (!/^\d+(?::\d{1,2}){1,2}$/.test(text)) return undefined;
return text.split(":").reduce((acc, part) => acc * 60 + Number(part), 0);
}
/** Depth-first walk collecting every value stored under `key`. */
/** Collects, depth-first, every value stored under `key` into `out`. */
function collect(node: unknown, key: string, out: unknown[]): void {
if (Array.isArray(node)) {
for (const child of node) collect(child, key, out);
@@ -59,13 +67,17 @@ function collect(node: unknown, key: string, out: unknown[]): void {
}
}
/** Returns the first string stored under `key`, at any depth. */
function firstString(node: unknown, key: string): string | undefined {
const found: unknown[] = [];
collect(node, key, found);
return found.find((v): v is string => typeof v === "string");
}
/** Follows `path` into parsed JSON; undefined wherever a step is missing. */
/**
* Returns the value at `path` in parsed JSON; undefined wherever a step is
* missing.
*/
function at(node: unknown, ...path: (string | number)[]): unknown {
let cur = node;
for (const key of path) {
@@ -75,6 +87,7 @@ function at(node: unknown, ...path: (string | number)[]): unknown {
return cur;
}
/** Reads a track from a current `lockupViewModel` row; null for a non-video. */
function fromLockup(lockup: unknown): YouTubePlaylistTrack | null {
if (at(lockup, "contentType") !== "LOCKUP_CONTENT_TYPE_VIDEO") return null;
const videoId = at(lockup, "contentId");
@@ -96,6 +109,7 @@ function fromLockup(lockup: unknown): YouTubePlaylistTrack | null {
return { videoId, title, duration };
}
/** Reads a track from an older `playlistVideoRenderer` row. */
function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null {
const videoId = at(r, "videoId");
const title = at(r, "title", "runs", 0, "text") ??
@@ -109,6 +123,10 @@ function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null {
};
}
/**
* Reads the tracklist from a playlist page's `ytInitialData`; null when the
* page has none or no track in it.
*/
export function parsePlaylistPage(
html: string,
listId: string,
@@ -145,6 +163,13 @@ export function parsePlaylistPage(
return { listId, title, tracks };
}
/**
* Fetches the playlist `listId` and returns its tracklist, cached.
*
* @throws {APIException} 400 when `listId` isn't a playlist id, 502 when
* YouTube can't be reached or answers with an error, 404 when the playlist has
* no tracks to read (private, deleted, or a mix).
*/
export async function resolveYouTubePlaylist(
listId: string,
): Promise<YouTubePlaylist> {