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
842
packages/connector-peertube/src/contract.test.ts
Normal file
842
packages/connector-peertube/src/contract.test.ts
Normal 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 & <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 & 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 });
|
||||
});
|
||||
});
|
||||
1177
packages/connector-peertube/src/index.ts
Normal file
1177
packages/connector-peertube/src/index.ts
Normal file
File diff suppressed because it is too large
Load diff
116
packages/connector-peertube/src/resolve.test.ts
Normal file
116
packages/connector-peertube/src/resolve.test.ts
Normal 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`);
|
||||
});
|
||||
});
|
||||
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