/** * 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"; /** 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 = u.hostname.toLowerCase().replace(/^www\./, ""); 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 { 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}`, }; }, };