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/.
236 lines
9.6 KiB
TypeScript
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`);
|
|
}
|