172 lines
5.0 KiB
TypeScript
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}`,
|
|
};
|
|
},
|
|
};
|