T-HUB/packages/connector-peertube/src/resolve.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

236 lines
9.6 KiB
TypeScript

/**
* 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readJson(response: Response): Promise<unknown> {
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<Response>;
/** 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<ResolvedPeerTubeSource> {
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<ResolvedPeerTubeSource> {
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`);
}