import type { RichContent } from "../../model/interfaces.ts"; import type { RichContentProvider } from "../rich-content-service.ts"; import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts"; function extractVideoId(url: string): string | null { try { const u = new URL(url); if (u.hostname === "youtu.be") { return u.pathname.slice(1).split("/")[0] || null; } if (u.hostname === "youtube.com" || u.hostname === "www.youtube.com") { if (u.pathname === "/watch" || u.pathname.startsWith("/watch?")) { return u.searchParams.get("v"); } if ( u.pathname.startsWith("/embed/") || u.pathname.startsWith("/shorts/") ) { return u.pathname.split("/")[2] || null; } } } catch { // invalid URL } return null; } function extractPlaylistId(url: string): string | null { try { return new URL(url).searchParams.get("list"); } catch { return null; } } /** * Extract the playback offset in seconds from a YouTube URL. * Accepts `t` (plain seconds, `90s`, or `1h2m3s` / `20m5s` forms) and `start` * (plain seconds). Returns null when absent or unparseable. */ 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); } /** Matches /channel/UC…, /@handle, /c/name, /user/name */ function extractChannelPath(url: string): string | null { try { const u = new URL(url); if (u.hostname === "youtube.com" || u.hostname === "www.youtube.com") { if ( u.pathname.startsWith("/channel/") || u.pathname.startsWith("/@") || u.pathname.startsWith("/c/") || u.pathname.startsWith("/user/") ) { return u.pathname; } } } catch { // invalid URL } return null; } 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 }; } } catch { // oembed failed — carry on } return {}; } 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); // ── Channel ─────────────────────────────────────────────────────────────── if (channelPath && !videoId) { // oEmbed doesn't support channel URLs — scrape og:image from the page instead let title: string | undefined; let thumbnailUrl: string | undefined; try { const res = await fetchWithTimeout(url); if (res.ok) { const html = await res.text(); title = extractOgTag(html, "title"); thumbnailUrl = extractOgTag(html, "image"); } } catch { // scrape failed — carry on with undefined values } return { type: "youtube", siteName: "YouTube", url, title, thumbnailUrl, // channels are not embeddable as a player }; } // ── Playlist (no specific video) ────────────────────────────────────────── 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`, }; } // ── Video (with optional playlist context) ──────────────────────────────── 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}`, }; }, };