// Build a timestamped deep link for a cited moment in a video. // // Two shapes, in preference order: // // 1. **Archilyzer viewer link** — `${siteOrigin}/?v=&t=`. This // is the exact scheme `common/components/urlState.ts` reads: `v` is the // video slug (`/`, the value the modal compares against // `detail.slug`) and `t` is whole seconds. Opening it lands in the archive's // transcript modal at the cited moment. Used whenever we know the public // origin of the viewer that owns the video (a deployed site, or a hub // member's url). // // 2. **Platform link** — the video's own `webpageUrl` plus a per-platform time // parameter, mirroring the seek patterns the in-app players use // (`common/components/{Odysee,Rumble,Twitch}Player.tsx`). Used as a fallback // when there is no viewer origin (e.g. an MCP pointed at a local build). // // Pure and dependency-light so it can be reused by the MCP server, the browser // report-citation UI, and build tools alike. import { detectPlatform, type Platform } from "./platform"; import { parseWaybackUrl } from "./wayback"; export type MomentUrlInput = { // Public origin of the archilyzer viewer that owns this video (a RemoteSource // base, or a hub member's url). When present and non-empty, we build a viewer // deep link. Null/undefined ⇒ fall back to a platform link. siteOrigin?: string | null; // The video's slug as the viewer's `?v=` param expects it (`channelSlug/id`). slug?: string | null; // The cited moment, in seconds. seconds: number; // Fallback building blocks when there is no viewer origin. webpageUrl?: string | null; platform?: Platform | null; }; // Twitch watch/VOD URLs take an `XhYmZs` time token, not raw seconds. function twitchTime(totalSeconds: number): string { const s = Math.max(0, Math.floor(totalSeconds)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); return `${h}h${m}m${s % 60}s`; } // Best-effort deep link into a platform's own watch page at `seconds`. Mirrors // the per-platform time params the in-app players build. Platforms whose watch // page has no reliable start param (Rumble, Kick, archive.org, BitChute) get // the bare `webpageUrl`. // Returns null only when there is no `webpageUrl` to work from. export function platformMomentUrl( webpageUrl: string | null | undefined, platform: Platform | null | undefined, seconds: number, ): string | null { if (!webpageUrl) return null; const secs = Math.max(0, Math.floor(seconds || 0)); if (secs <= 0) return webpageUrl; // A Wayback capture (lib/wayback.ts) is the page that plays, and a time // param would name a different URL — one the Wayback Machine never captured. if (parseWaybackUrl(webpageUrl)) return webpageUrl; const plat = platform ?? detectPlatform(webpageUrl); let u: URL; try { u = new URL(webpageUrl); } catch { return webpageUrl; } switch (plat) { case "youtube": u.searchParams.set("t", `${secs}s`); return u.toString(); case "odysee": u.searchParams.set("t", String(secs)); return u.toString(); case "twitch": u.searchParams.set("t", twitchTime(secs)); return u.toString(); // Rumble / Kick / archive.org / BitChute / unknown: the watch page has no // dependable start param — return the plain webpage URL rather than an // invalid seek. (archive.org's and BitChute's own players take none we can // rely on; the archive's viewer plays the file itself and seeks it — // PlayerProvider.) default: return webpageUrl; } } // The archilyzer viewer deep link, or null when `siteOrigin`/`slug` are missing // or the origin can't be parsed. export function viewerMomentUrl( siteOrigin: string | null | undefined, slug: string | null | undefined, seconds: number, ): string | null { if (!siteOrigin || !slug) return null; const secs = Math.max(0, Math.floor(seconds || 0)); let u: URL; try { u = new URL(siteOrigin); } catch { return null; } u.pathname = "/"; u.search = ""; u.hash = ""; u.searchParams.set("v", slug); if (secs > 0) u.searchParams.set("t", String(secs)); return u.toString(); } // An archived post's page on the viewer: the post modal for `/` // (`?v=&vm=post`), which shows the post and links on to the original. // Null when the origin or slug is missing or the origin can't be parsed. export function viewerPostUrl( siteOrigin: string | null | undefined, slug: string | null | undefined, ): string | null { if (!siteOrigin || !slug) return null; let u: URL; try { u = new URL(siteOrigin); } catch { return null; } u.pathname = "/"; u.search = ""; u.hash = ""; u.searchParams.set("v", slug); u.searchParams.set("vm", "post"); return u.toString(); } // The preferred moment link: viewer deep link when we have an origin+slug, // else the platform fallback. Null when neither can be built. export function momentUrl(input: MomentUrlInput): string | null { const viewer = viewerMomentUrl(input.siteOrigin, input.slug, input.seconds); if (viewer) return viewer; return platformMomentUrl(input.webpageUrl, input.platform, input.seconds); } // ─── Base (appendable) forms ─── // // A "moment base" is a URL that ends in `t=`, so appending integer seconds // yields a valid moment link (`156` ≡ the momentUrl for 156s). Used by // the MCP's compact `link_style:"base"` output: one base per video instead of // a full URL per line, expanded back to full links in the final report. // The viewer deep-link base: same normalization as viewerMomentUrl, with `t` // set last and empty so the caller can append seconds. Null when the origin or // slug is missing/unparseable. export function viewerMomentBaseUrl( siteOrigin: string | null | undefined, slug: string | null | undefined, ): string | null { if (!siteOrigin || !slug) return null; let u: URL; try { u = new URL(siteOrigin); } catch { return null; } u.pathname = "/"; u.search = ""; u.hash = ""; u.searchParams.set("v", slug); u.searchParams.set("t", ""); return u.toString(); } // The platform base. Unlike platformMomentUrl (which falls back to the bare // webpage URL), a base MUST be appendable — so only platforms whose time param // takes raw seconds qualify. Twitch is excluded (its `t` takes an `XhYmZs` // token, so appending an integer would be an invalid seek); Rumble/Kick/ // archive.org/BitChute/unknown have no dependable start param at all. Null in every // non-appendable case. export function platformMomentBaseUrl( webpageUrl: string | null | undefined, platform: Platform | null | undefined, ): string | null { if (!webpageUrl) return null; const plat = platform ?? detectPlatform(webpageUrl); let u: URL; try { u = new URL(webpageUrl); } catch { return null; } switch (plat) { case "youtube": // accepts raw seconds in `t` (the `s` suffix is optional) case "odysee": { // delete-then-set so a pre-existing `t` is re-appended LAST — the base // must end in `t=` for the append rule to hold. u.searchParams.delete("t"); u.searchParams.set("t", ""); return u.toString(); } default: return null; } } // The preferred moment base: viewer first, platform fallback — mirroring // momentUrl's preference order. Null when neither can be built. export function momentBaseUrl( input: Omit, ): string | null { const viewer = viewerMomentBaseUrl(input.siteOrigin, input.slug); if (viewer) return viewer; return platformMomentBaseUrl(input.webpageUrl, input.platform); }