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

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

1177 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* PeerTube connector — federated video platform, one instance per connection.
*
* Auth: per-instance OAuth2 with the password grant (facts verified against
* PeerTube v8.3 — docs/research/peertube-api-facts.md):
* - Every instance exposes its own local OAuth client at
* GET /api/v1/oauth-clients/local (client_id + client_secret). There is no
* RFC 7591 registration endpoint; fetching the local client IS the
* per-instance "registration".
* - POST /api/v1/users/token with grant_type=password trades
* username + password for an access token (~1 day) and a refresh token
* (~2 weeks). 2FA rides on the x-peertube-otp header.
* - The password is used for that single exchange and is NEVER persisted:
* only access_token, refresh_token and the instance client credentials go
* into the CredentialAccessor; the returned connection carries
* instanceUrl + username only. Afterwards every call runs on the stored
* tokens, and a 401 triggers one refresh_token round-trip and a retry.
* - The SDK forbids connection fields named "password" (so generic UIs and
* logs never treat it as a stored account password); the one-time field is
* therefore named `accountSecret`.
* - No granular scopes exist — authorization follows the account's role.
*
* Honest capability notes:
* - No text posts, feed or DMs — PeerTube is video-only, with comments.
* - Lives: POST /api/v1/videos/live, then GET /api/v1/videos/live/{id} for
* rtmpUrl + streamKey (owner-only). The stream key goes straight into the
* CredentialAccessor; LiveStream carries a credential-ref, never the key.
* saveReplay + permanentLive is rejected by PeerTube (400), so we refuse it
* before any request.
* - Live state: there is no LIVE VideoState. A running live is
* PUBLISHED (1) with isLive=true; WAITING_FOR_LIVE=4, LIVE_ENDED=5.
* - Live chat: PeerTube core has none. Chat exists only through the
* third-party livechat plugin (XMPP, instance-dependent), so liveChatRead /
* liveChatWrite are not declared and getLiveChat is intentionally absent.
* Comments on the live video are reachable through getComments.
* - Webhooks: PeerTube has no outbound webhooks (plugin action hooks only),
* so `webhooks` is false and lives must be polled.
* - Analytics: per-video stats (/videos/{id}/stats/overall + viewers
* timeseries) for owned videos, instance stats (/server/stats) otherwise.
*
* Rate limits: default instance config allows 50 API requests / 10 s per IP.
* We run the SDK client at 2 requests/s per host — well under that budget,
* shared by every connection on the same instance.
*/
import { createHash, randomUUID } from "node:crypto";
import type {
Comment,
ConnectorCapabilities,
ConnectorConnection,
CredentialRef,
LiveStream,
NormalizedIdentity,
NormalizedVideoPublication,
Page,
Publication,
RemoteVideoChannel,
StreamPlaybackSource,
VideoProcessingStatus,
Visibility,
} from "@tactical-media-broadcast-hub/core";
import {
assertValidManifest,
AuthError,
ConnectorError,
InMemoryCredentials,
pageOf,
RateLimitedHttpClient,
type AuthorizationRequest,
type AuthorizationResult,
type ConnectorManifest,
type CredentialAccessor,
type PlatformConnector,
} from "@tactical-media-broadcast-hub/connector-sdk";
import { resolvePeerTubeSource } from "./resolve.js";
const PROVIDER = "peertube";
const OTP_HEADER = "x-peertube-otp";
/** PeerTube validator bounds for video names and tags. */
const NAME_MIN = 3;
const NAME_MAX = 120;
const TAGS_MAX = 5;
const CAPABILITIES: ConnectorCapabilities = {
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,
};
/** What a single PeerTube channel can do — channels are the publishing unit. */
const CHANNEL_CAPABILITIES: ConnectorCapabilities = {
publishVideo: true,
longFormVideo: true,
comments: true,
replies: true,
livestreams: true,
createLivestream: true,
manageLivestream: true,
livePlaybackSource: true,
};
/** Credential keys stored per connection through the CredentialAccessor. */
const SECRET = {
accessToken: "access_token",
refreshToken: "refresh_token",
clientId: "client_id",
clientSecret: "client_secret",
} as const;
/** Vault key for a live's RTMP stream key (owner-only, treated as a credential). */
export function streamKeySecretName(videoUuid: string): string {
return `live_stream_key:${videoUuid}`;
}
/** VideoState ids (server/core/initializers/constants.ts, v8.3). */
export const VideoState = {
PUBLISHED: 1,
TO_TRANSCODE: 2,
TO_IMPORT: 3,
WAITING_FOR_LIVE: 4,
LIVE_ENDED: 5,
TO_MOVE_TO_EXTERNAL_STORAGE: 6,
TRANSCODING_FAILED: 7,
TO_MOVE_TO_EXTERNAL_STORAGE_FAILED: 8,
TO_EDIT: 9,
TO_MOVE_TO_FILE_SYSTEM: 10,
TO_MOVE_TO_FILE_SYSTEM_FAILED: 11,
TO_IMPORT_FAILED: 12,
} as const;
const FAILED_STATES: ReadonlySet<number> = new Set([
VideoState.TRANSCODING_FAILED,
VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED,
VideoState.TO_MOVE_TO_FILE_SYSTEM_FAILED,
VideoState.TO_IMPORT_FAILED,
]);
/** LiveVideoLatencyMode: 1=Default, 2=High latency, 3=Small latency. */
export type PeerTubeLatencyMode = 1 | 2 | 3;
/** VideoPrivacy: 1=Public, 2=Unlisted, 3=Private, 4=Internal (instance users). */
type PeerTubePrivacy = 1 | 2 | 3 | 4;
const PRIVACY: Record<Visibility, PeerTubePrivacy | null | undefined> = {
public: 1,
unlisted: 2,
private: 3,
// PeerTube has no follower-only privacy; "internal" means instance users.
followers: null,
provider_default: undefined,
};
export interface PeerTubeVideoFile {
bytes: Uint8Array;
filename: string;
mimeType: string;
}
export interface PeerTubeConnectorOptions {
/** Stock PeerTube has no authorization-code grant; a bridge must issue API tokens. */
env?: Record<string, string | undefined>;
/** fetch override for tests (same pattern as the other connectors). */
fetchImpl?: typeof fetch;
/** Requests per second per instance host. Default 2; tests raise it. */
requestsPerSecond?: number;
/** Sleep override for the rate limiter (tests). */
sleep?: (ms: number) => Promise<void>;
/** Secret store. Default: in-memory (dev + contract tests). */
credentials?: CredentialAccessor;
/** Read asset bytes (video file, thumbnail) by asset id — needed by uploadVideo. */
openAsset?: (assetId: string) => Promise<PeerTubeVideoFile>;
/** latencyMode used when a createLiveStream request does not set one. */
defaultLatencyMode?: PeerTubeLatencyMode;
}
/**
* The SDK's AuthorizationRequest carries no provider input, so the
* instance, username and one-time account secret travel in `fields`
* (manifest field names) and an optional 2FA code in providerMetadata.otp.
*/
export interface PeerTubeAuthorizationRequest extends AuthorizationRequest {
fields?: Record<string, string>;
providerMetadata?: Record<string, unknown>;
}
/** createLiveStream input: the SDK request plus PeerTube-only knobs. */
export interface PeerTubeCreateLiveRequest {
channelId: string;
title: string;
permanent: boolean;
saveReplay: boolean;
latencyMode?: PeerTubeLatencyMode;
visibility?: Visibility;
}
interface PtAvatar {
path?: string;
fileUrl?: string;
width?: number;
}
interface PtActor {
id: number;
name: string;
displayName?: string;
host: string;
avatars?: PtAvatar[];
avatar?: PtAvatar | null;
}
interface PtUserMe {
id: number;
username: string;
account: PtActor;
videoChannels?: PtActor[];
}
interface PtList<T> {
total: number;
data: T[];
}
interface PtStreamingPlaylist {
id: number;
/** 1 = HLS */
type: number;
playlistUrl: string;
}
interface PtVideo {
id: number;
uuid: string;
shortUUID?: string;
name: string;
isLive?: boolean;
state?: { id: number; label?: string };
createdAt?: string;
publishedAt?: string;
url?: string;
streamingPlaylists?: PtStreamingPlaylist[];
}
interface PtVideoRef {
video: { id: number; uuid: string; shortUUID?: string };
}
interface PtLive {
rtmpUrl?: string | null;
rtmpsUrl?: string | null;
streamKey?: string;
saveReplay?: boolean;
permanentLive?: boolean;
latencyMode?: number;
}
interface PtLiveSession {
id: number;
startDate: string;
endDate?: string | null;
error?: number | null;
}
interface PtComment {
id: number;
text: string;
createdAt: string;
url?: string;
isDeleted?: boolean;
account?: PtActor | null;
}
interface PtToken {
access_token: string;
refresh_token: string;
token_type: string;
expires_in?: number;
}
interface PtOAuthClient {
client_id: string;
client_secret: string;
}
interface PtAbout {
instance?: { name?: string };
}
interface PtTimeseries {
data?: { date: string; value: number }[];
}
function str(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Accepts "video.example", "https://video.example/", etc. → origin. */
export function normalizeInstanceUrl(raw: string): string {
const trimmed = raw.trim();
if (trimmed.length === 0) throw new ConnectorError("instanceUrl is required", PROVIDER);
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
let url: URL;
try {
url = new URL(withScheme);
} catch (cause) {
throw new ConnectorError(`instanceUrl "${raw}" is not a valid URL`, PROVIDER, cause);
}
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) {
throw new ConnectorError(`instanceUrl must use https (got ${url.protocol})`, PROVIDER);
}
return url.origin;
}
/**
* PeerTube handles are `name@host` without a leading `@` — a leading `@`
* splits into an empty name and 404s, so users' pasted `@alice@host` is
* normalized here.
*/
export function normalizeHandle(raw: string): string {
return raw.trim().replace(/^@+/, "");
}
const ENTITIES: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
};
/** Comment text arrives as sanitized HTML; flatten it for the inbox. */
export function htmlToText(html: string): string {
return html
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>\s*<p>/gi, "\n\n")
.replace(/<[^>]+>/g, "")
.replace(/&(#\d+|[a-z]+);/gi, (match, entity: string) => {
if (entity.startsWith("#")) return String.fromCodePoint(Number(entity.slice(1)));
return ENTITIES[entity.toLowerCase()] ?? match;
})
.trim();
}
/** Every numeric top-level field of a stats payload, by name. */
function numericValues(payload: unknown): Record<string, number> {
const values: Record<string, number> = {};
if (!isRecord(payload)) return values;
for (const [key, value] of Object.entries(payload)) {
if (typeof value === "number" && Number.isFinite(value)) values[key] = value;
}
return values;
}
function processingStateOf(video: PtVideo): VideoProcessingStatus["state"] {
const id = video.state?.id;
if (id === undefined) return "processing";
if (FAILED_STATES.has(id)) return "failed";
if (
id === VideoState.PUBLISHED ||
id === VideoState.WAITING_FOR_LIVE ||
id === VideoState.LIVE_ENDED
) {
return "ready";
}
return "processing";
}
/** No LIVE VideoState exists: a running live is PUBLISHED with isLive=true. */
function liveStateOf(video: PtVideo): LiveStream["state"] {
const id = video.state?.id;
if (id === VideoState.WAITING_FOR_LIVE) return "waiting";
if (id === VideoState.LIVE_ENDED) return "ended";
if (id === VideoState.PUBLISHED && video.isLive === true) return "live";
return "created";
}
export function createPeerTubeConnector(options: PeerTubeConnectorOptions = {}): PlatformConnector & { resolveSource: typeof resolvePeerTubeSource } {
const client = new RateLimitedHttpClient({
requestsPerSecond: options.requestsPerSecond ?? 2,
fetchImpl: options.fetchImpl,
sleep: options.sleep,
});
const credentials = options.credentials ?? new InMemoryCredentials();
/** GET /users/me per connection id — identity, account name, default channel. */
const selves = new Map<string, PtUserMe>();
let publicationSeq = 0;
const manifest: ConnectorManifest = {
id: PROVIDER,
displayName: "PeerTube",
capabilities: CAPABILITIES,
connectionFields: [
{
name: "instanceUrl",
label: "Instance URL",
required: true,
secret: false,
placeholder: "video.example.org",
},
{
name: "username",
label: "Username",
required: true,
secret: false,
placeholder: "alice",
},
{
// Account password, used once for the token exchange and never
// stored — only the issued access/refresh tokens are kept.
name: "accountSecret",
label: "Account password (used once to obtain tokens, never stored)",
required: true,
secret: true,
},
],
oauth: {
kind: "per-instance-oauth2", pkce: true,
connectEnabled: Boolean(options.env?.PEERTUBE_AUTHORIZE_PATH && options.env?.PEERTUBE_TOKEN_PATH),
connectNote: "Redirect linking requires an instance OAuth bridge; stock PeerTube supports password grants only.",
},
docsUrl: "https://docs.joinpeertube.org/api-rest-reference.html",
};
function instanceOf(connection: ConnectorConnection): string {
const raw = connection.fields.instanceUrl;
if (raw === undefined) {
throw new ConnectorError(`connection ${connection.id} has no instanceUrl`, PROVIDER);
}
return normalizeInstanceUrl(raw);
}
async function secretOrUndefined(connection: ConnectorConnection, key: string): Promise<string | undefined> {
try {
return await credentials.getSecret(connection, key);
} catch {
return undefined;
}
}
/** Maps PeerTube's problem+json errors onto the SDK error taxonomy. */
async function toError(response: Response, method: string, url: string): Promise<Error> {
let detail = "";
let code: string | undefined;
try {
const body: unknown = await response.json();
if (isRecord(body)) {
detail = str(body.detail) ?? str(body.error) ?? str(body.title) ?? "";
code = str(body.code);
}
} catch {
detail = "";
}
const message = `${PROVIDER}: HTTP ${response.status} ${method} ${new URL(url).pathname}${detail ? `: ${detail}` : ""}`;
if (code === "missing_two_factor" || response.headers.get(OTP_HEADER) === "required") {
return new AuthError(
`${PROVIDER}: two-factor code required — retry authorize with providerMetadata.otp`,
PROVIDER,
true,
);
}
if (response.status === 401 || response.status === 403) {
return new AuthError(message, PROVIDER, false);
}
return new ConnectorError(message, PROVIDER, { status: response.status, code });
}
async function parse<T>(response: Response): Promise<T> {
const text = await response.text();
return (text.length > 0 ? JSON.parse(text) : {}) as T;
}
/** Unauthenticated request (instance discovery + token endpoint). */
async function publicCall<T>(url: string, init: RequestInit = {}): Promise<T> {
const response = await client.request(PROVIDER, url, {
...init,
headers: { accept: "application/json", ...init.headers },
});
if (!response.ok) throw await toError(response, init.method ?? "GET", url);
return parse<T>(response);
}
async function tokenRequest(
instanceUrl: string,
params: Record<string, string>,
otp?: string,
): Promise<PtToken> {
return publicCall<PtToken>(`${instanceUrl}/api/v1/users/token`, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
...(otp !== undefined ? { [OTP_HEADER]: otp } : {}),
},
body: new URLSearchParams(params).toString(),
});
}
async function storeTokens(connection: ConnectorConnection, token: PtToken): Promise<void> {
await credentials.put(connection, SECRET.accessToken, token.access_token);
await credentials.put(connection, SECRET.refreshToken, token.refresh_token);
}
async function refresh(connection: ConnectorConnection): Promise<void> {
const refreshToken = await secretOrUndefined(connection, SECRET.refreshToken);
const clientId = await secretOrUndefined(connection, SECRET.clientId);
const clientSecret = await secretOrUndefined(connection, SECRET.clientSecret);
if (refreshToken === undefined || clientId === undefined || clientSecret === undefined) {
throw new AuthError(
`${PROVIDER}: no refresh token or client credentials stored — reconnect the account`,
PROVIDER,
false,
);
}
const token = await tokenRequest(instanceOf(connection), {
grant_type: "refresh_token",
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
});
await storeTokens(connection, token);
}
/**
* Authenticated call. Access tokens live ~1 day, so a 401 gets exactly one
* refresh_token round-trip and a retry before surfacing as AuthError.
*/
async function api<T>(
connection: ConnectorConnection,
method: string,
path: string,
body?: Record<string, unknown> | FormData,
retried = false,
): Promise<T> {
let token: string;
try {
token = await credentials.getAccessToken(connection);
} catch {
throw new AuthError(`${PROVIDER}: no access token for ${connection.id} — authorize first`, PROVIDER, false);
}
const headers: Record<string, string> = { accept: "application/json", authorization: `Bearer ${token}` };
const init: RequestInit = { method, headers };
if (body instanceof FormData) {
init.body = body;
} else if (body !== undefined) {
headers["content-type"] = "application/json";
init.body = JSON.stringify(body);
}
const url = `${instanceOf(connection)}${path}`;
const response = await client.request(PROVIDER, url, init);
if (response.status === 401 && !retried) {
const canRefresh = (await secretOrUndefined(connection, SECRET.refreshToken)) !== undefined;
if (canRefresh) {
await refresh(connection);
return api<T>(connection, method, path, body, true);
}
}
if (!response.ok) throw await toError(response, method, url);
return parse<T>(response);
}
async function self(connection: ConnectorConnection): Promise<PtUserMe> {
const cached = selves.get(connection.id);
if (cached !== undefined) return cached;
const me = await api<PtUserMe>(connection, "GET", "/api/v1/users/me");
selves.set(connection.id, me);
return me;
}
function avatarUrl(instanceUrl: string, actor: PtActor): string | undefined {
const candidates = [...(actor.avatars ?? []), ...(actor.avatar ? [actor.avatar] : [])];
const best = candidates.reduce<PtAvatar | undefined>(
(pick, avatar) => (pick === undefined || (avatar.width ?? 0) > (pick.width ?? 0) ? avatar : pick),
undefined,
);
if (best === undefined) return undefined;
if (best.fileUrl !== undefined) return best.fileUrl;
return best.path === undefined ? undefined : new URL(best.path, instanceUrl).toString();
}
function handleOf(actor: PtActor): string {
return `${actor.name}@${actor.host}`;
}
function identityOf(connection: ConnectorConnection, me: PtUserMe): NormalizedIdentity {
const identity: NormalizedIdentity = {
provider: PROVIDER,
remoteId: String(me.account.id),
handle: handleOf(me.account),
displayName: me.account.displayName ?? me.username,
};
const avatar = avatarUrl(instanceOf(connection), me.account);
if (avatar !== undefined) identity.avatarUrl = avatar;
return identity;
}
/**
* PeerTube wants a numeric channelId. Accepts a numeric id, a
* RemoteVideoChannel.id (`peertube:<host>:<id>`), or a channel handle
* (`name`, `name@host`, `@name@host`) resolved via /video-channels/{handle}.
* Absent → the account's first channel.
*/
async function resolveChannelId(connection: ConnectorConnection, raw: string | undefined): Promise<number> {
if (raw === undefined) {
const first = (await self(connection)).videoChannels?.[0];
if (first === undefined) {
throw new ConnectorError(`${PROVIDER}: account has no video channel to publish to`, PROVIDER);
}
return first.id;
}
if (/^\d+$/.test(raw)) return Number(raw);
const prefixed = new RegExp(`^${PROVIDER}:[^:]+:(\\d+)$`).exec(raw);
if (prefixed?.[1] !== undefined) return Number(prefixed[1]);
const handle = normalizeHandle(raw);
const channel = await api<PtActor>(
connection,
"GET",
`/api/v1/video-channels/${encodeURIComponent(handle)}`,
);
return channel.id;
}
function privacyOf(visibility: Visibility | undefined, override: unknown): PeerTubePrivacy | undefined {
if (override === 1 || override === 2 || override === 3 || override === 4) return override;
if (visibility === undefined) return undefined;
const privacy = PRIVACY[visibility];
if (privacy === null) {
throw new ConnectorError(
`${PROVIDER}: visibility "${visibility}" has no PeerTube equivalent — use public, unlisted, private, or providerMetadata.privacy=4 (internal)`,
PROVIDER,
);
}
return privacy;
}
function videoPath(remoteId: string): string {
return `/api/v1/videos/${encodeURIComponent(remoteId)}`;
}
async function getVideo(connection: ConnectorConnection, remoteId: string): Promise<PtVideo> {
return api<PtVideo>(connection, "GET", videoPath(remoteId));
}
/** Live master playlist is always master.m3u8 under the video's HLS dir. */
function liveHlsUrl(connection: ConnectorConnection, uuid: string): string {
return `${instanceOf(connection)}/static/streaming-playlists/hls/${uuid}/master.m3u8`;
}
async function latestSession(
connection: ConnectorConnection,
remoteId: string,
): Promise<PtLiveSession | undefined> {
const sessions = await api<PtList<PtLiveSession>>(
connection,
"GET",
`/api/v1/videos/live/${encodeURIComponent(remoteId)}/sessions`,
);
return sessions.data.reduce<PtLiveSession | undefined>(
(latest, session) =>
latest === undefined || Date.parse(session.startDate) > Date.parse(latest.startDate) ? session : latest,
undefined,
);
}
function streamKeyRef(connection: ConnectorConnection, uuid: string): CredentialRef {
return { kind: "credential-ref", id: `${connection.id}#${streamKeySecretName(uuid)}` };
}
async function toLiveStream(
connection: ConnectorConnection,
video: PtVideo,
withSession: boolean,
): Promise<LiveStream> {
const state = liveStateOf(video);
const live: LiveStream = {
id: `${PROVIDER}:${connection.id}:${video.uuid}`,
provider: PROVIDER,
connectionId: connection.id,
remoteId: video.uuid,
title: video.name,
state,
playbackUrls: [liveHlsUrl(connection, video.uuid)],
};
if ((await secretOrUndefined(connection, streamKeySecretName(video.uuid))) !== undefined) {
live.streamKeyRef = streamKeyRef(connection, video.uuid);
}
if (withSession && (state === "live" || state === "ended")) {
const session = await latestSession(connection, video.uuid);
if (session !== undefined) {
live.startedAt = new Date(session.startDate);
if (session.endDate) live.endedAt = new Date(session.endDate);
}
}
return live;
}
function requireOpener(): (assetId: string) => Promise<PeerTubeVideoFile> {
const opener = options.openAsset;
if (opener === undefined) {
throw new ConnectorError(`${PROVIDER}: uploadVideo requires the openAsset option (asset id → bytes)`, PROVIDER);
}
return opener;
}
function commentRef(videoId: string, commentId: number): string {
return `${videoId}:${commentId}`;
}
function toComment(videoId: string, comment: PtComment): Comment {
return {
id: `${PROVIDER}:${commentRef(videoId, comment.id)}`,
provider: PROVIDER,
remoteId: commentRef(videoId, comment.id),
onRemoteId: videoId,
authorHandle: comment.account ? handleOf(comment.account) : "deleted",
text: htmlToText(comment.text),
createdAt: new Date(comment.createdAt),
};
}
function offsetOf(cursor: string | undefined): number {
if (cursor === undefined) return 0;
const start = Number(cursor);
if (!Number.isInteger(start) || start < 0) {
throw new ConnectorError(`${PROVIDER}: invalid cursor "${cursor}"`, PROVIDER);
}
return start;
}
function nextCursor(start: number, received: number, total: number): string | undefined {
const next = start + received;
return received > 0 && next < total ? String(next) : undefined;
}
const connector: PlatformConnector = {
async buildAuthorizationUrl(input) {
const env = options.env ?? {};
const instanceUrl = normalizeInstanceUrl(input.fields?.instanceUrl ?? "");
const authorizePath = env.PEERTUBE_AUTHORIZE_PATH;
const tokenPath = env.PEERTUBE_TOKEN_PATH;
if (!authorizePath?.startsWith("/") || authorizePath.startsWith("//") || authorizePath.includes("\\") || !tokenPath?.startsWith("/") || tokenPath.startsWith("//") || tokenPath.includes("\\")) {
throw new ConnectorError("PeerTube redirect linking requires a configured instance OAuth bridge", PROVIDER);
}
const connectionId = input.connectionId ?? randomUUID();
const connection: ConnectorConnection = {
id: connectionId, workspaceId: input.workspaceId, provider: PROVIDER, channelId: connectionId,
credentialId: { kind: "credential-ref", id: connectionId }, fields: { instanceUrl }, createdAt: new Date(),
};
const configured = env.PEERTUBE_INSTANCE_URL !== undefined && normalizeInstanceUrl(env.PEERTUBE_INSTANCE_URL) === instanceUrl;
const oauthClient = configured && env.PEERTUBE_CLIENT_ID && env.PEERTUBE_CLIENT_SECRET
? { client_id: env.PEERTUBE_CLIENT_ID, client_secret: env.PEERTUBE_CLIENT_SECRET }
: await publicCall<PtOAuthClient>(`${instanceUrl}/api/v1/oauth-clients/local`);
await credentials.put(connection, SECRET.clientId, oauthClient.client_id);
await credentials.put(connection, SECRET.clientSecret, oauthClient.client_secret);
const url = new URL(authorizePath, instanceUrl);
url.search = new URLSearchParams({ client_id: oauthClient.client_id, redirect_uri: input.redirectUri, response_type: "code", state: input.state }).toString();
if (input.codeVerifier) {
url.searchParams.set("code_challenge", createHash("sha256").update(input.codeVerifier).digest("base64url"));
url.searchParams.set("code_challenge_method", "S256");
}
return { authorizationUrl: url.toString(), connection };
},
async exchangeCode(input) {
const instanceUrl = normalizeInstanceUrl(input.fields?.instanceUrl ?? "");
const connectionId = input.connectionId ?? randomUUID();
const connection: ConnectorConnection = {
id: connectionId, workspaceId: input.workspaceId, provider: PROVIDER, channelId: connectionId,
credentialId: { kind: "credential-ref", id: connectionId }, fields: { instanceUrl }, createdAt: new Date(),
};
const path = options.env?.PEERTUBE_TOKEN_PATH;
if (!path?.startsWith("/") || path.startsWith("//") || path.includes("\\")) throw new ConnectorError("OAuth bridge is not configured", PROVIDER);
const token = await publicCall<PtToken>(new URL(path, instanceUrl).toString(), {
method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code", code: input.code, redirect_uri: input.redirectUri,
client_id: await credentials.getSecret(connection, SECRET.clientId),
client_secret: await credentials.getSecret(connection, SECRET.clientSecret),
...(input.codeVerifier ? { code_verifier: input.codeVerifier } : {}),
}).toString(),
});
await storeTokens(connection, token);
selves.delete(connection.id);
return { connection, identity: identityOf(connection, await self(connection)) };
},
manifest: () => {
assertValidManifest(manifest);
return manifest;
},
/**
* Single leg: verify the instance (GET /config/about), fetch the local
* OAuth client, exchange username + accountSecret for tokens, store the
* tokens and client credentials. The account secret is not stored and
* does not appear in the returned connection.
*/
async authorize(input: PeerTubeAuthorizationRequest): Promise<AuthorizationResult> {
const fields = input.fields ?? {};
const meta = input.providerMetadata ?? {};
const rawInstance = str(fields.instanceUrl);
const username = str(fields.username);
const accountSecret = fields.accountSecret;
if (rawInstance === undefined) throw new ConnectorError(`${PROVIDER}: authorize needs fields.instanceUrl`, PROVIDER);
if (username === undefined) throw new ConnectorError(`${PROVIDER}: authorize needs fields.username`, PROVIDER);
if (accountSecret === undefined || accountSecret.length === 0) {
throw new ConnectorError(`${PROVIDER}: authorize needs fields.accountSecret (account password)`, PROVIDER);
}
const instanceUrl = normalizeInstanceUrl(rawInstance);
const host = new URL(instanceUrl).host;
// Instance discovery doubles as a "is this really PeerTube" check.
try {
await publicCall<PtAbout>(`${instanceUrl}/api/v1/config/about`);
} catch (cause) {
throw new ConnectorError(`${PROVIDER}: ${host} does not answer as a PeerTube instance`, PROVIDER, cause);
}
const oauthClient = await publicCall<PtOAuthClient>(`${instanceUrl}/api/v1/oauth-clients/local`);
const connectionId = str(meta.connectionId) ?? `${PROVIDER}:${input.workspaceId}:${username}@${host}`;
const connection: ConnectorConnection = {
id: connectionId,
workspaceId: input.workspaceId,
provider: PROVIDER,
channelId: str(meta.channelId) ?? connectionId,
credentialId: { kind: "credential-ref", id: connectionId },
fields: { instanceUrl, username },
createdAt: new Date(),
};
const token = await tokenRequest(
instanceUrl,
{
grant_type: "password",
client_id: oauthClient.client_id,
client_secret: oauthClient.client_secret,
username,
password: accountSecret,
},
str(meta.otp),
);
await credentials.put(connection, SECRET.clientId, oauthClient.client_id);
await credentials.put(connection, SECRET.clientSecret, oauthClient.client_secret);
await storeTokens(connection, token);
selves.delete(connection.id);
const identity = identityOf(connection, await self(connection));
return { connection, identity };
},
async refreshCredentials(connection: ConnectorConnection): Promise<CredentialRef> {
await refresh(connection);
return connection.credentialId;
},
async revokeCredentials(connection: ConnectorConnection): Promise<void> {
await api<Record<string, never>>(connection, "POST", "/api/v1/users/revoke-token");
selves.delete(connection.id);
},
async getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
return identityOf(connection, await self(connection));
},
async getRemoteVideoChannels(connection: ConnectorConnection): Promise<RemoteVideoChannel[]> {
const me = await self(connection);
const instanceUrl = instanceOf(connection);
const channels = await api<PtList<PtActor>>(
connection,
"GET",
`/api/v1/accounts/${encodeURIComponent(me.account.name)}/video-channels?start=0&count=100`,
);
return channels.data.map((channel) => {
const remote: RemoteVideoChannel = {
id: `${PROVIDER}:${channel.host}:${channel.id}`,
provider: PROVIDER,
instanceUrl,
remoteChannelId: String(channel.id),
handle: handleOf(channel),
capabilities: CHANNEL_CAPABILITIES,
};
if (channel.displayName !== undefined) remote.displayName = channel.displayName;
const avatar = avatarUrl(instanceUrl, channel);
if (avatar !== undefined) remote.avatarUrl = avatar;
return remote;
});
},
/**
* Legacy multipart upload (POST /api/v1/videos/upload). providerMetadata:
* channelId (id or handle), privacy (1–4), category, licence, language,
* nsfw, waitTranscoding.
*/
async uploadVideo(
connection: ConnectorConnection,
video: NormalizedVideoPublication,
): Promise<{ remoteId: string }> {
const meta = video.providerMetadata;
const open = requireOpener();
const file = await open(video.videoAssetId);
const name = (video.title ?? file.filename.replace(/\.[^.]+$/, "")).trim();
if (name.length < NAME_MIN || name.length > NAME_MAX) {
throw new ConnectorError(
`${PROVIDER}: video name must be ${NAME_MIN}–${NAME_MAX} characters (got ${name.length})`,
PROVIDER,
);
}
const tags = video.tags ?? [];
if (tags.length > TAGS_MAX) {
throw new ConnectorError(`${PROVIDER}: at most ${TAGS_MAX} tags per video (got ${tags.length})`, PROVIDER);
}
const channelId = await resolveChannelId(connection, str(meta.channelId) ?? str(connection.fields.channelId));
const privacy = privacyOf(video.visibility, meta.privacy);
const form = new FormData();
form.append("videofile", new Blob([file.bytes], { type: file.mimeType }), file.filename);
form.append("name", name);
form.append("channelId", String(channelId));
if (privacy !== undefined) form.append("privacy", String(privacy));
if (video.description !== undefined) form.append("description", video.description);
// Same array encoding as PeerTube's own web client (objectToFormData).
tags.forEach((tag, index) => form.append(`tags[${index}]`, tag));
for (const key of ["category", "licence"] as const) {
if (typeof meta[key] === "number") form.append(key, String(meta[key]));
}
if (typeof meta.language === "string") form.append("language", meta.language);
if (typeof meta.nsfw === "boolean") form.append("nsfw", String(meta.nsfw));
if (typeof meta.waitTranscoding === "boolean") form.append("waitTranscoding", String(meta.waitTranscoding));
if (video.thumbnailAssetId !== undefined) {
const thumb = await open(video.thumbnailAssetId);
form.append("thumbnailfile", new Blob([thumb.bytes], { type: thumb.mimeType }), thumb.filename);
}
const result = await api<PtVideoRef>(connection, "POST", "/api/v1/videos/upload", form);
return { remoteId: result.video.uuid };
},
async getVideoStatus(connection: ConnectorConnection, remoteId: string): Promise<VideoProcessingStatus> {
const video = await getVideo(connection, remoteId);
const state = processingStateOf(video);
const status: VideoProcessingStatus = { remoteId: video.uuid, state };
if (state === "failed") status.error = video.state?.label ?? `state ${video.state?.id ?? "unknown"}`;
return status;
},
async getVideos(
connection: ConnectorConnection,
request: { cursor?: string; limit: number },
): Promise<Page<{ remoteId: string; title?: string; createdAt?: Date }>> {
const start = offsetOf(request.cursor);
const params = new URLSearchParams({ start: String(start), count: String(request.limit), sort: "-createdAt" });
const list = await api<PtList<PtVideo>>(connection, "GET", `/api/v1/users/me/videos?${params}`);
const items = list.data.map((video) => {
const item: { remoteId: string; title?: string; createdAt?: Date } = { remoteId: video.uuid, title: video.name };
if (video.createdAt !== undefined) item.createdAt = new Date(video.createdAt);
return item;
});
return pageOf(items, nextCursor(start, list.data.length, list.total));
},
async getComments(
connection: ConnectorConnection,
request: { onRemoteId: string; cursor?: string; limit: number },
): Promise<Page<Comment>> {
const start = offsetOf(request.cursor);
const params = new URLSearchParams({ start: String(start), count: String(request.limit), sort: "-createdAt" });
const list = await api<PtList<PtComment>>(
connection,
"GET",
`${videoPath(request.onRemoteId)}/comment-threads?${params}`,
);
const items = list.data
.filter((comment) => comment.isDeleted !== true)
.map((comment) => toComment(request.onRemoteId, comment));
return pageOf(items, nextCursor(start, list.data.length, list.total));
},
/**
* onRemoteId `<video>` starts a new comment thread on the video;
* `<video>:<commentId>` (a Comment.remoteId) replies to that comment.
*/
async reply(
connection: ConnectorConnection,
request: { onRemoteId: string; text: string },
): Promise<Publication> {
if (request.text.trim().length === 0) {
throw new ConnectorError(`${PROVIDER}: reply text is empty`, PROVIDER);
}
const separator = request.onRemoteId.lastIndexOf(":");
const videoId = separator === -1 ? request.onRemoteId : request.onRemoteId.slice(0, separator);
const commentId = separator === -1 ? undefined : request.onRemoteId.slice(separator + 1);
if (videoId.length === 0 || (commentId !== undefined && !/^\d+$/.test(commentId))) {
throw new ConnectorError(`${PROVIDER}: malformed reply target "${request.onRemoteId}"`, PROVIDER);
}
const path =
commentId === undefined
? `${videoPath(videoId)}/comment-threads`
: `${videoPath(videoId)}/comments/${commentId}`;
const { comment } = await api<{ comment: PtComment }>(connection, "POST", path, { text: request.text });
const remoteId = commentRef(videoId, comment.id);
const publication: Publication = {
id: `pt-${(publicationSeq += 1)}-${remoteId}`,
workspaceId: connection.workspaceId,
draftId: "reply",
connectionId: connection.id,
remoteId,
publishedAt: new Date(comment.createdAt),
providerMetadata: { videoId, inReplyTo: commentId ?? null, commentId: comment.id },
};
if (comment.url !== undefined) publication.remoteUrl = comment.url;
return publication;
},
/**
* POST /api/v1/videos/live, then read the owner-only stream key and put
* it in the vault. The returned LiveStream carries a credential-ref.
*/
async createLiveStream(
connection: ConnectorConnection,
request: PeerTubeCreateLiveRequest,
): Promise<LiveStream> {
if (request.permanent && request.saveReplay) {
throw new ConnectorError(
`${PROVIDER}: permanent lives cannot save replays (PeerTube rejects saveReplay + permanentLive)`,
PROVIDER,
);
}
const title = request.title.trim();
if (title.length < NAME_MIN || title.length > NAME_MAX) {
throw new ConnectorError(`${PROVIDER}: live title must be ${NAME_MIN}–${NAME_MAX} characters`, PROVIDER);
}
const channelId = await resolveChannelId(connection, str(request.channelId));
const body: Record<string, unknown> = {
channelId,
name: title,
saveReplay: request.saveReplay,
permanentLive: request.permanent,
latencyMode: request.latencyMode ?? options.defaultLatencyMode ?? 1,
};
const privacy = privacyOf(request.visibility, undefined);
if (privacy !== undefined) body.privacy = privacy;
const created = await api<PtVideoRef>(connection, "POST", "/api/v1/videos/live", body);
const uuid = created.video.uuid;
const settings = await api<PtLive>(connection, "GET", `/api/v1/videos/live/${encodeURIComponent(uuid)}`);
const live: LiveStream = {
id: `${PROVIDER}:${connection.id}:${uuid}`,
provider: PROVIDER,
connectionId: connection.id,
remoteId: uuid,
title,
state: "created",
playbackUrls: [liveHlsUrl(connection, uuid)],
};
if (settings.streamKey !== undefined && settings.streamKey.length > 0) {
await credentials.put(connection, streamKeySecretName(uuid), settings.streamKey);
live.streamKeyRef = streamKeyRef(connection, uuid);
}
const ingest = settings.rtmpsUrl ?? settings.rtmpUrl;
if (ingest) live.rtmpUrl = ingest;
return live;
},
/**
* Title lives on the video (PUT /api/v1/videos/{id}, multipart), not on
* the live settings — so it can be renamed even while running.
*/
async updateLiveStream(
connection: ConnectorConnection,
request: { remoteId: string; title?: string },
): Promise<LiveStream> {
if (request.title !== undefined) {
const title = request.title.trim();
if (title.length < NAME_MIN || title.length > NAME_MAX) {
throw new ConnectorError(`${PROVIDER}: live title must be ${NAME_MIN}–${NAME_MAX} characters`, PROVIDER);
}
const form = new FormData();
form.append("name", title);
await api<Record<string, never>>(connection, "PUT", videoPath(request.remoteId), form);
}
const video = await getVideo(connection, request.remoteId);
if (video.isLive !== true) {
throw new ConnectorError(`${PROVIDER}: video ${request.remoteId} is not a live`, PROVIDER);
}
return toLiveStream(connection, video, true);
},
async getLiveStreams(
connection: ConnectorConnection,
request: { cursor?: string; limit: number },
): Promise<Page<LiveStream>> {
const start = offsetOf(request.cursor);
const params = new URLSearchParams({
start: String(start),
count: String(request.limit),
sort: "-createdAt",
isLive: "true",
});
const list = await api<PtList<PtVideo>>(connection, "GET", `/api/v1/users/me/videos?${params}`);
// Filter again client-side: older servers may ignore the isLive filter.
const lives = list.data.filter((video) => video.isLive === true);
const items = await Promise.all(lives.map((video) => toLiveStream(connection, video, true)));
return pageOf(items, nextCursor(start, list.data.length, list.total));
},
/**
* Lives: the master playlist is deterministic (`master.m3u8` under the
* video's HLS directory) so monitors can probe it before the stream
* starts. VOD: the API's streamingPlaylists[].playlistUrl is the only
* reliable source (the filename is randomized). Plus the embed page.
*/
async getStreamPlaybackSources(
connection: ConnectorConnection,
request: { remoteId: string },
): Promise<StreamPlaybackSource[]> {
const video = await getVideo(connection, request.remoteId);
const instanceUrl = instanceOf(connection);
const hlsUrls =
video.isLive === true
? [liveHlsUrl(connection, video.uuid)]
: (video.streamingPlaylists ?? []).filter((p) => p.type === 1).map((p) => p.playlistUrl);
const sources: StreamPlaybackSource[] = hlsUrls.map((url) => ({
remoteId: video.uuid,
url: new URL(url, instanceUrl).toString(),
kind: "hls",
protocol: "https",
}));
sources.push({
remoteId: video.uuid,
url: `${instanceUrl}/videos/embed/${video.uuid}`,
kind: "embed",
protocol: "https",
});
return sources;
},
/**
* With subjectRemoteId: owner video stats (overall + viewers timeseries).
* Without: instance-wide /server/stats (no time series — from/to unused).
*/
async getAnalytics(
connection: ConnectorConnection,
request: { subjectRemoteId?: string; from: Date; to: Date },
): Promise<{ values: Record<string, number>; series: { at: Date; values: Record<string, number> }[] }> {
if (request.subjectRemoteId === undefined) {
const stats = await api<unknown>(connection, "GET", "/api/v1/server/stats");
return { values: numericValues(stats), series: [] };
}
const range = new URLSearchParams({
startDate: request.from.toISOString(),
endDate: request.to.toISOString(),
});
const base = `${videoPath(request.subjectRemoteId)}/stats`;
const overall = await api<unknown>(connection, "GET", `${base}/overall?${range}`);
const viewers = await api<PtTimeseries>(connection, "GET", `${base}/timeseries/viewers?${range}`);
return {
values: numericValues(overall),
series: (viewers.data ?? []).map((point) => ({ at: new Date(point.date), values: { viewers: point.value } })),
};
},
};
// WP7 bulk-import seam: unauthenticated resolution over the supplied
// transport (an egress-backed fetch in the API, a stub in tests). Public
// endpoints only — resolving other users' channels never needs the token.
return Object.assign(connector, { resolveSource: resolvePeerTubeSource });
}