Files
gerbeur/api/remote/providers/youtube.ts
khannurien c3594e4c27 v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:16:52 +00:00

182 lines
5.1 KiB
TypeScript

/**
* Previews YouTube videos, playlists and channels, for
* `rich-content-service.ts`. Videos and playlists are read from YouTube's
* oEmbed endpoint, channels from their 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";
import { youtubeHost, youtubeVideoId } from "../../lib/canonical-url.ts";
import { siteHostOf } from "../../../shared/url.ts";
/** Returns the id of the video `url` points at, in any shape YouTube uses. */
function extractVideoId(url: string): string | null {
try {
const u = new URL(url);
const host = siteHostOf(u);
return youtubeVideoId(host, u.pathname, u.searchParams);
} catch {
return null;
}
}
/** Returns the `list` parameter of a YouTube URL, and null for any other. */
function extractPlaylistId(url: string): string | null {
try {
const u = new URL(url);
return youtubeHost(u) ? u.searchParams.get("list") : null;
} catch {
return null;
}
}
/**
* Returns the start time of a YouTube URL in seconds, from `t` (`90`, `90s`,
* `1h2m3s`) or `start`; null when there is none that parses.
*/
function extractStartSeconds(url: string): number | null {
let raw: string | null;
try {
const u = new URL(url);
raw = u.searchParams.get("t") ?? u.searchParams.get("start");
} catch {
return null;
}
if (!raw) return null;
const plain = /^\d+$/.exec(raw);
if (plain) return Number(raw);
const hms = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i.exec(raw);
if (!hms || (!hms[1] && !hms[2] && !hms[3])) return null;
return Number(hms[1] ?? 0) * 3600 + Number(hms[2] ?? 0) * 60 +
Number(hms[3] ?? 0);
}
/**
* Returns the path of a YouTube channel URL (`/channel/UC…`, `/@handle`,
* `/c/name` or `/user/name`); null for any other URL.
*/
function extractChannelPath(url: string): string | null {
try {
const u = new URL(url);
if (youtubeHost(u) && /^\/(channel\/|@|c\/|user\/)/.test(u.pathname)) {
return u.pathname;
}
} catch {
// invalid URL
}
return null;
}
/**
* Fetches the title and thumbnail of `url` from YouTube's oEmbed endpoint;
* empty when that fails, since the preview can do without them.
*/
async function fetchOEmbed(
url: string,
): Promise<{ title?: string; thumbnailUrl?: string }> {
try {
const oembedUrl = `https://www.youtube.com/oembed?url=${
encodeURIComponent(url)
}&format=json`;
const res = await fetchWithTimeout(oembedUrl);
if (res.ok) {
const data = await res.json() as {
title?: string;
thumbnail_url?: string;
};
return { title: data.title, thumbnailUrl: data.thumbnail_url };
}
await res.body?.cancel();
} catch {
// YouTube unreachable, or its answer not JSON.
}
return {};
}
/**
* Comes first in the service's list. A video's embed keeps the link's playlist
* and start time, a playlist embeds as a `videoseries`, and a channel gets no
* embed. Its `fetch` never rejects: whatever YouTube doesn't answer is left
* out.
*/
export const youtubeProvider: RichContentProvider = {
name: "youtube",
matches(url: string): boolean {
return (
extractVideoId(url) !== null ||
extractPlaylistId(url) !== null ||
extractChannelPath(url) !== null
);
},
async fetch(url: string): Promise<RichContent> {
const videoId = extractVideoId(url);
const listId = extractPlaylistId(url);
const channelPath = extractChannelPath(url);
if (channelPath && !videoId) {
// oEmbed doesn't take channel URLs.
let title: string | undefined;
let thumbnailUrl: string | undefined;
try {
const res = await fetchWithTimeout(url);
if (res.ok) {
const html = await readTextCapped(res);
title = extractOgTag(html, "title");
thumbnailUrl = extractOgTag(html, "image");
} else {
await res.body?.cancel();
}
} catch {
// YouTube unreachable: the preview goes without.
}
return {
type: "youtube",
siteName: "YouTube",
url,
title,
thumbnailUrl,
};
}
if (listId && !videoId) {
const { title, thumbnailUrl } = await fetchOEmbed(url);
return {
type: "youtube",
siteName: "YouTube",
url,
title,
thumbnailUrl,
embedUrl:
`https://www.youtube.com/embed/videoseries?list=${listId}&rel=0`,
};
}
// Built, not taken from oEmbed, so it is there even when oEmbed fails.
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
const { title } = await fetchOEmbed(url);
const embedParams = new URLSearchParams({ rel: "0" });
if (listId) embedParams.set("list", listId);
const start = extractStartSeconds(url);
if (start) embedParams.set("start", String(start));
return {
type: "youtube",
siteName: "YouTube",
url,
videoId: videoId!,
title,
thumbnailUrl,
embedUrl: `https://www.youtube.com/embed/${videoId}?${embedParams}`,
};
},
};