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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
236
packages/connector-peertube/src/resolve.ts
Normal file
236
packages/connector-peertube/src/resolve.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/**
|
||||
* 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`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue