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/.
1177 lines
45 KiB
TypeScript
1177 lines
45 KiB
TypeScript
/**
|
||
* 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 });
|
||
}
|