Files
gerbeur/api/remote/providers/youtube.ts
khannurien 2c3ba6ad11 v4: link previews are read once, in one place, and no longer take an error page, a relative image or a mask icon for the page's — each provider fetched its page and scanned it with regular expressions of its own, the generic one some fifteen times, and Bandcamp and SoundCloud read only OG tags. fetchPage (remote/fetch.ts) now fetches every page, follows redirects to the address it was served from, and refuses an error status, so a 404 or a bot challenge is unreachable rather than a preview titled "Just a moment...", and a refresh no longer overwrites good metadata with it. parsePage (remote/html.ts) reads it in one linear pass with a real attribute reader: unquoted values, a > inside quotes, og: tags under name as well as property, and nothing from comments or scripts. Every URL a page names is resolved against where it was served from, so a relative og:image works; entities are decoded in one pass, &eacute; and &rsquo; included, and one that names no character no longer fails the preview (bandcamp-tralbum.ts shares the decoder). The mask icon is no longer the favicon, JSON-LD prefers the page's node over the site's, tracking pixels and lazy placeholders aren't thumbnails, an og:url with a slug keeps the OG tags, and a <title> loses its site suffix. genericPreview is the base of the Bandcamp, SoundCloud and YouTube channel previews; SoundCloud takes www., m. and on.soundcloud.com short links, embedding the page they lead to, and Bandcamp keeps only its own player. The service tidies every preview: text on one line, titles and descriptions capped, only http(s) images and players. RichContent.type is now RichContentType, and the unused videoId is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:46:32 +00:00

172 lines
5.0 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.
*
* @module
*/
import type { RichContent } from "../../../shared/dump.ts";
import type { RichContentProvider } from "./provider.ts";
import { parsePage } from "../html.ts";
import { fetchPage, fetchWithTimeout } from "../fetch.ts";
import { youtubeHost, youtubeVideoId } from "../../lib/canonical-url.ts";
import { siteHostOf } from "../../../shared/url.ts";
import { genericPreview } from "./generic.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 channel: RichContent | undefined;
try {
const fetched = await fetchPage(url);
if (fetched.html !== undefined) {
const page = parsePage(fetched.html, fetched.url);
channel = genericPreview(page, url, fetched.url);
}
} catch {
// YouTube unreachable: the preview goes without.
}
return { ...channel, type: "youtube", siteName: "YouTube", url };
}
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,
title,
thumbnailUrl,
embedUrl: `https://www.youtube.com/embed/${videoId}?${embedParams}`,
};
},
};