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:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,842 @@
import { describe, expect, it } from "vitest";
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
import { AuthError, ConnectorError, InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk";
import {
connectorContractTests,
livestreamConnectorContractTests,
videoConnectorContractTests,
} from "@tactical-media-broadcast-hub/connector-sdk/testing";
import {
createPeerTubeConnector,
htmlToText,
normalizeHandle,
normalizeInstanceUrl,
streamKeySecretName,
type PeerTubeAuthorizationRequest,
type PeerTubeConnectorOptions,
} from "./index.js";
/**
* Stubbed PeerTube v8 REST API: one instance, one account with two
* channels, in-memory videos/lives/comments. Shapes follow the OpenAPI
* spec (docs/research/peertube-api-facts.md).
*/
const INSTANCE = "https://video.example";
const HOST = "video.example";
const ACCESS = "stub-access-1";
const REFRESH = "stub-refresh-1";
const SECRET_PASSWORD = "correct horse battery staple";
const STREAM_KEY = "3fa1b2c4d5e6f708192a3b4c5d6e7f80";
interface StubVideo {
id: number;
uuid: string;
name: string;
channelId: number;
isLive: boolean;
state: number;
createdAt: string;
permanentLive?: boolean;
saveReplay?: boolean;
latencyMode?: number;
privacy?: number;
}
interface StubCall {
method: string;
url: string;
init: RequestInit | undefined;
}
interface StubState {
calls: StubCall[];
videos: StubVideo[];
comments: { id: number; videoUuid: string; text: string; inReplyTo?: number }[];
validTokens: Set<string>;
tokenSeq: number;
/** When set, the next authenticated request with this token gets 401. */
expireToken?: string;
requireOtp: boolean;
}
function newState(): StubState {
return {
calls: [],
videos: [
{
id: 41,
uuid: "0d1c2b3a-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
name: "Existing VOD",
channelId: 7,
isLive: false,
state: 1,
createdAt: "2026-10-01T10:00:00.000Z",
},
],
comments: [],
validTokens: new Set([ACCESS]),
tokenSeq: 1,
requireOtp: false,
};
}
const ACCOUNT = {
id: 12,
name: "alice",
displayName: "Alice",
host: HOST,
avatars: [
{ path: "/lazy-static/avatars/small.png", width: 48 },
{ path: "/lazy-static/avatars/big.png", width: 120 },
],
};
const CHANNELS = [
{ id: 7, name: "alice_channel", displayName: "Alice's channel", host: HOST, avatars: [] },
{ id: 8, name: "alice_live", displayName: "Alice Live", host: HOST, avatars: [] },
];
function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json", ...headers },
});
}
function problem(status: number, detail: string, extra: Record<string, unknown> = {}): Response {
return json({ status, detail, ...extra }, status);
}
function urlOf(input: Parameters<typeof fetch>[0]): string {
return typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
}
function header(init: RequestInit | undefined, name: string): string | undefined {
const headers = init?.headers;
if (headers === undefined || headers instanceof Headers || Array.isArray(headers)) return undefined;
return (headers as Record<string, string>)[name];
}
function jsonBody(init: RequestInit | undefined): Record<string, unknown> {
return JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
}
function summary(video: StubVideo): Record<string, unknown> {
return {
id: video.id,
uuid: video.uuid,
shortUUID: `s${video.id}`,
name: video.name,
isLive: video.isLive,
state: { id: video.state, label: video.state === 7 ? "Transcoding failed" : "Published" },
createdAt: video.createdAt,
streamingPlaylists: video.isLive
? []
: [{ id: 1, type: 1, playlistUrl: `${INSTANCE}/static/streaming-playlists/hls/${video.uuid}/aa11-master.m3u8` }],
};
}
function stubApi(state: StubState): typeof fetch {
return async (input, init) => {
const url = new URL(urlOf(input));
const method = init?.method ?? "GET";
state.calls.push({ method, url: url.href, init });
const path = url.pathname;
// Public endpoints.
if (path === "/api/v1/config/about") return json({ instance: { name: "Example Tube" } });
if (path === "/api/v1/oauth-clients/local") {
return json({ client_id: "local-client", client_secret: "local-client-secret" });
}
if (path === "/api/v1/users/token" && method === "POST") {
const form = new URLSearchParams(String(init?.body ?? ""));
if (form.get("client_id") !== "local-client" || form.get("client_secret") !== "local-client-secret") {
return problem(400, "Invalid client");
}
if (form.get("grant_type") === "password") {
if (form.get("username") !== "alice" || form.get("password") !== SECRET_PASSWORD) {
return problem(400, "Invalid grant: user credentials are invalid", { code: "invalid_grant" });
}
if (state.requireOtp && header(init, "x-peertube-otp") === undefined) {
return problem(401, "Missing two factor header", { code: "missing_two_factor" });
}
return json({ access_token: ACCESS, refresh_token: REFRESH, token_type: "Bearer", expires_in: 86400 });
}
if (form.get("grant_type") === "refresh_token") {
if (!String(form.get("refresh_token")).startsWith("stub-refresh")) return problem(400, "Invalid refresh token");
state.tokenSeq += 1;
const access = `stub-access-${state.tokenSeq}`;
state.validTokens.add(access);
return json({ access_token: access, refresh_token: `stub-refresh-${state.tokenSeq}`, token_type: "Bearer" });
}
return problem(400, "Unsupported grant");
}
if (path.startsWith("/static/")) return new Response("#EXTM3U", { status: 200 });
// Authenticated endpoints.
const bearer = header(init, "authorization")?.replace(/^Bearer /, "");
if (bearer === undefined || !state.validTokens.has(bearer)) return problem(401, "Token is invalid");
if (state.expireToken === bearer) {
state.validTokens.delete(bearer);
state.expireToken = undefined;
return problem(401, "Token is expired");
}
if (path === "/api/v1/users/me") {
return json({ id: 3, username: "alice", account: ACCOUNT, videoChannels: CHANNELS });
}
if (path === "/api/v1/users/revoke-token" && method === "POST") {
state.validTokens.delete(bearer);
return json({});
}
if (path === "/api/v1/accounts/alice/video-channels") return json({ total: 2, data: CHANNELS });
const channelMatch = /^\/api\/v1\/video-channels\/(.+)$/.exec(path);
if (channelMatch) {
const handle = decodeURIComponent(channelMatch[1] ?? "");
if (handle.startsWith("@")) return problem(404, "Video channel not found");
const name = handle.split("@")[0];
const channel = CHANNELS.find((c) => c.name === name) ?? (name === "channel-1" ? CHANNELS[1] : undefined);
return channel ? json(channel) : problem(404, "Video channel not found");
}
if (path === "/api/v1/server/stats") {
return json({ totalUsers: 40, totalLocalVideos: 120, totalLocalVideoViews: 9001, videosRedundancy: [] });
}
if (path === "/api/v1/users/me/videos") {
const start = Number(url.searchParams.get("start") ?? 0);
const count = Number(url.searchParams.get("count") ?? 15);
const onlyLive = url.searchParams.get("isLive") === "true";
const all = state.videos.filter((v) => !onlyLive || v.isLive).sort((a, b) => b.id - a.id);
return json({ total: all.length, data: all.slice(start, start + count).map(summary) });
}
if (path === "/api/v1/videos/upload" && method === "POST") {
const form = init?.body;
if (!(form instanceof FormData)) return problem(400, "multipart expected");
if (!(form.get("videofile") instanceof Blob)) return problem(400, "videofile missing");
const id = 100 + state.videos.length;
const video: StubVideo = {
id,
uuid: `a1b2c3d4-0000-4000-8000-${String(id).padStart(12, "0")}`,
name: String(form.get("name")),
channelId: Number(form.get("channelId")),
isLive: false,
state: 2,
createdAt: new Date().toISOString(),
};
state.videos.push(video);
return json({ video: { id, uuid: video.uuid, shortUUID: `s${id}` } });
}
if (path === "/api/v1/videos/live" && method === "POST") {
const body = jsonBody(init);
if (typeof body.channelId !== "number") return problem(400, "channelId must be a number");
if (body.saveReplay === true && body.permanentLive === true) return problem(400, "conflict");
const id = 200 + state.videos.length;
const video: StubVideo = {
id,
uuid: `b1c2d3e4-1111-4111-8111-${String(id).padStart(12, "0")}`,
name: String(body.name),
channelId: body.channelId,
isLive: true,
state: 4,
createdAt: new Date().toISOString(),
permanentLive: body.permanentLive === true,
saveReplay: body.saveReplay === true,
latencyMode: Number(body.latencyMode),
privacy: typeof body.privacy === "number" ? body.privacy : undefined,
};
state.videos.push(video);
return json({ video: { id, uuid: video.uuid, shortUUID: `s${id}` } });
}
const sessionsMatch = /^\/api\/v1\/videos\/live\/([^/]+)\/sessions$/.exec(path);
if (sessionsMatch) {
return json({
total: 2,
data: [
{ id: 1, startDate: "2026-10-06T18:00:00.000Z", endDate: "2026-10-06T19:00:00.000Z", error: null },
{ id: 2, startDate: "2026-10-07T18:00:00.000Z", endDate: null, error: null },
],
});
}
const liveMatch = /^\/api\/v1\/videos\/live\/([^/]+)$/.exec(path);
if (liveMatch) {
const video = state.videos.find((v) => v.uuid === decodeURIComponent(liveMatch[1] ?? ""));
if (video === undefined || !video.isLive) return problem(404, "Live video not found");
return json({
rtmpUrl: `rtmp://${HOST}:1935/live`,
rtmpsUrl: null,
streamKey: STREAM_KEY,
saveReplay: video.saveReplay,
permanentLive: video.permanentLive,
latencyMode: video.latencyMode,
});
}
const statsMatch = /^\/api\/v1\/videos\/([^/]+)\/stats\/(overall|timeseries\/viewers)$/.exec(path);
if (statsMatch) {
if (statsMatch[2] === "overall") {
return json({ averageWatchTime: 42, totalWatchTime: 4200, viewersPeak: 17, totalViewers: 100, countries: [] });
}
return json({
groupInterval: "1 day",
data: [
{ date: "2026-10-05T00:00:00.000Z", value: 30 },
{ date: "2026-10-06T00:00:00.000Z", value: 70 },
],
});
}
const threadsMatch = /^\/api\/v1\/videos\/([^/]+)\/comment-threads$/.exec(path);
if (threadsMatch) {
const videoUuid = decodeURIComponent(threadsMatch[1] ?? "");
if (method === "POST") {
const id = 500 + state.comments.length;
state.comments.push({ id, videoUuid, text: String(jsonBody(init).text) });
return json({ comment: { id, text: String(jsonBody(init).text), createdAt: new Date().toISOString(), url: `${INSTANCE}/videos/watch/${videoUuid};threadId=${id}` } });
}
return json({
total: 3,
data: [
{ id: 1, text: "<p>Great &amp; <b>fun</b></p>", createdAt: "2026-10-06T10:00:00.000Z", isDeleted: false, account: { id: 99, name: "bob", host: "other.example" } },
{ id: 2, text: "", createdAt: "2026-10-06T11:00:00.000Z", isDeleted: true, account: null },
],
});
}
const replyMatch = /^\/api\/v1\/videos\/([^/]+)\/comments\/(\d+)$/.exec(path);
if (replyMatch && method === "POST") {
const id = 500 + state.comments.length;
state.comments.push({ id, videoUuid: decodeURIComponent(replyMatch[1] ?? ""), text: String(jsonBody(init).text), inReplyTo: Number(replyMatch[2]) });
return json({ comment: { id, text: String(jsonBody(init).text), createdAt: new Date().toISOString() } });
}
const videoMatch = /^\/api\/v1\/videos\/([^/]+)$/.exec(path);
if (videoMatch) {
const key = decodeURIComponent(videoMatch[1] ?? "");
const video = state.videos.find((v) => v.uuid === key || String(v.id) === key);
if (video === undefined) return problem(404, "Video not found");
if (method === "PUT") {
const form = init?.body;
if (!(form instanceof FormData)) return problem(400, "multipart expected");
const name = form.get("name");
if (typeof name === "string") video.name = name;
return new Response(null, { status: 204 });
}
return json(summary(video));
}
return problem(404, `no stub for ${method} ${path}`);
};
}
const connection: ConnectorConnection = {
id: "conn-peertube",
workspaceId: "ws-1",
provider: "peertube",
channelId: "7",
credentialId: { kind: "credential-ref", id: "cred-1" },
fields: { instanceUrl: HOST, username: "alice" },
createdAt: new Date(),
};
interface Harness {
state: StubState;
credentials: InMemoryCredentials;
connector: ReturnType<typeof createPeerTubeConnector>;
}
function createHarness(overrides: Partial<PeerTubeConnectorOptions> = {}): Harness {
const state = newState();
const credentials = new InMemoryCredentials();
void credentials.put(connection, "access_token", ACCESS);
void credentials.put(connection, "refresh_token", REFRESH);
void credentials.put(connection, "client_id", "local-client");
void credentials.put(connection, "client_secret", "local-client-secret");
const connector = createPeerTubeConnector({
fetchImpl: stubApi(state),
requestsPerSecond: 1000,
sleep: async () => Promise.resolve(),
credentials,
openAsset: async (assetId) =>
assetId.startsWith("thumb")
? { bytes: new Uint8Array([137, 80, 78, 71]), filename: "thumb.png", mimeType: "image/png" }
: { bytes: new Uint8Array([0, 0, 0, 24]), filename: `${assetId}.mp4`, mimeType: "video/mp4" },
...overrides,
});
return { state, credentials, connector };
}
const contractHarness = createHarness();
connectorContractTests(contractHarness.connector, { connection });
videoConnectorContractTests(contractHarness.connector, { connection });
livestreamConnectorContractTests(contractHarness.connector, { connection });
const authRequest = (
fields: Record<string, string>,
providerMetadata: Record<string, unknown> = {},
): PeerTubeAuthorizationRequest => ({
redirectUri: "https://app.example.com/connections/callback",
workspaceId: "ws-1",
state: "st",
fields,
providerMetadata,
});
describe("peertube: manifest", () => {
it("declares the video/live surface and nothing text-shaped", () => {
const { connector } = createHarness();
const m = connector.manifest();
expect(m.id).toBe("peertube");
expect(m.displayName).toBe("PeerTube");
expect(m.capabilities).toMatchObject({
publishVideo: true,
longFormVideo: true,
comments: true,
replies: true,
livestreams: true,
createLivestream: true,
manageLivestream: true,
livePlaybackSource: true,
analytics: true,
publishText: false,
feed: false,
directMessages: false,
webhooks: false,
});
expect(m.oauth?.kind).toBe("per-instance-oauth2");
expect(connector.publish).toBeUndefined();
expect(connector.getFeed).toBeUndefined();
expect(connector.getMessages).toBeUndefined();
expect(connector.subscribeWebhooks).toBeUndefined();
// Core PeerTube has no live chat (plugin-only) → no method, no capability.
expect(connector.getLiveChat).toBeUndefined();
});
it("connection fields: instanceUrl + username in clear, account secret masked", () => {
const fields = createHarness().connector.manifest().connectionFields;
expect(fields.map((f) => [f.name, f.required, f.secret])).toEqual([
["instanceUrl", true, false],
["username", true, false],
["accountSecret", true, true],
]);
});
});
describe("peertube: authorize", () => {
it("fetches the local client, exchanges the password once, stores only tokens", async () => {
const state = newState();
const credentials = new InMemoryCredentials();
const connector = createPeerTubeConnector({
fetchImpl: stubApi(state),
requestsPerSecond: 1000,
sleep: async () => Promise.resolve(),
credentials,
});
const result = await connector.authorize(
authRequest({ instanceUrl: "https://video.example/", username: "alice", accountSecret: SECRET_PASSWORD }),
);
expect(state.calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([
"GET /api/v1/config/about",
"GET /api/v1/oauth-clients/local",
"POST /api/v1/users/token",
"GET /api/v1/users/me",
]);
const tokenForm = new URLSearchParams(String(state.calls[2]?.init?.body));
expect(tokenForm.get("grant_type")).toBe("password");
expect(tokenForm.get("client_id")).toBe("local-client");
const conn = result.connection;
expect(conn.fields).toEqual({ instanceUrl: INSTANCE, username: "alice" });
expect(JSON.stringify(conn)).not.toContain(SECRET_PASSWORD);
expect(await credentials.getAccessToken(conn)).toBe(ACCESS);
expect(await credentials.getSecret(conn, "refresh_token")).toBe(REFRESH);
for (const key of ["password", "accountSecret"]) {
await expect(credentials.getSecret(conn, key)).rejects.toThrow();
}
expect(result.identity).toMatchObject({
provider: "peertube",
remoteId: "12",
handle: "alice@video.example",
displayName: "Alice",
avatarUrl: "https://video.example/lazy-static/avatars/big.png",
});
});
it("rejects bad credentials with the instance's detail", async () => {
const { connector } = createHarness();
await expect(
connector.authorize(authRequest({ instanceUrl: HOST, username: "alice", accountSecret: "wrong" })),
).rejects.toThrow(/user credentials are invalid/);
});
it("asks for a 2FA code and forwards it via x-peertube-otp", async () => {
const state = newState();
state.requireOtp = true;
const connector = createPeerTubeConnector({ fetchImpl: stubApi(state), requestsPerSecond: 1000 });
const fields = { instanceUrl: HOST, username: "alice", accountSecret: SECRET_PASSWORD };
await expect(connector.authorize(authRequest(fields))).rejects.toMatchObject({
name: "AuthError",
retryable: true,
});
const result = await connector.authorize(authRequest(fields, { otp: "123456" }));
expect(result.identity.handle).toBe("alice@video.example");
const tokenCall = state.calls.filter((c) => c.url.endsWith("/users/token")).at(-1);
expect(header(tokenCall?.init, "x-peertube-otp")).toBe("123456");
});
it("requires instanceUrl, username and accountSecret; refuses plain http", async () => {
const { connector } = createHarness();
await expect(connector.authorize(authRequest({ username: "alice", accountSecret: "x" }))).rejects.toThrow(/instanceUrl/);
await expect(connector.authorize(authRequest({ instanceUrl: HOST, accountSecret: "x" }))).rejects.toThrow(/username/);
await expect(connector.authorize(authRequest({ instanceUrl: HOST, username: "alice" }))).rejects.toThrow(/accountSecret/);
await expect(
connector.authorize(authRequest({ instanceUrl: "http://video.example", username: "a", accountSecret: "x" })),
).rejects.toThrow(/https/);
});
});
describe("peertube: tokens", () => {
it("refreshes once on 401 and retries with the new token", async () => {
const { state, credentials, connector } = createHarness();
state.expireToken = ACCESS;
const identity = await connector.getIdentity(connection);
expect(identity.handle).toBe("alice@video.example");
expect(await credentials.getAccessToken(connection)).toBe("stub-access-2");
expect(await credentials.getSecret(connection, "refresh_token")).toBe("stub-refresh-2");
expect(state.calls.map((c) => new URL(c.url).pathname)).toEqual([
"/api/v1/users/me",
"/api/v1/users/token",
"/api/v1/users/me",
]);
});
it("refreshCredentials uses the refresh_token grant with the stored client", async () => {
const { state, connector } = createHarness();
const ref = await connector.refreshCredentials?.(connection);
expect(ref).toEqual(connection.credentialId);
const form = new URLSearchParams(String(state.calls[0]?.init?.body));
expect(form.get("grant_type")).toBe("refresh_token");
expect(form.get("refresh_token")).toBe(REFRESH);
expect(form.get("client_secret")).toBe("local-client-secret");
});
it("surfaces AuthError when no token is stored", async () => {
const { connector } = createHarness({ credentials: new InMemoryCredentials() });
await expect(connector.getIdentity(connection)).rejects.toBeInstanceOf(AuthError);
});
it("revokeCredentials calls revoke-token", async () => {
const { state, connector } = createHarness();
await connector.revokeCredentials?.(connection);
expect(state.calls[0]?.url).toBe(`${INSTANCE}/api/v1/users/revoke-token`);
expect(state.validTokens.has(ACCESS)).toBe(false);
});
});
describe("peertube: channels", () => {
it("lists the account's channels as name@host handles", async () => {
const { connector } = createHarness();
const channels = await connector.getRemoteVideoChannels?.(connection);
expect(channels).toHaveLength(2);
expect(channels?.[0]).toMatchObject({
id: "peertube:video.example:7",
provider: "peertube",
instanceUrl: INSTANCE,
remoteChannelId: "7",
handle: "alice_channel@video.example",
displayName: "Alice's channel",
});
expect(channels?.[0]?.handle.startsWith("@")).toBe(false);
expect(channels?.[0]?.capabilities.createLivestream).toBe(true);
});
it("normalizes handles and instance URLs", () => {
expect(normalizeHandle("@alice@video.example")).toBe("alice@video.example");
expect(normalizeHandle("alice_channel@video.example")).toBe("alice_channel@video.example");
expect(normalizeInstanceUrl("video.example/")).toBe(INSTANCE);
expect(normalizeInstanceUrl("http://localhost:9000")).toBe("http://localhost:9000");
expect(() => normalizeInstanceUrl("ftp://video.example")).toThrow(/https/);
});
});
describe("peertube: video upload", () => {
it("uploads multipart with name, channel, privacy, tags and thumbnail", async () => {
const { state, connector } = createHarness();
const result = await connector.uploadVideo?.(connection, {
videoAssetId: "asset-9",
title: "Federation explained",
description: "A long-form talk",
tags: ["fediverse", "video"],
thumbnailAssetId: "thumb-1",
visibility: "unlisted",
formatIntent: "long_form",
providerMetadata: { channelId: "@alice_live@video.example", category: 15, nsfw: false },
});
expect(result?.remoteId).toMatch(/^a1b2c3d4-/);
const upload = state.calls.find((c) => c.url.endsWith("/api/v1/videos/upload"));
const form = upload?.init?.body as FormData;
expect(form.get("videofile")).toBeInstanceOf(Blob);
expect(form.get("thumbnailfile")).toBeInstanceOf(Blob);
expect(form.get("name")).toBe("Federation explained");
expect(form.get("channelId")).toBe("8"); // handle resolved, leading @ stripped
expect(form.get("privacy")).toBe("2");
expect(form.get("category")).toBe("15");
expect(form.get("nsfw")).toBe("false");
expect(form.get("tags[0]")).toBe("fediverse");
expect(form.get("tags[1]")).toBe("video");
const lookup = state.calls.find((c) => c.url.includes("/video-channels/"));
expect(lookup?.url).toBe(`${INSTANCE}/api/v1/video-channels/alice_live%40video.example`);
});
it("defaults to the account's first channel and filename-derived name", async () => {
const { state, connector } = createHarness();
await connector.uploadVideo?.(connection, {
videoAssetId: "talk",
visibility: "provider_default",
formatIntent: "provider_default",
providerMetadata: {},
});
const form = state.calls.find((c) => c.url.endsWith("/videos/upload"))?.init?.body as FormData;
expect(form.get("channelId")).toBe("7");
expect(form.get("name")).toBe("talk");
expect(form.get("privacy")).toBeNull();
});
it("refuses followers-only visibility, too many tags and bad names before uploading", async () => {
const { state, connector } = createHarness();
const base = { videoAssetId: "asset-1", formatIntent: "provider_default" as const, providerMetadata: {} };
await expect(connector.uploadVideo?.(connection, { ...base, title: "Hello", visibility: "followers" })).rejects.toThrow(
/no PeerTube equivalent/,
);
await expect(
connector.uploadVideo?.(connection, { ...base, title: "Hello", visibility: "public", tags: ["a", "b", "c", "d", "e", "f"] }),
).rejects.toThrow(/at most 5 tags/);
await expect(connector.uploadVideo?.(connection, { ...base, title: "Hi", visibility: "public" })).rejects.toThrow(
/3–120 characters/,
);
expect(state.calls.some((c) => c.url.endsWith("/videos/upload"))).toBe(false);
});
it("requires an asset opener", async () => {
const { connector } = createHarness({ openAsset: undefined });
await expect(
connector.uploadVideo?.(connection, {
videoAssetId: "asset-1",
visibility: "public",
formatIntent: "provider_default",
providerMetadata: {},
}),
).rejects.toThrow(/openAsset/);
});
it("maps VideoState to processing status", async () => {
const { state, connector } = createHarness();
const { remoteId } = (await connector.uploadVideo?.(connection, {
videoAssetId: "asset-1",
title: "Processing check",
visibility: "public",
formatIntent: "provider_default",
providerMetadata: {},
})) ?? { remoteId: "" };
expect((await connector.getVideoStatus?.(connection, remoteId))?.state).toBe("processing");
const video = state.videos.find((v) => v.uuid === remoteId);
if (video === undefined) throw new Error("stub video missing");
video.state = 1;
expect((await connector.getVideoStatus?.(connection, remoteId))?.state).toBe("ready");
video.state = 7;
const failed = await connector.getVideoStatus?.(connection, remoteId);
expect(failed).toMatchObject({ state: "failed", error: "Transcoding failed" });
});
it("pages the user's videos with an offset cursor", async () => {
const { connector } = createHarness();
for (const title of ["First video", "Second video"]) {
await connector.uploadVideo?.(connection, {
videoAssetId: "a",
title,
visibility: "public",
formatIntent: "provider_default",
providerMetadata: {},
});
}
const first = await connector.getVideos?.(connection, { limit: 2 });
expect(first?.items.map((v) => v.title)).toEqual(["Second video", "First video"]);
expect(first?.nextCursor).toBe("2");
const second = await connector.getVideos?.(connection, { limit: 2, cursor: first?.nextCursor });
expect(second?.items.map((v) => v.title)).toEqual(["Existing VOD"]);
expect(second?.items[0]?.createdAt).toBeInstanceOf(Date);
expect(second?.nextCursor).toBeUndefined();
});
});
describe("peertube: livestreams", () => {
it("creates a live with latencyMode, stores the stream key in the vault, returns a ref", async () => {
const { state, credentials, connector } = createHarness({ defaultLatencyMode: 3 });
const live = await connector.createLiveStream?.(connection, {
channelId: "peertube:video.example:8",
title: "Weekly stream",
permanent: true,
saveReplay: false,
});
const body = jsonBody(state.calls.find((c) => c.url.endsWith("/api/v1/videos/live"))?.init);
expect(body).toEqual({ channelId: 8, name: "Weekly stream", saveReplay: false, permanentLive: true, latencyMode: 3 });
expect(live?.state).toBe("created");
expect(live?.rtmpUrl).toBe("rtmp://video.example:1935/live");
expect(live?.streamKeyRef?.kind).toBe("credential-ref");
expect(JSON.stringify(live)).not.toContain(STREAM_KEY);
expect(await credentials.getSecret(connection, streamKeySecretName(live?.remoteId ?? ""))).toBe(STREAM_KEY);
expect(live?.playbackUrls).toEqual([
`${INSTANCE}/static/streaming-playlists/hls/${live?.remoteId}/master.m3u8`,
]);
});
it("passes an explicit latencyMode and privacy", async () => {
const { state, connector } = createHarness();
const create = connector.createLiveStream;
await create?.(connection, {
channelId: "7",
title: "Low latency",
permanent: false,
saveReplay: true,
latencyMode: 2,
visibility: "unlisted",
} as Parameters<NonNullable<typeof create>>[1]);
const body = jsonBody(state.calls.find((c) => c.url.endsWith("/api/v1/videos/live"))?.init);
expect(body).toMatchObject({ channelId: 7, saveReplay: true, permanentLive: false, latencyMode: 2, privacy: 2 });
});
it("refuses saveReplay on a permanent live before calling the instance", async () => {
const { state, connector } = createHarness();
await expect(
connector.createLiveStream?.(connection, { channelId: "7", title: "Nope", permanent: true, saveReplay: true }),
).rejects.toThrow(/permanent lives cannot save replays/);
expect(state.calls).toHaveLength(0);
});
it("detects running lives via isLive + PUBLISHED and attaches the latest session", async () => {
const { state, connector } = createHarness();
const waiting = await connector.createLiveStream?.(connection, { channelId: "7", title: "Waiting one", permanent: false, saveReplay: false });
const running = await connector.createLiveStream?.(connection, { channelId: "7", title: "Running one", permanent: false, saveReplay: false });
const ended = await connector.createLiveStream?.(connection, { channelId: "7", title: "Ended one", permanent: false, saveReplay: false });
const byUuid = (uuid: string | undefined) => state.videos.find((v) => v.uuid === uuid);
const runningVideo = byUuid(running?.remoteId);
const endedVideo = byUuid(ended?.remoteId);
if (runningVideo === undefined || endedVideo === undefined) throw new Error("stub lives missing");
runningVideo.state = 1;
endedVideo.state = 5;
const page = await connector.getLiveStreams?.(connection, { limit: 10 });
const states = Object.fromEntries(page?.items.map((l) => [l.title, l.state]) ?? []);
expect(states).toEqual({ "Waiting one": "waiting", "Running one": "live", "Ended one": "ended" });
expect(page?.items.some((l) => l.title === "Existing VOD")).toBe(false);
const live = page?.items.find((l) => l.remoteId === running?.remoteId);
expect(live?.startedAt?.toISOString()).toBe("2026-10-07T18:00:00.000Z");
expect(live?.endedAt).toBeUndefined();
expect(live?.streamKeyRef?.kind).toBe("credential-ref");
const listCall = state.calls.find((c) => c.url.includes("/users/me/videos"));
expect(new URL(listCall?.url ?? INSTANCE).searchParams.get("isLive")).toBe("true");
expect(waiting?.remoteId).toBeTruthy();
});
it("updateLiveStream renames through the video (multipart PUT)", async () => {
const { state, connector } = createHarness();
const live = await connector.createLiveStream?.(connection, { channelId: "7", title: "Old title", permanent: false, saveReplay: false });
const updated = await connector.updateLiveStream?.(connection, { remoteId: live?.remoteId ?? "", title: "New title" });
expect(updated?.title).toBe("New title");
expect(updated?.state).toBe("waiting");
const put = state.calls.find((c) => c.method === "PUT");
expect((put?.init?.body as FormData).get("name")).toBe("New title");
});
it("updateLiveStream refuses a non-live video", async () => {
const { connector } = createHarness();
await expect(
connector.updateLiveStream?.(connection, { remoteId: "0d1c2b3a-4e5f-4a6b-8c7d-9e0f1a2b3c4d" }),
).rejects.toThrow(/not a live/);
});
it("playback: lives get the deterministic master.m3u8, VODs the API playlistUrl, both an embed", async () => {
const { connector } = createHarness();
const live = await connector.createLiveStream?.(connection, { channelId: "7", title: "Playback", permanent: false, saveReplay: false });
const liveSources = await connector.getStreamPlaybackSources?.(connection, { remoteId: live?.remoteId ?? "" });
expect(liveSources).toEqual([
{ remoteId: live?.remoteId, url: `${INSTANCE}/static/streaming-playlists/hls/${live?.remoteId}/master.m3u8`, kind: "hls", protocol: "https" },
{ remoteId: live?.remoteId, url: `${INSTANCE}/videos/embed/${live?.remoteId}`, kind: "embed", protocol: "https" },
]);
const vod = await connector.getStreamPlaybackSources?.(connection, { remoteId: "41" });
expect(vod?.[0]?.url).toBe(`${INSTANCE}/static/streaming-playlists/hls/0d1c2b3a-4e5f-4a6b-8c7d-9e0f1a2b3c4d/aa11-master.m3u8`);
expect(vod?.[1]?.kind).toBe("embed");
});
});
describe("peertube: comments", () => {
it("lists comment threads as plain text with federated handles, skipping deleted", async () => {
const { connector } = createHarness();
const page = await connector.getComments?.(connection, { onRemoteId: "vid-1", limit: 2 });
expect(page?.items).toHaveLength(1);
expect(page?.items[0]).toMatchObject({
provider: "peertube",
remoteId: "vid-1:1",
onRemoteId: "vid-1",
authorHandle: "bob@other.example",
text: "Great & fun",
});
expect(page?.nextCursor).toBe("2");
});
it("replies to a video (new thread) and to a comment", async () => {
const { state, connector } = createHarness();
const thread = await connector.reply?.(connection, { onRemoteId: "vid-1", text: "Thanks!" });
expect(thread?.remoteId).toBe("vid-1:500");
expect(thread?.remoteUrl).toContain("threadId=500");
const reply = await connector.reply?.(connection, { onRemoteId: "vid-1:1", text: "Glad you liked it" });
expect(reply?.remoteId).toBe("vid-1:501");
expect(reply?.providerMetadata).toMatchObject({ videoId: "vid-1", inReplyTo: "1" });
expect(state.comments[1]).toMatchObject({ inReplyTo: 1, text: "Glad you liked it" });
expect(jsonBody(state.calls.at(-1)?.init)).toEqual({ text: "Glad you liked it" });
});
it("rejects empty text and malformed targets", async () => {
const { connector } = createHarness();
await expect(connector.reply?.(connection, { onRemoteId: "vid-1", text: " " })).rejects.toThrow(/empty/);
await expect(connector.reply?.(connection, { onRemoteId: "vid-1:abc", text: "x" })).rejects.toBeInstanceOf(
ConnectorError,
);
});
it("htmlToText flattens PeerTube comment HTML", () => {
expect(htmlToText("<p>a<br>b</p><p>c &#38; d</p>")).toBe("a\nb\n\nc & d");
});
});
describe("peertube: analytics", () => {
it("video stats: overall values + viewers series over the requested range", async () => {
const { state, connector } = createHarness();
const from = new Date("2026-10-01T00:00:00.000Z");
const to = new Date("2026-10-07T00:00:00.000Z");
const stats = await connector.getAnalytics?.(connection, { subjectRemoteId: "vid-1", from, to });
expect(stats?.values).toEqual({ averageWatchTime: 42, totalWatchTime: 4200, viewersPeak: 17, totalViewers: 100 });
expect(stats?.series).toEqual([
{ at: new Date("2026-10-05T00:00:00.000Z"), values: { viewers: 30 } },
{ at: new Date("2026-10-06T00:00:00.000Z"), values: { viewers: 70 } },
]);
const overall = new URL(state.calls[0]?.url ?? INSTANCE);
expect(overall.pathname).toBe("/api/v1/videos/vid-1/stats/overall");
expect(overall.searchParams.get("startDate")).toBe(from.toISOString());
});
it("without a subject: instance stats from /server/stats", async () => {
const { connector } = createHarness();
const stats = await connector.getAnalytics?.(connection, { from: new Date(0), to: new Date() });
expect(stats).toEqual({ values: { totalUsers: 40, totalLocalVideos: 120, totalLocalVideoViews: 9001 }, series: [] });
});
});
describe("peertube: errors", () => {
it("maps 404 problem+json to ConnectorError with the detail", async () => {
const { connector } = createHarness();
await expect(connector.getVideoStatus?.(connection, "missing")).rejects.toThrow(/HTTP 404 GET .*Video not found/);
});
it("maps 429 to RateLimitError via the SDK client", async () => {
const connector = createPeerTubeConnector({
fetchImpl: async () => new Response("", { status: 429, headers: { "retry-after": "10" } }),
requestsPerSecond: 1000,
sleep: async () => Promise.resolve(),
credentials: createHarness().credentials,
});
await expect(connector.getIdentity(connection)).rejects.toMatchObject({ name: "RateLimitError", retryAfterMs: 10_000 });
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,116 @@
import { describe, expect, it, vi } from "vitest";
import {
canonicalPeerTubeHost,
channelRemoteRef,
PeerTubeResolveError,
resolvePeerTubeSource,
videoRemoteRef,
} from "./resolve.js";
const INSTANCE = "https://video.example";
const UUID = "0d1c2b3a-4e5f-4a6b-8c7d-9e0f1a2b3c4d";
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
describe("remote_ref canonicalization", () => {
it("IDNA-lowercases hosts in channel refs", () => {
expect(channelRemoteRef("alice", "VIDEO.Example.")).toBe("channel:alice@video.example");
expect(channelRemoteRef("Alice", "vïdeo.example")).toBe(channelRemoteRef("alice", "xn--vdeo-5pa.example"));
});
it("lowercases uuids in video refs", () => {
expect(videoRemoteRef(UUID.toUpperCase(), "video.example")).toBe(`video:${UUID}@video.example`);
});
it("canonicalPeerTubeHost strips brackets and trailing dots", () => {
expect(canonicalPeerTubeHost("[video.example].")).toBe("video.example");
expect(canonicalPeerTubeHost("")).toBe("");
});
});
describe("resolvePeerTubeSource", () => {
it("resolves a channel through the public video-channels endpoint", async () => {
const fetch = vi.fn(async (url: string) => {
expect(url).toBe(`${INSTANCE}/api/v1/video-channels/alice%40video.example`);
return json({ id: 7, name: "alice", host: "video.example", displayName: "Alice's channel" });
});
const result = await resolvePeerTubeSource("channel", "alice", "video.example", { fetch });
expect(result).toMatchObject({
remoteRef: "channel:alice@video.example",
instanceUrl: INSTANCE,
live: true,
label: "Alice's channel",
channelId: 7,
});
expect(fetch).toHaveBeenCalledOnce();
});
it("resolves a running live: isLive + PUBLISHED, deterministic master.m3u8", async () => {
const fetch = vi.fn(async () => json({ uuid: UUID, name: "My live", isLive: true, state: { id: 1, label: "Published" } }));
const result = await resolvePeerTubeSource("live", UUID, "video.example", { fetch });
expect(result.live).toBe(true);
expect(result.playbackHlsUrl).toBe(`${INSTANCE}/static/streaming-playlists/hls/${UUID}/master.m3u8`);
expect(result.remoteRef).toBe(`video:${UUID}@video.example`);
});
it("a WAITING_FOR_LIVE video is not live and carries no playback URL", async () => {
const fetch = vi.fn(async () => json({ uuid: UUID, name: "Waiting", isLive: true, state: { id: 4 } }));
const result = await resolvePeerTubeSource("live", UUID, "video.example", { fetch });
expect(result.live).toBe(false);
expect(result.playbackHlsUrl).toBeUndefined();
});
it("a VOD uses the API streamingPlaylists[].playlistUrl (type 1 only)", async () => {
const fetch = vi.fn(async () => json({
uuid: UUID, name: "VOD", isLive: false, state: { id: 1 },
streamingPlaylists: [
{ id: 1, type: 2, playlistUrl: "/dash/manifest.mpd" },
{ id: 2, type: 1, playlistUrl: "/static/streaming-playlists/hls/x/master.m3u8" },
],
}));
const result = await resolvePeerTubeSource("live", UUID, "video.example", { fetch });
expect(result.live).toBe(false);
expect(result.playbackHlsUrl).toBe(`${INSTANCE}/static/streaming-playlists/hls/x/master.m3u8`);
});
it("404 maps to not_found (invalid row), 500 to upstream (failed row), 403 to denied", async () => {
for (const [status, reason] of [[404, "not_found"], [410, "not_found"], [500, "upstream"], [403, "denied"]] as const) {
const fetch = vi.fn(async () => json({ detail: "nope" }, status));
await expect(resolvePeerTubeSource("channel", "alice", "video.example", { fetch }))
.rejects.toMatchObject({ reason });
await expect(resolvePeerTubeSource("live", UUID, "video.example", { fetch }))
.rejects.toMatchObject({ reason });
}
});
it("transport failures are upstream, malformed payloads are upstream", async () => {
const dead = vi.fn(async () => { throw new TypeError("socket hang up"); });
await expect(resolvePeerTubeSource("channel", "alice", "video.example", { fetch: dead })).rejects.toMatchObject({ reason: "upstream" });
const garbage = vi.fn(async () => new Response("<html>not peertube</html>", { status: 200 }));
await expect(resolvePeerTubeSource("channel", "alice", "video.example", { fetch: garbage })).rejects.toBeInstanceOf(PeerTubeResolveError);
const notVideo = vi.fn(async () => json({ name: "not a video" }));
await expect(resolvePeerTubeSource("live", UUID, "video.example", { fetch: notVideo })).rejects.toMatchObject({ reason: "upstream" });
});
it("forwards the abort signal to the transport", async () => {
const fetch = vi.fn(async (_url: string, init?: RequestInit) => {
expect((init as { signal?: AbortSignal } | undefined)?.signal?.aborted).toBe(true);
return json({ uuid: UUID, isLive: false, name: "x", state: { id: 1 } });
});
const controller = new AbortController();
controller.abort();
await resolvePeerTubeSource("live", UUID, "video.example", { fetch, signal: controller.signal });
expect(fetch).toHaveBeenCalledOnce();
});
it("a shortUUID is passed through to the videos endpoint", async () => {
const fetch = vi.fn(async (url: string) => {
expect(url.endsWith("/api/v1/videos/abc123_XY")).toBe(true);
return json({ uuid: UUID, isLive: false, name: "x", state: { id: 1 } });
});
const result = await resolvePeerTubeSource("live", "abc123_XY", "video.example", { fetch });
expect(result.remoteRef).toBe(`video:${UUID}@video.example`);
});
});

View 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`);
}