/** * WP7 bulk-import resolver: turn a pasted PeerTube handle / watch URL / video * uuid into the canonical remote identity, using @tactical-media-broadcast-hub/egress for * every network call (per-host budgets + outbound policy honored there). * * Kept in its own module because it must NOT depend on the connector's * authenticated session: bulk import resolves *other people's* channels and * lives, which are public data on every instance. Public endpoints only: * GET /api/v1/video-channels/{name@host} -> channel actor (id, host) * GET /api/v1/videos/{uuid|shortUUID} -> video (isLive, state, playlists) * * The connector's `fetchImpl` option stays the transport seam for tests; in * production the API passes an egress-backed fetch (createEgressClient's * egressFetch bound to hostClass "import"), so DNS pinning, SSRF denial, * per-host budgets and redirect policy are all the egress package's. * * remote_ref canonical form (IDNA-lowercased host, lowercase name/uuid): * channel:{name}@{host} * video:{uuid}@{host} */ import { ConnectorError } from "@tactical-media-broadcast-hub/connector-sdk"; const PROVIDER = "peertube"; /** What the import service needs back per row. */ export interface ResolvedPeerTubeSource { remoteRef: string; /** Canonical instance origin, e.g. https://video.example */ instanceUrl: string; /** Playback HLS when the remote already exposes one (lives: master.m3u8). */ playbackHlsUrl?: string; /** True when the resolved video is currently a live (isLive + PUBLISHED). */ live: boolean; /** Display name for the source label (channel displayName / video name). */ label?: string; /** Numeric channel id, when a channel was resolved. */ channelId?: number; } export class PeerTubeResolveError extends Error { /** "not_found" -> row counted invalid; "upstream" -> row counted failed. */ constructor(readonly reason: "not_found" | "upstream" | "denied", message: string) { super(message); this.name = "PeerTubeResolveError"; } } interface PtChannelActor { id: number; name: string; host: string; displayName?: string; } interface PtResolveVideo { uuid: string; name: string; isLive?: boolean; state?: { id: number } | number; streamingPlaylists?: { id: number; type: number; playlistUrl: string }[]; } const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /** IDNA-lowercase host (brackets/dot stripped); "" when unusable. */ export function canonicalPeerTubeHost(host: string): string { const lowered = host.trim().replace(/[[\]]/g, "").replace(/[.]+$/, "").toLowerCase(); if (lowered === "") return ""; try { const ascii = new URL(`https://${lowered}`).hostname; return ascii === "" ? "" : ascii; } catch { return ""; } } /** Canonical, collision-free remote_ref for an imported channel or video. */ export function channelRemoteRef(name: string, host: string): string { return `channel:${name.toLowerCase()}@${canonicalPeerTubeHost(host)}`; } export function videoRemoteRef(uuid: string, host: string): string { return `video:${uuid.toLowerCase()}@${canonicalPeerTubeHost(host)}`; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } async function readJson(response: Response): Promise { const text = await response.text(); if (text.length === 0) return {}; try { return JSON.parse(text) as unknown; } catch { throw new PeerTubeResolveError("upstream", `${PROVIDER}: instance returned a non-JSON body`); } } export interface PeerTubeResolverOptions { /** * Transport used for the two public GETs. Production: an egress client's * egressFetch (per-host budgets). Tests: a stub. */ fetch: (url: string, init?: RequestInit) => Promise; /** Abort/deadline is the caller's responsibility; forwarded verbatim. */ signal?: AbortSignal; /** Instance host used when a bare video uuid names no host. */ defaultHost?: string; } /** * Resolves one already-normalized identity. `kind`/`name`/`host` come from * the import normalizer (leading `@` stripped, host canonical, name lower). */ export async function resolvePeerTubeSource( kind: "channel" | "live", name: string, host: string, options: PeerTubeResolverOptions, ): Promise { const origin = `https://${canonicalPeerTubeHost(host)}`; if (origin === "https://") throw new PeerTubeResolveError("not_found", `${PROVIDER}: no instance host`); if (kind === "channel") { // Federated handle form name@host (docs/research/peertube-api-facts.md §2). const handle = `${name}@${canonicalPeerTubeHost(host)}`; let response: Response; try { response = await options.fetch(`${origin}/api/v1/video-channels/${encodeURIComponent(handle)}`, { headers: { accept: "application/json" }, signal: options.signal, }); } catch { throw new PeerTubeResolveError("upstream", `${PROVIDER}: instance request failed`); } if (response.status === 404 || response.status === 410) { throw new PeerTubeResolveError("not_found", `${PROVIDER}: channel not found on the instance`); } if (!response.ok) { throw new PeerTubeResolveError( response.status === 401 || response.status === 403 ? "denied" : "upstream", `${PROVIDER}: instance answered HTTP ${response.status}`, ); } const body = await readJson(response); if (!isRecord(body) || typeof body.name !== "string" || typeof body.host !== "string") { throw new PeerTubeResolveError("upstream", `${PROVIDER}: instance returned an invalid channel payload`); } const actor = body as unknown as PtChannelActor; return { remoteRef: channelRemoteRef(actor.name, actor.host), instanceUrl: origin, // A channel source is a *candidate* feed; `live` only means "this row // points at something live-capable", which a channel always is. live: true, label: actor.displayName ?? actor.name, channelId: typeof actor.id === "number" ? actor.id : undefined, }; } // live/video: uuid or shortUUID. const id = UUID.test(name) ? name.toLowerCase() : name; let response: Response; try { response = await options.fetch(`${origin}/api/v1/videos/${encodeURIComponent(id)}`, { headers: { accept: "application/json" }, signal: options.signal, }); } catch { throw new PeerTubeResolveError("upstream", `${PROVIDER}: instance request failed`); } if (response.status === 404 || response.status === 410) { throw new PeerTubeResolveError("not_found", `${PROVIDER}: video not found on the instance`); } if (!response.ok) { throw new PeerTubeResolveError( response.status === 401 || response.status === 403 ? "denied" : "upstream", `${PROVIDER}: instance answered HTTP ${response.status}`, ); } const body = await readJson(response); if (!isRecord(body) || typeof body.uuid !== "string" || typeof body.isLive !== "boolean") { throw new PeerTubeResolveError("upstream", `${PROVIDER}: instance returned an invalid video payload`); } const video = body as unknown as PtResolveVideo; // A running live stays PUBLISHED (state 1); there is no LIVE VideoState. const stateId = typeof video.state === "object" && video.state !== null ? (video.state as { id?: unknown }).id : video.state; const live = video.isLive === true && (stateId === undefined || stateId === 1); const result: ResolvedPeerTubeSource = { remoteRef: videoRemoteRef(video.uuid, host), instanceUrl: origin, live, label: typeof video.name === "string" ? video.name : undefined, }; const hls = (video.streamingPlaylists ?? []).find((playlist) => playlist.type === 1)?.playlistUrl; if (live) { // Deterministic live master playlist (facts §5) — probeable before start. result.playbackHlsUrl = `${origin}/static/streaming-playlists/hls/${video.uuid}/master.m3u8`; } else if (typeof hls === "string" && hls.length > 0) { result.playbackHlsUrl = new URL(hls, origin).toString(); } return result; } /** Back-compat helper for callers holding a raw user string. */ export async function resolvePeerTubeInput(input: string, options: PeerTubeResolverOptions): Promise { const text = input.trim().replace(/^@+/, ""); try { const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `https://${text}`); const host = canonicalPeerTubeHost(url.hostname); const segments = url.pathname.split("/").filter((part) => part !== ""); if ((segments[0] === "w" || segments[0] === "videos" || segments[0] === "embed") && segments.length === 2) { return resolvePeerTubeSource("live", segments[1]!.split(";")[0]!, host, options); } if ((segments[0] === "c" || segments[0] === "a" || segments[0] === "video-channels") && segments.length === 2) { return resolvePeerTubeSource("channel", decodeURIComponent(segments[1]!), host, options); } const handle = /^([^\s@/]+)@([^\s@/]+)$/.exec(text); if (handle) { const kind = UUID.test(handle[1]!) ? "live" : "channel"; return resolvePeerTubeSource(kind, handle[1]!, handle[2]!, options); } if (UUID.test(text)) { if (!options.defaultHost) throw new PeerTubeResolveError("not_found", `${PROVIDER}: a bare video id needs an instance host`); return resolvePeerTubeSource("live", text, options.defaultHost, options); } } catch (error) { if (error instanceof PeerTubeResolveError) throw error; if (error instanceof ConnectorError) throw error; } throw new PeerTubeResolveError("not_found", `${PROVIDER}: input is not a resolvable PeerTube source`); }