From 3a4992633ee62d5edfbb484d9c6bcb3cf158489d Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 31 Jul 2023 14:34:36 +0200 Subject: Migrate server to ESM Sorry for the very big commit that may lead to git log issues and merge conflicts, but it's a major step forward: * Server can be faster at startup because imports() are async and we can easily lazy import big modules * Angular doesn't seem to support ES import (with .js extension), so we had to correctly organize peertube into a monorepo: * Use yarn workspace feature * Use typescript reference projects for dependencies * Shared projects have been moved into "packages", each one is now a node module (with a dedicated package.json/tsconfig.json) * server/tools have been moved into apps/ and is now a dedicated app bundled and published on NPM so users don't have to build peertube cli tools manually * server/tests have been moved into packages/ so we don't compile them every time we want to run the server * Use isolatedModule option: * Had to move from const enum to const (https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums) * Had to explictely specify "type" imports when used in decorators * Prefer tsx (that uses esbuild under the hood) instead of ts-node to load typescript files (tests with mocha or scripts): * To reduce test complexity as esbuild doesn't support decorator metadata, we only test server files that do not import server models * We still build tests files into js files for a faster CI * Remove unmaintained peertube CLI import script * Removed some barrels to speed up execution (less imports) --- packages/server-commands/src/bulk/bulk-command.ts | 20 + packages/server-commands/src/bulk/index.ts | 1 + packages/server-commands/src/cli/cli-command.ts | 27 + packages/server-commands/src/cli/index.ts | 1 + .../src/custom-pages/custom-pages-command.ts | 33 + packages/server-commands/src/custom-pages/index.ts | 1 + .../server-commands/src/feeds/feeds-command.ts | 78 ++ packages/server-commands/src/feeds/index.ts | 1 + packages/server-commands/src/index.ts | 14 + packages/server-commands/src/logs/index.ts | 1 + packages/server-commands/src/logs/logs-command.ts | 56 ++ .../src/moderation/abuses-command.ts | 228 ++++++ packages/server-commands/src/moderation/index.ts | 1 + packages/server-commands/src/overviews/index.ts | 1 + .../src/overviews/overviews-command.ts | 23 + packages/server-commands/src/requests/index.ts | 1 + packages/server-commands/src/requests/requests.ts | 260 +++++++ packages/server-commands/src/runners/index.ts | 3 + .../src/runners/runner-jobs-command.ts | 297 ++++++++ .../runners/runner-registration-tokens-command.ts | 55 ++ .../server-commands/src/runners/runners-command.ts | 85 +++ packages/server-commands/src/search/index.ts | 1 + .../server-commands/src/search/search-command.ts | 98 +++ .../server-commands/src/server/config-command.ts | 576 ++++++++++++++ .../src/server/contact-form-command.ts | 30 + .../server-commands/src/server/debug-command.ts | 33 + .../server-commands/src/server/follows-command.ts | 139 ++++ packages/server-commands/src/server/follows.ts | 20 + packages/server-commands/src/server/index.ts | 15 + .../server-commands/src/server/jobs-command.ts | 84 +++ packages/server-commands/src/server/jobs.ts | 117 +++ .../server-commands/src/server/metrics-command.ts | 18 + .../src/server/object-storage-command.ts | 165 ++++ .../server-commands/src/server/plugins-command.ts | 258 +++++++ .../src/server/redundancy-command.ts | 80 ++ packages/server-commands/src/server/server.ts | 451 +++++++++++ .../server-commands/src/server/servers-command.ts | 104 +++ packages/server-commands/src/server/servers.ts | 68 ++ .../server-commands/src/server/stats-command.ts | 25 + .../server-commands/src/shared/abstract-command.ts | 225 ++++++ packages/server-commands/src/shared/index.ts | 1 + packages/server-commands/src/socket/index.ts | 1 + .../src/socket/socket-io-command.ts | 24 + .../server-commands/src/users/accounts-command.ts | 76 ++ packages/server-commands/src/users/accounts.ts | 15 + .../server-commands/src/users/blocklist-command.ts | 165 ++++ packages/server-commands/src/users/index.ts | 10 + .../server-commands/src/users/login-command.ts | 159 ++++ packages/server-commands/src/users/login.ts | 19 + .../src/users/notifications-command.ts | 85 +++ .../src/users/registrations-command.ts | 157 ++++ .../src/users/subscriptions-command.ts | 83 ++ .../src/users/two-factor-command.ts | 92 +++ .../server-commands/src/users/users-command.ts | 389 ++++++++++ .../src/videos/blacklist-command.ts | 74 ++ .../server-commands/src/videos/captions-command.ts | 67 ++ .../src/videos/change-ownership-command.ts | 67 ++ .../src/videos/channel-syncs-command.ts | 55 ++ .../server-commands/src/videos/channels-command.ts | 202 +++++ packages/server-commands/src/videos/channels.ts | 29 + .../server-commands/src/videos/comments-command.ts | 159 ++++ .../server-commands/src/videos/history-command.ts | 54 ++ .../server-commands/src/videos/imports-command.ts | 76 ++ packages/server-commands/src/videos/index.ts | 22 + .../server-commands/src/videos/live-command.ts | 339 +++++++++ packages/server-commands/src/videos/live.ts | 129 ++++ .../src/videos/playlists-command.ts | 281 +++++++ .../server-commands/src/videos/services-command.ts | 29 + .../src/videos/storyboard-command.ts | 19 + .../src/videos/streaming-playlists-command.ts | 119 +++ .../src/videos/video-passwords-command.ts | 56 ++ .../src/videos/video-stats-command.ts | 62 ++ .../src/videos/video-studio-command.ts | 67 ++ .../src/videos/video-token-command.ts | 34 + .../server-commands/src/videos/videos-command.ts | 831 +++++++++++++++++++++ .../server-commands/src/videos/views-command.ts | 51 ++ 76 files changed, 7792 insertions(+) create mode 100644 packages/server-commands/src/bulk/bulk-command.ts create mode 100644 packages/server-commands/src/bulk/index.ts create mode 100644 packages/server-commands/src/cli/cli-command.ts create mode 100644 packages/server-commands/src/cli/index.ts create mode 100644 packages/server-commands/src/custom-pages/custom-pages-command.ts create mode 100644 packages/server-commands/src/custom-pages/index.ts create mode 100644 packages/server-commands/src/feeds/feeds-command.ts create mode 100644 packages/server-commands/src/feeds/index.ts create mode 100644 packages/server-commands/src/index.ts create mode 100644 packages/server-commands/src/logs/index.ts create mode 100644 packages/server-commands/src/logs/logs-command.ts create mode 100644 packages/server-commands/src/moderation/abuses-command.ts create mode 100644 packages/server-commands/src/moderation/index.ts create mode 100644 packages/server-commands/src/overviews/index.ts create mode 100644 packages/server-commands/src/overviews/overviews-command.ts create mode 100644 packages/server-commands/src/requests/index.ts create mode 100644 packages/server-commands/src/requests/requests.ts create mode 100644 packages/server-commands/src/runners/index.ts create mode 100644 packages/server-commands/src/runners/runner-jobs-command.ts create mode 100644 packages/server-commands/src/runners/runner-registration-tokens-command.ts create mode 100644 packages/server-commands/src/runners/runners-command.ts create mode 100644 packages/server-commands/src/search/index.ts create mode 100644 packages/server-commands/src/search/search-command.ts create mode 100644 packages/server-commands/src/server/config-command.ts create mode 100644 packages/server-commands/src/server/contact-form-command.ts create mode 100644 packages/server-commands/src/server/debug-command.ts create mode 100644 packages/server-commands/src/server/follows-command.ts create mode 100644 packages/server-commands/src/server/follows.ts create mode 100644 packages/server-commands/src/server/index.ts create mode 100644 packages/server-commands/src/server/jobs-command.ts create mode 100644 packages/server-commands/src/server/jobs.ts create mode 100644 packages/server-commands/src/server/metrics-command.ts create mode 100644 packages/server-commands/src/server/object-storage-command.ts create mode 100644 packages/server-commands/src/server/plugins-command.ts create mode 100644 packages/server-commands/src/server/redundancy-command.ts create mode 100644 packages/server-commands/src/server/server.ts create mode 100644 packages/server-commands/src/server/servers-command.ts create mode 100644 packages/server-commands/src/server/servers.ts create mode 100644 packages/server-commands/src/server/stats-command.ts create mode 100644 packages/server-commands/src/shared/abstract-command.ts create mode 100644 packages/server-commands/src/shared/index.ts create mode 100644 packages/server-commands/src/socket/index.ts create mode 100644 packages/server-commands/src/socket/socket-io-command.ts create mode 100644 packages/server-commands/src/users/accounts-command.ts create mode 100644 packages/server-commands/src/users/accounts.ts create mode 100644 packages/server-commands/src/users/blocklist-command.ts create mode 100644 packages/server-commands/src/users/index.ts create mode 100644 packages/server-commands/src/users/login-command.ts create mode 100644 packages/server-commands/src/users/login.ts create mode 100644 packages/server-commands/src/users/notifications-command.ts create mode 100644 packages/server-commands/src/users/registrations-command.ts create mode 100644 packages/server-commands/src/users/subscriptions-command.ts create mode 100644 packages/server-commands/src/users/two-factor-command.ts create mode 100644 packages/server-commands/src/users/users-command.ts create mode 100644 packages/server-commands/src/videos/blacklist-command.ts create mode 100644 packages/server-commands/src/videos/captions-command.ts create mode 100644 packages/server-commands/src/videos/change-ownership-command.ts create mode 100644 packages/server-commands/src/videos/channel-syncs-command.ts create mode 100644 packages/server-commands/src/videos/channels-command.ts create mode 100644 packages/server-commands/src/videos/channels.ts create mode 100644 packages/server-commands/src/videos/comments-command.ts create mode 100644 packages/server-commands/src/videos/history-command.ts create mode 100644 packages/server-commands/src/videos/imports-command.ts create mode 100644 packages/server-commands/src/videos/index.ts create mode 100644 packages/server-commands/src/videos/live-command.ts create mode 100644 packages/server-commands/src/videos/live.ts create mode 100644 packages/server-commands/src/videos/playlists-command.ts create mode 100644 packages/server-commands/src/videos/services-command.ts create mode 100644 packages/server-commands/src/videos/storyboard-command.ts create mode 100644 packages/server-commands/src/videos/streaming-playlists-command.ts create mode 100644 packages/server-commands/src/videos/video-passwords-command.ts create mode 100644 packages/server-commands/src/videos/video-stats-command.ts create mode 100644 packages/server-commands/src/videos/video-studio-command.ts create mode 100644 packages/server-commands/src/videos/video-token-command.ts create mode 100644 packages/server-commands/src/videos/videos-command.ts create mode 100644 packages/server-commands/src/videos/views-command.ts (limited to 'packages/server-commands/src') diff --git a/packages/server-commands/src/bulk/bulk-command.ts b/packages/server-commands/src/bulk/bulk-command.ts new file mode 100644 index 000000000..784836e19 --- /dev/null +++ b/packages/server-commands/src/bulk/bulk-command.ts @@ -0,0 +1,20 @@ +import { BulkRemoveCommentsOfBody, HttpStatusCode } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class BulkCommand extends AbstractCommand { + + removeCommentsOf (options: OverrideCommandOptions & { + attributes: BulkRemoveCommentsOfBody + }) { + const { attributes } = options + + return this.postBodyRequest({ + ...options, + + path: '/api/v1/bulk/remove-comments-of', + fields: attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/bulk/index.ts b/packages/server-commands/src/bulk/index.ts new file mode 100644 index 000000000..903f7a282 --- /dev/null +++ b/packages/server-commands/src/bulk/index.ts @@ -0,0 +1 @@ +export * from './bulk-command.js' diff --git a/packages/server-commands/src/cli/cli-command.ts b/packages/server-commands/src/cli/cli-command.ts new file mode 100644 index 000000000..8b9400c85 --- /dev/null +++ b/packages/server-commands/src/cli/cli-command.ts @@ -0,0 +1,27 @@ +import { exec } from 'child_process' +import { AbstractCommand } from '../shared/index.js' + +export class CLICommand extends AbstractCommand { + + static exec (command: string) { + return new Promise((res, rej) => { + exec(command, (err, stdout, _stderr) => { + if (err) return rej(err) + + return res(stdout) + }) + }) + } + + getEnv () { + return `NODE_ENV=test NODE_APP_INSTANCE=${this.server.internalServerNumber}` + } + + async execWithEnv (command: string, configOverride?: any) { + const prefix = configOverride + ? `NODE_CONFIG='${JSON.stringify(configOverride)}'` + : '' + + return CLICommand.exec(`${prefix} ${this.getEnv()} ${command}`) + } +} diff --git a/packages/server-commands/src/cli/index.ts b/packages/server-commands/src/cli/index.ts new file mode 100644 index 000000000..d79b13a76 --- /dev/null +++ b/packages/server-commands/src/cli/index.ts @@ -0,0 +1 @@ +export * from './cli-command.js' diff --git a/packages/server-commands/src/custom-pages/custom-pages-command.ts b/packages/server-commands/src/custom-pages/custom-pages-command.ts new file mode 100644 index 000000000..412f3f763 --- /dev/null +++ b/packages/server-commands/src/custom-pages/custom-pages-command.ts @@ -0,0 +1,33 @@ +import { CustomPage, HttpStatusCode } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class CustomPagesCommand extends AbstractCommand { + + getInstanceHomepage (options: OverrideCommandOptions = {}) { + const path = '/api/v1/custom-pages/homepage/instance' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateInstanceHomepage (options: OverrideCommandOptions & { + content: string + }) { + const { content } = options + const path = '/api/v1/custom-pages/homepage/instance' + + return this.putBodyRequest({ + ...options, + + path, + fields: { content }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/custom-pages/index.ts b/packages/server-commands/src/custom-pages/index.ts new file mode 100644 index 000000000..67f537f07 --- /dev/null +++ b/packages/server-commands/src/custom-pages/index.ts @@ -0,0 +1 @@ +export * from './custom-pages-command.js' diff --git a/packages/server-commands/src/feeds/feeds-command.ts b/packages/server-commands/src/feeds/feeds-command.ts new file mode 100644 index 000000000..51bc45b7f --- /dev/null +++ b/packages/server-commands/src/feeds/feeds-command.ts @@ -0,0 +1,78 @@ +import { buildUUID } from '@peertube/peertube-node-utils' +import { HttpStatusCode } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +type FeedType = 'videos' | 'video-comments' | 'subscriptions' + +export class FeedCommand extends AbstractCommand { + + getXML (options: OverrideCommandOptions & { + feed: FeedType + ignoreCache: boolean + format?: string + }) { + const { feed, format, ignoreCache } = options + const path = '/feeds/' + feed + '.xml' + + const query: { [id: string]: string } = {} + + if (ignoreCache) query.v = buildUUID() + if (format) query.format = format + + return this.getRequestText({ + ...options, + + path, + query, + accept: 'application/xml', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getPodcastXML (options: OverrideCommandOptions & { + ignoreCache: boolean + channelId: number + }) { + const { ignoreCache, channelId } = options + const path = `/feeds/podcast/videos.xml` + + const query: { [id: string]: string } = {} + + if (ignoreCache) query.v = buildUUID() + if (channelId) query.videoChannelId = channelId + '' + + return this.getRequestText({ + ...options, + + path, + query, + accept: 'application/xml', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getJSON (options: OverrideCommandOptions & { + feed: FeedType + ignoreCache: boolean + query?: { [ id: string ]: any } + }) { + const { feed, query = {}, ignoreCache } = options + const path = '/feeds/' + feed + '.json' + + const cacheQuery = ignoreCache + ? { v: buildUUID() } + : {} + + return this.getRequestText({ + ...options, + + path, + query: { ...query, ...cacheQuery }, + accept: 'application/json', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/feeds/index.ts b/packages/server-commands/src/feeds/index.ts new file mode 100644 index 000000000..316ebb974 --- /dev/null +++ b/packages/server-commands/src/feeds/index.ts @@ -0,0 +1 @@ +export * from './feeds-command.js' diff --git a/packages/server-commands/src/index.ts b/packages/server-commands/src/index.ts new file mode 100644 index 000000000..382fe966e --- /dev/null +++ b/packages/server-commands/src/index.ts @@ -0,0 +1,14 @@ +export * from './bulk/index.js' +export * from './cli/index.js' +export * from './custom-pages/index.js' +export * from './feeds/index.js' +export * from './logs/index.js' +export * from './moderation/index.js' +export * from './overviews/index.js' +export * from './requests/index.js' +export * from './runners/index.js' +export * from './search/index.js' +export * from './server/index.js' +export * from './socket/index.js' +export * from './users/index.js' +export * from './videos/index.js' diff --git a/packages/server-commands/src/logs/index.ts b/packages/server-commands/src/logs/index.ts new file mode 100644 index 000000000..37e77901c --- /dev/null +++ b/packages/server-commands/src/logs/index.ts @@ -0,0 +1 @@ +export * from './logs-command.js' diff --git a/packages/server-commands/src/logs/logs-command.ts b/packages/server-commands/src/logs/logs-command.ts new file mode 100644 index 000000000..d5d11b997 --- /dev/null +++ b/packages/server-commands/src/logs/logs-command.ts @@ -0,0 +1,56 @@ +import { ClientLogCreate, HttpStatusCode, ServerLogLevel } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class LogsCommand extends AbstractCommand { + + createLogClient (options: OverrideCommandOptions & { payload: ClientLogCreate }) { + const path = '/api/v1/server/logs/client' + + return this.postBodyRequest({ + ...options, + + path, + fields: options.payload, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getLogs (options: OverrideCommandOptions & { + startDate: Date + endDate?: Date + level?: ServerLogLevel + tagsOneOf?: string[] + }) { + const { startDate, endDate, tagsOneOf, level } = options + const path = '/api/v1/server/logs' + + return this.getRequestBody({ + ...options, + + path, + query: { startDate, endDate, level, tagsOneOf }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getAuditLogs (options: OverrideCommandOptions & { + startDate: Date + endDate?: Date + }) { + const { startDate, endDate } = options + + const path = '/api/v1/server/audit-logs' + + return this.getRequestBody({ + ...options, + + path, + query: { startDate, endDate }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + +} diff --git a/packages/server-commands/src/moderation/abuses-command.ts b/packages/server-commands/src/moderation/abuses-command.ts new file mode 100644 index 000000000..e267709e2 --- /dev/null +++ b/packages/server-commands/src/moderation/abuses-command.ts @@ -0,0 +1,228 @@ +import { pick } from '@peertube/peertube-core-utils' +import { + AbuseFilter, + AbuseMessage, + AbusePredefinedReasonsString, + AbuseStateType, + AbuseUpdate, + AbuseVideoIs, + AdminAbuse, + HttpStatusCode, + ResultList, + UserAbuse +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/requests.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class AbusesCommand extends AbstractCommand { + + report (options: OverrideCommandOptions & { + reason: string + + accountId?: number + videoId?: number + commentId?: number + + predefinedReasons?: AbusePredefinedReasonsString[] + + startAt?: number + endAt?: number + }) { + const path = '/api/v1/abuses' + + const video = options.videoId + ? { + id: options.videoId, + startAt: options.startAt, + endAt: options.endAt + } + : undefined + + const comment = options.commentId + ? { id: options.commentId } + : undefined + + const account = options.accountId + ? { id: options.accountId } + : undefined + + const body = { + account, + video, + comment, + + reason: options.reason, + predefinedReasons: options.predefinedReasons + } + + return unwrapBody<{ abuse: { id: number } }>(this.postBodyRequest({ + ...options, + + path, + fields: body, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + getAdminList (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + + id?: number + predefinedReason?: AbusePredefinedReasonsString + search?: string + filter?: AbuseFilter + state?: AbuseStateType + videoIs?: AbuseVideoIs + searchReporter?: string + searchReportee?: string + searchVideo?: string + searchVideoChannel?: string + } = {}) { + const toPick: (keyof typeof options)[] = [ + 'count', + 'filter', + 'id', + 'predefinedReason', + 'search', + 'searchReportee', + 'searchReporter', + 'searchVideo', + 'searchVideoChannel', + 'sort', + 'start', + 'state', + 'videoIs' + ] + + const path = '/api/v1/abuses' + + const defaultQuery = { sort: 'createdAt' } + const query = { ...defaultQuery, ...pick(options, toPick) } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getUserList (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + + id?: number + search?: string + state?: AbuseStateType + }) { + const toPick: (keyof typeof options)[] = [ + 'id', + 'search', + 'state', + 'start', + 'count', + 'sort' + ] + + const path = '/api/v1/users/me/abuses' + + const defaultQuery = { sort: 'createdAt' } + const query = { ...defaultQuery, ...pick(options, toPick) } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + update (options: OverrideCommandOptions & { + abuseId: number + body: AbuseUpdate + }) { + const { abuseId, body } = options + const path = '/api/v1/abuses/' + abuseId + + return this.putBodyRequest({ + ...options, + + path, + fields: body, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + delete (options: OverrideCommandOptions & { + abuseId: number + }) { + const { abuseId } = options + const path = '/api/v1/abuses/' + abuseId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + listMessages (options: OverrideCommandOptions & { + abuseId: number + }) { + const { abuseId } = options + const path = '/api/v1/abuses/' + abuseId + '/messages' + + return this.getRequestBody>({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + deleteMessage (options: OverrideCommandOptions & { + abuseId: number + messageId: number + }) { + const { abuseId, messageId } = options + const path = '/api/v1/abuses/' + abuseId + '/messages/' + messageId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + addMessage (options: OverrideCommandOptions & { + abuseId: number + message: string + }) { + const { abuseId, message } = options + const path = '/api/v1/abuses/' + abuseId + '/messages' + + return this.postBodyRequest({ + ...options, + + path, + fields: { message }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + +} diff --git a/packages/server-commands/src/moderation/index.ts b/packages/server-commands/src/moderation/index.ts new file mode 100644 index 000000000..8164afd7c --- /dev/null +++ b/packages/server-commands/src/moderation/index.ts @@ -0,0 +1 @@ +export * from './abuses-command.js' diff --git a/packages/server-commands/src/overviews/index.ts b/packages/server-commands/src/overviews/index.ts new file mode 100644 index 000000000..54c90705a --- /dev/null +++ b/packages/server-commands/src/overviews/index.ts @@ -0,0 +1 @@ +export * from './overviews-command.js' diff --git a/packages/server-commands/src/overviews/overviews-command.ts b/packages/server-commands/src/overviews/overviews-command.ts new file mode 100644 index 000000000..decd2fd8e --- /dev/null +++ b/packages/server-commands/src/overviews/overviews-command.ts @@ -0,0 +1,23 @@ +import { HttpStatusCode, VideosOverview } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class OverviewsCommand extends AbstractCommand { + + getVideos (options: OverrideCommandOptions & { + page: number + }) { + const { page } = options + const path = '/api/v1/overviews/videos' + + const query = { page } + + return this.getRequestBody({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/requests/index.ts b/packages/server-commands/src/requests/index.ts new file mode 100644 index 000000000..4c818659e --- /dev/null +++ b/packages/server-commands/src/requests/index.ts @@ -0,0 +1 @@ +export * from './requests.js' diff --git a/packages/server-commands/src/requests/requests.ts b/packages/server-commands/src/requests/requests.ts new file mode 100644 index 000000000..ac143ea5d --- /dev/null +++ b/packages/server-commands/src/requests/requests.ts @@ -0,0 +1,260 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ + +import { decode } from 'querystring' +import request from 'supertest' +import { URL } from 'url' +import { pick } from '@peertube/peertube-core-utils' +import { HttpStatusCode, HttpStatusCodeType } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' + +export type CommonRequestParams = { + url: string + path?: string + contentType?: string + responseType?: string + range?: string + redirects?: number + accept?: string + host?: string + token?: string + headers?: { [ name: string ]: string } + type?: string + xForwardedFor?: string + expectedStatus?: HttpStatusCodeType +} + +function makeRawRequest (options: { + url: string + token?: string + expectedStatus?: HttpStatusCodeType + range?: string + query?: { [ id: string ]: string } + method?: 'GET' | 'POST' + headers?: { [ name: string ]: string } +}) { + const { host, protocol, pathname } = new URL(options.url) + + const reqOptions = { + url: `${protocol}//${host}`, + path: pathname, + contentType: undefined, + + ...pick(options, [ 'expectedStatus', 'range', 'token', 'query', 'headers' ]) + } + + if (options.method === 'POST') { + return makePostBodyRequest(reqOptions) + } + + return makeGetRequest(reqOptions) +} + +function makeGetRequest (options: CommonRequestParams & { + query?: any + rawQuery?: string +}) { + const req = request(options.url).get(options.path) + + if (options.query) req.query(options.query) + if (options.rawQuery) req.query(options.rawQuery) + + return buildRequest(req, { contentType: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) +} + +function makeHTMLRequest (url: string, path: string) { + return makeGetRequest({ + url, + path, + accept: 'text/html', + expectedStatus: HttpStatusCode.OK_200 + }) +} + +function makeActivityPubGetRequest (url: string, path: string, expectedStatus: HttpStatusCodeType = HttpStatusCode.OK_200) { + return makeGetRequest({ + url, + path, + expectedStatus, + accept: 'application/activity+json,text/html;q=0.9,\\*/\\*;q=0.8' + }) +} + +function makeDeleteRequest (options: CommonRequestParams & { + query?: any + rawQuery?: string +}) { + const req = request(options.url).delete(options.path) + + if (options.query) req.query(options.query) + if (options.rawQuery) req.query(options.rawQuery) + + return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) +} + +function makeUploadRequest (options: CommonRequestParams & { + method?: 'POST' | 'PUT' + + fields: { [ fieldName: string ]: any } + attaches?: { [ attachName: string ]: any | any[] } +}) { + let req = options.method === 'PUT' + ? request(options.url).put(options.path) + : request(options.url).post(options.path) + + req = buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) + + buildFields(req, options.fields) + + Object.keys(options.attaches || {}).forEach(attach => { + const value = options.attaches[attach] + if (!value) return + + if (Array.isArray(value)) { + req.attach(attach, buildAbsoluteFixturePath(value[0]), value[1]) + } else { + req.attach(attach, buildAbsoluteFixturePath(value)) + } + }) + + return req +} + +function makePostBodyRequest (options: CommonRequestParams & { + fields?: { [ fieldName: string ]: any } +}) { + const req = request(options.url).post(options.path) + .send(options.fields) + + return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) +} + +function makePutBodyRequest (options: { + url: string + path: string + token?: string + fields: { [ fieldName: string ]: any } + expectedStatus?: HttpStatusCodeType + headers?: { [name: string]: string } +}) { + const req = request(options.url).put(options.path) + .send(options.fields) + + return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) +} + +function decodeQueryString (path: string) { + return decode(path.split('?')[1]) +} + +// --------------------------------------------------------------------------- + +function unwrapBody (test: request.Test): Promise { + return test.then(res => res.body) +} + +function unwrapText (test: request.Test): Promise { + return test.then(res => res.text) +} + +function unwrapBodyOrDecodeToJSON (test: request.Test): Promise { + return test.then(res => { + if (res.body instanceof Buffer) { + try { + return JSON.parse(new TextDecoder().decode(res.body)) + } catch (err) { + console.error('Cannot decode JSON.', { res, body: res.body instanceof Buffer ? res.body.toString() : res.body }) + throw err + } + } + + if (res.text) { + try { + return JSON.parse(res.text) + } catch (err) { + console.error('Cannot decode json', { res, text: res.text }) + throw err + } + } + + return res.body + }) +} + +function unwrapTextOrDecode (test: request.Test): Promise { + return test.then(res => res.text || new TextDecoder().decode(res.body)) +} + +// --------------------------------------------------------------------------- + +export { + makeHTMLRequest, + makeGetRequest, + decodeQueryString, + makeUploadRequest, + makePostBodyRequest, + makePutBodyRequest, + makeDeleteRequest, + makeRawRequest, + makeActivityPubGetRequest, + unwrapBody, + unwrapTextOrDecode, + unwrapBodyOrDecodeToJSON, + unwrapText +} + +// --------------------------------------------------------------------------- + +function buildRequest (req: request.Test, options: CommonRequestParams) { + if (options.contentType) req.set('Accept', options.contentType) + if (options.responseType) req.responseType(options.responseType) + if (options.token) req.set('Authorization', 'Bearer ' + options.token) + if (options.range) req.set('Range', options.range) + if (options.accept) req.set('Accept', options.accept) + if (options.host) req.set('Host', options.host) + if (options.redirects) req.redirects(options.redirects) + if (options.xForwardedFor) req.set('X-Forwarded-For', options.xForwardedFor) + if (options.type) req.type(options.type) + + Object.keys(options.headers || {}).forEach(name => { + req.set(name, options.headers[name]) + }) + + return req.expect(res => { + if (options.expectedStatus && res.status !== options.expectedStatus) { + const err = new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` + + `\nThe server responded: "${res.body?.error ?? res.text}".\n` + + 'You may take a closer look at the logs. To see how to do so, check out this page: ' + + 'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs'); + + (err as any).res = res + + throw err + } + + return res + }) +} + +function buildFields (req: request.Test, fields: { [ fieldName: string ]: any }, namespace?: string) { + if (!fields) return + + let formKey: string + + for (const key of Object.keys(fields)) { + if (namespace) formKey = `${namespace}[${key}]` + else formKey = key + + if (fields[key] === undefined) continue + + if (Array.isArray(fields[key]) && fields[key].length === 0) { + req.field(key, []) + continue + } + + if (fields[key] !== null && typeof fields[key] === 'object') { + buildFields(req, fields[key], formKey) + } else { + req.field(formKey, fields[key]) + } + } +} diff --git a/packages/server-commands/src/runners/index.ts b/packages/server-commands/src/runners/index.ts new file mode 100644 index 000000000..c868fa78e --- /dev/null +++ b/packages/server-commands/src/runners/index.ts @@ -0,0 +1,3 @@ +export * from './runner-jobs-command.js' +export * from './runner-registration-tokens-command.js' +export * from './runners-command.js' diff --git a/packages/server-commands/src/runners/runner-jobs-command.ts b/packages/server-commands/src/runners/runner-jobs-command.ts new file mode 100644 index 000000000..4e702199f --- /dev/null +++ b/packages/server-commands/src/runners/runner-jobs-command.ts @@ -0,0 +1,297 @@ +import { omit, pick, wait } from '@peertube/peertube-core-utils' +import { + AbortRunnerJobBody, + AcceptRunnerJobBody, + AcceptRunnerJobResult, + ErrorRunnerJobBody, + HttpStatusCode, + isHLSTranscodingPayloadSuccess, + isLiveRTMPHLSTranscodingUpdatePayload, + isWebVideoOrAudioMergeTranscodingPayloadSuccess, + ListRunnerJobsQuery, + RequestRunnerJobBody, + RequestRunnerJobResult, + ResultList, + RunnerJobAdmin, + RunnerJobLiveRTMPHLSTranscodingPayload, + RunnerJobPayload, + RunnerJobState, + RunnerJobStateType, + RunnerJobSuccessBody, + RunnerJobSuccessPayload, + RunnerJobType, + RunnerJobUpdateBody, + RunnerJobVODPayload, + VODHLSTranscodingSuccess, + VODWebVideoTranscodingSuccess +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { waitJobs } from '../server/jobs.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class RunnerJobsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & ListRunnerJobsQuery = {}) { + const path = '/api/v1/runners/jobs' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'search', 'stateOneOf' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + cancelByAdmin (options: OverrideCommandOptions & { jobUUID: string }) { + const path = '/api/v1/runners/jobs/' + options.jobUUID + '/cancel' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + deleteByAdmin (options: OverrideCommandOptions & { jobUUID: string }) { + const path = '/api/v1/runners/jobs/' + options.jobUUID + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + request (options: OverrideCommandOptions & RequestRunnerJobBody) { + const path = '/api/v1/runners/jobs/request' + + return unwrapBody(this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'runnerToken' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + async requestVOD (options: OverrideCommandOptions & RequestRunnerJobBody) { + const vodTypes = new Set([ 'vod-audio-merge-transcoding', 'vod-hls-transcoding', 'vod-web-video-transcoding' ]) + + const { availableJobs } = await this.request(options) + + return { + availableJobs: availableJobs.filter(j => vodTypes.has(j.type)) + } as RequestRunnerJobResult + } + + async requestLive (options: OverrideCommandOptions & RequestRunnerJobBody) { + const vodTypes = new Set([ 'live-rtmp-hls-transcoding' ]) + + const { availableJobs } = await this.request(options) + + return { + availableJobs: availableJobs.filter(j => vodTypes.has(j.type)) + } as RequestRunnerJobResult + } + + // --------------------------------------------------------------------------- + + accept (options: OverrideCommandOptions & AcceptRunnerJobBody & { jobUUID: string }) { + const path = '/api/v1/runners/jobs/' + options.jobUUID + '/accept' + + return unwrapBody>(this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'runnerToken' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + abort (options: OverrideCommandOptions & AbortRunnerJobBody & { jobUUID: string }) { + const path = '/api/v1/runners/jobs/' + options.jobUUID + '/abort' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'reason', 'jobToken', 'runnerToken' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + update (options: OverrideCommandOptions & RunnerJobUpdateBody & { jobUUID: string }) { + const path = '/api/v1/runners/jobs/' + options.jobUUID + '/update' + + const { payload } = options + const attaches: { [id: string]: any } = {} + let payloadWithoutFiles = payload + + if (isLiveRTMPHLSTranscodingUpdatePayload(payload)) { + if (payload.masterPlaylistFile) { + attaches[`payload[masterPlaylistFile]`] = payload.masterPlaylistFile + } + + attaches[`payload[resolutionPlaylistFile]`] = payload.resolutionPlaylistFile + attaches[`payload[videoChunkFile]`] = payload.videoChunkFile + + payloadWithoutFiles = omit(payloadWithoutFiles, [ 'masterPlaylistFile', 'resolutionPlaylistFile', 'videoChunkFile' ]) + } + + return this.postUploadRequest({ + ...options, + + path, + fields: { + ...pick(options, [ 'progress', 'jobToken', 'runnerToken' ]), + + payload: payloadWithoutFiles + }, + attaches, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + error (options: OverrideCommandOptions & ErrorRunnerJobBody & { jobUUID: string }) { + const path = '/api/v1/runners/jobs/' + options.jobUUID + '/error' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'message', 'jobToken', 'runnerToken' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + success (options: OverrideCommandOptions & RunnerJobSuccessBody & { jobUUID: string }) { + const { payload } = options + + const path = '/api/v1/runners/jobs/' + options.jobUUID + '/success' + const attaches: { [id: string]: any } = {} + let payloadWithoutFiles = payload + + if ((isWebVideoOrAudioMergeTranscodingPayloadSuccess(payload) || isHLSTranscodingPayloadSuccess(payload)) && payload.videoFile) { + attaches[`payload[videoFile]`] = payload.videoFile + + payloadWithoutFiles = omit(payloadWithoutFiles as VODWebVideoTranscodingSuccess, [ 'videoFile' ]) + } + + if (isHLSTranscodingPayloadSuccess(payload) && payload.resolutionPlaylistFile) { + attaches[`payload[resolutionPlaylistFile]`] = payload.resolutionPlaylistFile + + payloadWithoutFiles = omit(payloadWithoutFiles as VODHLSTranscodingSuccess, [ 'resolutionPlaylistFile' ]) + } + + return this.postUploadRequest({ + ...options, + + path, + attaches, + fields: { + ...pick(options, [ 'jobToken', 'runnerToken' ]), + + payload: payloadWithoutFiles + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getJobFile (options: OverrideCommandOptions & { url: string, jobToken: string, runnerToken: string }) { + const { host, protocol, pathname } = new URL(options.url) + + return this.postBodyRequest({ + url: `${protocol}//${host}`, + path: pathname, + + fields: pick(options, [ 'jobToken', 'runnerToken' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + async autoAccept (options: OverrideCommandOptions & RequestRunnerJobBody & { type?: RunnerJobType }) { + const { availableJobs } = await this.request(options) + + const job = options.type + ? availableJobs.find(j => j.type === options.type) + : availableJobs[0] + + return this.accept({ ...options, jobUUID: job.uuid }) + } + + async autoProcessWebVideoJob (runnerToken: string, jobUUIDToProcess?: string) { + let jobUUID = jobUUIDToProcess + + if (!jobUUID) { + const { availableJobs } = await this.request({ runnerToken }) + jobUUID = availableJobs[0].uuid + } + + const { job } = await this.accept({ runnerToken, jobUUID }) + const jobToken = job.jobToken + + const payload: RunnerJobSuccessPayload = { videoFile: 'video_short.mp4' } + await this.success({ runnerToken, jobUUID, jobToken, payload }) + + await waitJobs([ this.server ]) + + return job + } + + async cancelAllJobs (options: { state?: RunnerJobStateType } = {}) { + const { state } = options + + const { data } = await this.list({ count: 100 }) + + const allowedStates = new Set([ + RunnerJobState.PENDING, + RunnerJobState.PROCESSING, + RunnerJobState.WAITING_FOR_PARENT_JOB + ]) + + for (const job of data) { + if (state && job.state.id !== state) continue + else if (allowedStates.has(job.state.id) !== true) continue + + await this.cancelByAdmin({ jobUUID: job.uuid }) + } + } + + async getJob (options: OverrideCommandOptions & { uuid: string }) { + const { data } = await this.list({ ...options, count: 100, sort: '-updatedAt' }) + + return data.find(j => j.uuid === options.uuid) + } + + async requestLiveJob (runnerToken: string) { + let availableJobs: RequestRunnerJobResult['availableJobs'] = [] + + while (availableJobs.length === 0) { + const result = await this.requestLive({ runnerToken }) + availableJobs = result.availableJobs + + if (availableJobs.length === 1) break + + await wait(150) + } + + return availableJobs[0] + } +} diff --git a/packages/server-commands/src/runners/runner-registration-tokens-command.ts b/packages/server-commands/src/runners/runner-registration-tokens-command.ts new file mode 100644 index 000000000..86b6e5f93 --- /dev/null +++ b/packages/server-commands/src/runners/runner-registration-tokens-command.ts @@ -0,0 +1,55 @@ +import { pick } from '@peertube/peertube-core-utils' +import { HttpStatusCode, ResultList, RunnerRegistrationToken } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class RunnerRegistrationTokensCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + } = {}) { + const path = '/api/v1/runners/registration-tokens' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + generate (options: OverrideCommandOptions = {}) { + const path = '/api/v1/runners/registration-tokens/generate' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + delete (options: OverrideCommandOptions & { + id: number + }) { + const path = '/api/v1/runners/registration-tokens/' + options.id + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async getFirstRegistrationToken (options: OverrideCommandOptions = {}) { + const { data } = await this.list(options) + + return data[0].registrationToken + } +} diff --git a/packages/server-commands/src/runners/runners-command.ts b/packages/server-commands/src/runners/runners-command.ts new file mode 100644 index 000000000..376a1dff9 --- /dev/null +++ b/packages/server-commands/src/runners/runners-command.ts @@ -0,0 +1,85 @@ +import { pick } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + RegisterRunnerBody, + RegisterRunnerResult, + ResultList, + Runner, + UnregisterRunnerBody +} from '@peertube/peertube-models' +import { buildUUID } from '@peertube/peertube-node-utils' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class RunnersCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + } = {}) { + const path = '/api/v1/runners' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + register (options: OverrideCommandOptions & RegisterRunnerBody) { + const path = '/api/v1/runners/register' + + return unwrapBody(this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'name', 'registrationToken', 'description' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + unregister (options: OverrideCommandOptions & UnregisterRunnerBody) { + const path = '/api/v1/runners/unregister' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'runnerToken' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + delete (options: OverrideCommandOptions & { + id: number + }) { + const path = '/api/v1/runners/' + options.id + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + async autoRegisterRunner () { + const { data } = await this.server.runnerRegistrationTokens.list({ sort: 'createdAt' }) + + const { runnerToken } = await this.register({ + name: 'runner ' + buildUUID(), + registrationToken: data[0].registrationToken + }) + + return runnerToken + } +} diff --git a/packages/server-commands/src/search/index.ts b/packages/server-commands/src/search/index.ts new file mode 100644 index 000000000..ca56fc669 --- /dev/null +++ b/packages/server-commands/src/search/index.ts @@ -0,0 +1 @@ +export * from './search-command.js' diff --git a/packages/server-commands/src/search/search-command.ts b/packages/server-commands/src/search/search-command.ts new file mode 100644 index 000000000..e766a2861 --- /dev/null +++ b/packages/server-commands/src/search/search-command.ts @@ -0,0 +1,98 @@ +import { + HttpStatusCode, + ResultList, + Video, + VideoChannel, + VideoChannelsSearchQuery, + VideoPlaylist, + VideoPlaylistsSearchQuery, + VideosSearchQuery +} from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class SearchCommand extends AbstractCommand { + + searchChannels (options: OverrideCommandOptions & { + search: string + }) { + return this.advancedChannelSearch({ + ...options, + + search: { search: options.search } + }) + } + + advancedChannelSearch (options: OverrideCommandOptions & { + search: VideoChannelsSearchQuery + }) { + const { search } = options + const path = '/api/v1/search/video-channels' + + return this.getRequestBody>({ + ...options, + + path, + query: search, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + searchPlaylists (options: OverrideCommandOptions & { + search: string + }) { + return this.advancedPlaylistSearch({ + ...options, + + search: { search: options.search } + }) + } + + advancedPlaylistSearch (options: OverrideCommandOptions & { + search: VideoPlaylistsSearchQuery + }) { + const { search } = options + const path = '/api/v1/search/video-playlists' + + return this.getRequestBody>({ + ...options, + + path, + query: search, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + searchVideos (options: OverrideCommandOptions & { + search: string + sort?: string + }) { + const { search, sort } = options + + return this.advancedVideoSearch({ + ...options, + + search: { + search, + sort: sort ?? '-publishedAt' + } + }) + } + + advancedVideoSearch (options: OverrideCommandOptions & { + search: VideosSearchQuery + }) { + const { search } = options + const path = '/api/v1/search/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: search, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/server/config-command.ts b/packages/server-commands/src/server/config-command.ts new file mode 100644 index 000000000..8fcf0bd51 --- /dev/null +++ b/packages/server-commands/src/server/config-command.ts @@ -0,0 +1,576 @@ +import merge from 'lodash-es/merge.js' +import { About, CustomConfig, HttpStatusCode, ServerConfig } from '@peertube/peertube-models' +import { DeepPartial } from '@peertube/peertube-typescript-utils' +import { AbstractCommand, OverrideCommandOptions } from '../shared/abstract-command.js' + +export class ConfigCommand extends AbstractCommand { + + static getCustomConfigResolutions (enabled: boolean, with0p = false) { + return { + '0p': enabled && with0p, + '144p': enabled, + '240p': enabled, + '360p': enabled, + '480p': enabled, + '720p': enabled, + '1080p': enabled, + '1440p': enabled, + '2160p': enabled + } + } + + // --------------------------------------------------------------------------- + + static getEmailOverrideConfig (emailPort: number) { + return { + smtp: { + hostname: '127.0.0.1', + port: emailPort + } + } + } + + // --------------------------------------------------------------------------- + + enableSignup (requiresApproval: boolean, limit = -1) { + return this.updateExistingSubConfig({ + newConfig: { + signup: { + enabled: true, + requiresApproval, + limit + } + } + }) + } + + // --------------------------------------------------------------------------- + + disableImports () { + return this.setImportsEnabled(false) + } + + enableImports () { + return this.setImportsEnabled(true) + } + + private setImportsEnabled (enabled: boolean) { + return this.updateExistingSubConfig({ + newConfig: { + import: { + videos: { + http: { + enabled + }, + + torrent: { + enabled + } + } + } + } + }) + } + + // --------------------------------------------------------------------------- + + disableFileUpdate () { + return this.setFileUpdateEnabled(false) + } + + enableFileUpdate () { + return this.setFileUpdateEnabled(true) + } + + private setFileUpdateEnabled (enabled: boolean) { + return this.updateExistingSubConfig({ + newConfig: { + videoFile: { + update: { + enabled + } + } + } + }) + } + + // --------------------------------------------------------------------------- + + enableChannelSync () { + return this.setChannelSyncEnabled(true) + } + + disableChannelSync () { + return this.setChannelSyncEnabled(false) + } + + private setChannelSyncEnabled (enabled: boolean) { + return this.updateExistingSubConfig({ + newConfig: { + import: { + videoChannelSynchronization: { + enabled + } + } + } + }) + } + + // --------------------------------------------------------------------------- + + enableLive (options: { + allowReplay?: boolean + transcoding?: boolean + resolutions?: 'min' | 'max' // Default max + } = {}) { + const { allowReplay, transcoding, resolutions = 'max' } = options + + return this.updateExistingSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: allowReplay ?? true, + transcoding: { + enabled: transcoding ?? true, + resolutions: ConfigCommand.getCustomConfigResolutions(resolutions === 'max') + } + } + } + }) + } + + disableTranscoding () { + return this.updateExistingSubConfig({ + newConfig: { + transcoding: { + enabled: false + }, + videoStudio: { + enabled: false + } + } + }) + } + + enableTranscoding (options: { + webVideo?: boolean // default true + hls?: boolean // default true + with0p?: boolean // default false + } = {}) { + const { webVideo = true, hls = true, with0p = false } = options + + return this.updateExistingSubConfig({ + newConfig: { + transcoding: { + enabled: true, + + allowAudioFiles: true, + allowAdditionalExtensions: true, + + resolutions: ConfigCommand.getCustomConfigResolutions(true, with0p), + + webVideos: { + enabled: webVideo + }, + hls: { + enabled: hls + } + } + } + }) + } + + enableMinimumTranscoding (options: { + webVideo?: boolean // default true + hls?: boolean // default true + } = {}) { + const { webVideo = true, hls = true } = options + + return this.updateExistingSubConfig({ + newConfig: { + transcoding: { + enabled: true, + + allowAudioFiles: true, + allowAdditionalExtensions: true, + + resolutions: { + ...ConfigCommand.getCustomConfigResolutions(false), + + '240p': true + }, + + webVideos: { + enabled: webVideo + }, + hls: { + enabled: hls + } + } + } + }) + } + + enableRemoteTranscoding () { + return this.updateExistingSubConfig({ + newConfig: { + transcoding: { + remoteRunners: { + enabled: true + } + }, + live: { + transcoding: { + remoteRunners: { + enabled: true + } + } + } + } + }) + } + + enableRemoteStudio () { + return this.updateExistingSubConfig({ + newConfig: { + videoStudio: { + remoteRunners: { + enabled: true + } + } + } + }) + } + + // --------------------------------------------------------------------------- + + enableStudio () { + return this.updateExistingSubConfig({ + newConfig: { + videoStudio: { + enabled: true + } + } + }) + } + + // --------------------------------------------------------------------------- + + getConfig (options: OverrideCommandOptions = {}) { + const path = '/api/v1/config' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async getIndexHTMLConfig (options: OverrideCommandOptions = {}) { + const text = await this.getRequestText({ + ...options, + + path: '/', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + + const match = text.match('') + + // We parse the string twice, first to extract the string and then to extract the JSON + return JSON.parse(JSON.parse(match[1])) as ServerConfig + } + + getAbout (options: OverrideCommandOptions = {}) { + const path = '/api/v1/config/about' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getCustomConfig (options: OverrideCommandOptions = {}) { + const path = '/api/v1/config/custom' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateCustomConfig (options: OverrideCommandOptions & { + newCustomConfig: CustomConfig + }) { + const path = '/api/v1/config/custom' + + return this.putBodyRequest({ + ...options, + + path, + fields: options.newCustomConfig, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + deleteCustomConfig (options: OverrideCommandOptions = {}) { + const path = '/api/v1/config/custom' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async updateExistingSubConfig (options: OverrideCommandOptions & { + newConfig: DeepPartial + }) { + const existing = await this.getCustomConfig({ ...options, expectedStatus: HttpStatusCode.OK_200 }) + + return this.updateCustomConfig({ ...options, newCustomConfig: merge({}, existing, options.newConfig) }) + } + + updateCustomSubConfig (options: OverrideCommandOptions & { + newConfig: DeepPartial + }) { + const newCustomConfig: CustomConfig = { + instance: { + name: 'PeerTube updated', + shortDescription: 'my short description', + description: 'my super description', + terms: 'my super terms', + codeOfConduct: 'my super coc', + + creationReason: 'my super creation reason', + moderationInformation: 'my super moderation information', + administrator: 'Kuja', + maintenanceLifetime: 'forever', + businessModel: 'my super business model', + hardwareInformation: '2vCore 3GB RAM', + + languages: [ 'en', 'es' ], + categories: [ 1, 2 ], + + isNSFW: true, + defaultNSFWPolicy: 'blur', + + defaultClientRoute: '/videos/recently-added', + + customizations: { + javascript: 'alert("coucou")', + css: 'body { background-color: red; }' + } + }, + theme: { + default: 'default' + }, + services: { + twitter: { + username: '@MySuperUsername', + whitelisted: true + } + }, + client: { + videos: { + miniature: { + preferAuthorDisplayName: false + } + }, + menu: { + login: { + redirectOnSingleExternalAuth: false + } + } + }, + cache: { + previews: { + size: 2 + }, + captions: { + size: 3 + }, + torrents: { + size: 4 + }, + storyboards: { + size: 5 + } + }, + signup: { + enabled: false, + limit: 5, + requiresApproval: true, + requiresEmailVerification: false, + minimumAge: 16 + }, + admin: { + email: 'superadmin1@example.com' + }, + contactForm: { + enabled: true + }, + user: { + history: { + videos: { + enabled: true + } + }, + videoQuota: 5242881, + videoQuotaDaily: 318742 + }, + videoChannels: { + maxPerUser: 20 + }, + transcoding: { + enabled: true, + remoteRunners: { + enabled: false + }, + allowAdditionalExtensions: true, + allowAudioFiles: true, + threads: 1, + concurrency: 3, + profile: 'default', + resolutions: { + '0p': false, + '144p': false, + '240p': false, + '360p': true, + '480p': true, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + }, + alwaysTranscodeOriginalResolution: true, + webVideos: { + enabled: true + }, + hls: { + enabled: false + } + }, + live: { + enabled: true, + allowReplay: false, + latencySetting: { + enabled: false + }, + maxDuration: -1, + maxInstanceLives: -1, + maxUserLives: 50, + transcoding: { + enabled: true, + remoteRunners: { + enabled: false + }, + threads: 4, + profile: 'default', + resolutions: { + '144p': true, + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '1440p': true, + '2160p': true + }, + alwaysTranscodeOriginalResolution: true + } + }, + videoStudio: { + enabled: false, + remoteRunners: { + enabled: false + } + }, + videoFile: { + update: { + enabled: false + } + }, + import: { + videos: { + concurrency: 3, + http: { + enabled: false + }, + torrent: { + enabled: false + } + }, + videoChannelSynchronization: { + enabled: false, + maxPerUser: 10 + } + }, + trending: { + videos: { + algorithms: { + enabled: [ 'hot', 'most-viewed', 'most-liked' ], + default: 'hot' + } + } + }, + autoBlacklist: { + videos: { + ofUsers: { + enabled: false + } + } + }, + followers: { + instance: { + enabled: true, + manualApproval: false + } + }, + followings: { + instance: { + autoFollowBack: { + enabled: false + }, + autoFollowIndex: { + indexUrl: 'https://instances.joinpeertube.org/api/v1/instances/hosts', + enabled: false + } + } + }, + broadcastMessage: { + enabled: true, + level: 'warning', + message: 'hello', + dismissable: true + }, + search: { + remoteUri: { + users: true, + anonymous: true + }, + searchIndex: { + enabled: true, + url: 'https://search.joinpeertube.org', + disableLocalSearch: true, + isDefaultSearch: true + } + } + } + + merge(newCustomConfig, options.newConfig) + + return this.updateCustomConfig({ ...options, newCustomConfig }) + } +} diff --git a/packages/server-commands/src/server/contact-form-command.ts b/packages/server-commands/src/server/contact-form-command.ts new file mode 100644 index 000000000..399e06d2f --- /dev/null +++ b/packages/server-commands/src/server/contact-form-command.ts @@ -0,0 +1,30 @@ +import { ContactForm, HttpStatusCode } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ContactFormCommand extends AbstractCommand { + + send (options: OverrideCommandOptions & { + fromEmail: string + fromName: string + subject: string + body: string + }) { + const path = '/api/v1/server/contact' + + const body: ContactForm = { + fromEmail: options.fromEmail, + fromName: options.fromName, + subject: options.subject, + body: options.body + } + + return this.postBodyRequest({ + ...options, + + path, + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/server/debug-command.ts b/packages/server-commands/src/server/debug-command.ts new file mode 100644 index 000000000..9bb7fda10 --- /dev/null +++ b/packages/server-commands/src/server/debug-command.ts @@ -0,0 +1,33 @@ +import { Debug, HttpStatusCode, SendDebugCommand } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class DebugCommand extends AbstractCommand { + + getDebug (options: OverrideCommandOptions = {}) { + const path = '/api/v1/server/debug' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + sendCommand (options: OverrideCommandOptions & { + body: SendDebugCommand + }) { + const { body } = options + const path = '/api/v1/server/debug/run-command' + + return this.postBodyRequest({ + ...options, + + path, + fields: body, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/server/follows-command.ts b/packages/server-commands/src/server/follows-command.ts new file mode 100644 index 000000000..cdc263982 --- /dev/null +++ b/packages/server-commands/src/server/follows-command.ts @@ -0,0 +1,139 @@ +import { pick } from '@peertube/peertube-core-utils' +import { ActivityPubActorType, ActorFollow, FollowState, HttpStatusCode, ResultList, ServerFollowCreate } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' +import { PeerTubeServer } from './server.js' + +export class FollowsCommand extends AbstractCommand { + + getFollowers (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + actorType?: ActivityPubActorType + state?: FollowState + } = {}) { + const path = '/api/v1/server/followers' + + const query = pick(options, [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getFollowings (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + actorType?: ActivityPubActorType + state?: FollowState + } = {}) { + const path = '/api/v1/server/following' + + const query = pick(options, [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + follow (options: OverrideCommandOptions & { + hosts?: string[] + handles?: string[] + }) { + const path = '/api/v1/server/following' + + const fields: ServerFollowCreate = {} + + if (options.hosts) { + fields.hosts = options.hosts.map(f => f.replace(/^http:\/\//, '')) + } + + if (options.handles) { + fields.handles = options.handles + } + + return this.postBodyRequest({ + ...options, + + path, + fields, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async unfollow (options: OverrideCommandOptions & { + target: PeerTubeServer | string + }) { + const { target } = options + + const handle = typeof target === 'string' + ? target + : target.host + + const path = '/api/v1/server/following/' + handle + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + acceptFollower (options: OverrideCommandOptions & { + follower: string + }) { + const path = '/api/v1/server/followers/' + options.follower + '/accept' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + rejectFollower (options: OverrideCommandOptions & { + follower: string + }) { + const path = '/api/v1/server/followers/' + options.follower + '/reject' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeFollower (options: OverrideCommandOptions & { + follower: PeerTubeServer + }) { + const path = '/api/v1/server/followers/peertube@' + options.follower.host + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/server/follows.ts b/packages/server-commands/src/server/follows.ts new file mode 100644 index 000000000..32304495a --- /dev/null +++ b/packages/server-commands/src/server/follows.ts @@ -0,0 +1,20 @@ +import { waitJobs } from './jobs.js' +import { PeerTubeServer } from './server.js' + +async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) { + await Promise.all([ + server1.follows.follow({ hosts: [ server2.url ] }), + server2.follows.follow({ hosts: [ server1.url ] }) + ]) + + // Wait request propagation + await waitJobs([ server1, server2 ]) + + return true +} + +// --------------------------------------------------------------------------- + +export { + doubleFollow +} diff --git a/packages/server-commands/src/server/index.ts b/packages/server-commands/src/server/index.ts new file mode 100644 index 000000000..c13972eca --- /dev/null +++ b/packages/server-commands/src/server/index.ts @@ -0,0 +1,15 @@ +export * from './config-command.js' +export * from './contact-form-command.js' +export * from './debug-command.js' +export * from './follows-command.js' +export * from './follows.js' +export * from './jobs.js' +export * from './jobs-command.js' +export * from './metrics-command.js' +export * from './object-storage-command.js' +export * from './plugins-command.js' +export * from './redundancy-command.js' +export * from './server.js' +export * from './servers-command.js' +export * from './servers.js' +export * from './stats-command.js' diff --git a/packages/server-commands/src/server/jobs-command.ts b/packages/server-commands/src/server/jobs-command.ts new file mode 100644 index 000000000..18aa0cd95 --- /dev/null +++ b/packages/server-commands/src/server/jobs-command.ts @@ -0,0 +1,84 @@ +import { pick } from '@peertube/peertube-core-utils' +import { HttpStatusCode, Job, JobState, JobType, ResultList } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class JobsCommand extends AbstractCommand { + + async getLatest (options: OverrideCommandOptions & { + jobType: JobType + }) { + const { data } = await this.list({ ...options, start: 0, count: 1, sort: '-createdAt' }) + + if (data.length === 0) return undefined + + return data[0] + } + + pauseJobQueue (options: OverrideCommandOptions = {}) { + const path = '/api/v1/jobs/pause' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + resumeJobQueue (options: OverrideCommandOptions = {}) { + const path = '/api/v1/jobs/resume' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + state?: JobState + jobType?: JobType + start?: number + count?: number + sort?: string + } = {}) { + const path = this.buildJobsUrl(options.state) + + const query = pick(options, [ 'start', 'count', 'sort', 'jobType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listFailed (options: OverrideCommandOptions & { + jobType?: JobType + }) { + const path = this.buildJobsUrl('failed') + + return this.getRequestBody>({ + ...options, + + path, + query: { start: 0, count: 50 }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + private buildJobsUrl (state?: JobState) { + let path = '/api/v1/jobs' + + if (state) path += '/' + state + + return path + } +} diff --git a/packages/server-commands/src/server/jobs.ts b/packages/server-commands/src/server/jobs.ts new file mode 100644 index 000000000..1f3b1f745 --- /dev/null +++ b/packages/server-commands/src/server/jobs.ts @@ -0,0 +1,117 @@ +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { JobState, JobType, RunnerJobState } from '@peertube/peertube-models' +import { PeerTubeServer } from './server.js' + +async function waitJobs ( + serversArg: PeerTubeServer[] | PeerTubeServer, + options: { + skipDelayed?: boolean // default false + runnerJobs?: boolean // default false + } = {} +) { + const { skipDelayed = false, runnerJobs = false } = options + + const pendingJobWait = process.env.NODE_PENDING_JOB_WAIT + ? parseInt(process.env.NODE_PENDING_JOB_WAIT, 10) + : 250 + + let servers: PeerTubeServer[] + + if (Array.isArray(serversArg) === false) servers = [ serversArg as PeerTubeServer ] + else servers = serversArg as PeerTubeServer[] + + const states: JobState[] = [ 'waiting', 'active' ] + if (!skipDelayed) states.push('delayed') + + const repeatableJobs: JobType[] = [ 'videos-views-stats', 'activitypub-cleaner' ] + let pendingRequests: boolean + + function tasksBuilder () { + const tasks: Promise[] = [] + + // Check if each server has pending request + for (const server of servers) { + if (process.env.DEBUG) console.log('Checking ' + server.url) + + for (const state of states) { + + const jobPromise = server.jobs.list({ + state, + start: 0, + count: 10, + sort: '-createdAt' + }).then(body => body.data) + .then(jobs => jobs.filter(j => !repeatableJobs.includes(j.type))) + .then(jobs => { + if (jobs.length !== 0) { + pendingRequests = true + + if (process.env.DEBUG) { + console.log(jobs) + } + } + }) + + tasks.push(jobPromise) + } + + const debugPromise = server.debug.getDebug() + .then(obj => { + if (obj.activityPubMessagesWaiting !== 0) { + pendingRequests = true + + if (process.env.DEBUG) { + console.log('AP messages waiting: ' + obj.activityPubMessagesWaiting) + } + } + }) + tasks.push(debugPromise) + + if (runnerJobs) { + const runnerJobsPromise = server.runnerJobs.list({ count: 100 }) + .then(({ data }) => { + for (const job of data) { + if (job.state.id !== RunnerJobState.COMPLETED) { + pendingRequests = true + + if (process.env.DEBUG) { + console.log(job) + } + } + } + }) + tasks.push(runnerJobsPromise) + } + } + + return tasks + } + + do { + pendingRequests = false + await Promise.all(tasksBuilder()) + + // Retry, in case of new jobs were created + if (pendingRequests === false) { + await wait(pendingJobWait) + await Promise.all(tasksBuilder()) + } + + if (pendingRequests) { + await wait(pendingJobWait) + } + } while (pendingRequests) +} + +async function expectNoFailedTranscodingJob (server: PeerTubeServer) { + const { data } = await server.jobs.listFailed({ jobType: 'video-transcoding' }) + expect(data).to.have.lengthOf(0) +} + +// --------------------------------------------------------------------------- + +export { + waitJobs, + expectNoFailedTranscodingJob +} diff --git a/packages/server-commands/src/server/metrics-command.ts b/packages/server-commands/src/server/metrics-command.ts new file mode 100644 index 000000000..1f969a024 --- /dev/null +++ b/packages/server-commands/src/server/metrics-command.ts @@ -0,0 +1,18 @@ +import { HttpStatusCode, PlaybackMetricCreate } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class MetricsCommand extends AbstractCommand { + + addPlaybackMetric (options: OverrideCommandOptions & { metrics: PlaybackMetricCreate }) { + const path = '/api/v1/metrics/playback' + + return this.postBodyRequest({ + ...options, + + path, + fields: options.metrics, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/server/object-storage-command.ts b/packages/server-commands/src/server/object-storage-command.ts new file mode 100644 index 000000000..ff8d5d75c --- /dev/null +++ b/packages/server-commands/src/server/object-storage-command.ts @@ -0,0 +1,165 @@ +import { randomInt } from 'crypto' +import { HttpStatusCode } from '@peertube/peertube-models' +import { makePostBodyRequest } from '../requests/index.js' + +export class ObjectStorageCommand { + static readonly DEFAULT_SCALEWAY_BUCKET = 'peertube-ci-test' + + private readonly bucketsCreated: string[] = [] + private readonly seed: number + + // --------------------------------------------------------------------------- + + constructor () { + this.seed = randomInt(0, 10000) + } + + static getMockCredentialsConfig () { + return { + access_key_id: 'AKIAIOSFODNN7EXAMPLE', + secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + } + } + + static getMockEndpointHost () { + return 'localhost:9444' + } + + static getMockRegion () { + return 'us-east-1' + } + + getDefaultMockConfig () { + return { + object_storage: { + enabled: true, + endpoint: 'http://' + ObjectStorageCommand.getMockEndpointHost(), + region: ObjectStorageCommand.getMockRegion(), + + credentials: ObjectStorageCommand.getMockCredentialsConfig(), + + streaming_playlists: { + bucket_name: this.getMockStreamingPlaylistsBucketName() + }, + + web_videos: { + bucket_name: this.getMockWebVideosBucketName() + } + } + } + } + + getMockWebVideosBaseUrl () { + return `http://${this.getMockWebVideosBucketName()}.${ObjectStorageCommand.getMockEndpointHost()}/` + } + + getMockPlaylistBaseUrl () { + return `http://${this.getMockStreamingPlaylistsBucketName()}.${ObjectStorageCommand.getMockEndpointHost()}/` + } + + async prepareDefaultMockBuckets () { + await this.createMockBucket(this.getMockStreamingPlaylistsBucketName()) + await this.createMockBucket(this.getMockWebVideosBucketName()) + } + + async createMockBucket (name: string) { + this.bucketsCreated.push(name) + + await this.deleteMockBucket(name) + + await makePostBodyRequest({ + url: ObjectStorageCommand.getMockEndpointHost(), + path: '/ui/' + name + '?create', + expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307 + }) + + await makePostBodyRequest({ + url: ObjectStorageCommand.getMockEndpointHost(), + path: '/ui/' + name + '?make-public', + expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307 + }) + } + + async cleanupMock () { + for (const name of this.bucketsCreated) { + await this.deleteMockBucket(name) + } + } + + getMockStreamingPlaylistsBucketName (name = 'streaming-playlists') { + return this.getMockBucketName(name) + } + + getMockWebVideosBucketName (name = 'web-videos') { + return this.getMockBucketName(name) + } + + getMockBucketName (name: string) { + return `${this.seed}-${name}` + } + + private async deleteMockBucket (name: string) { + await makePostBodyRequest({ + url: ObjectStorageCommand.getMockEndpointHost(), + path: '/ui/' + name + '?delete', + expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307 + }) + } + + // --------------------------------------------------------------------------- + + static getDefaultScalewayConfig (options: { + serverNumber: number + enablePrivateProxy?: boolean // default true + privateACL?: 'private' | 'public-read' // default 'private' + }) { + const { serverNumber, enablePrivateProxy = true, privateACL = 'private' } = options + + return { + object_storage: { + enabled: true, + endpoint: this.getScalewayEndpointHost(), + region: this.getScalewayRegion(), + + credentials: this.getScalewayCredentialsConfig(), + + upload_acl: { + private: privateACL + }, + + proxy: { + proxify_private_files: enablePrivateProxy + }, + + streaming_playlists: { + bucket_name: this.DEFAULT_SCALEWAY_BUCKET, + prefix: `test:server-${serverNumber}-streaming-playlists:` + }, + + web_videos: { + bucket_name: this.DEFAULT_SCALEWAY_BUCKET, + prefix: `test:server-${serverNumber}-web-videos:` + } + } + } + } + + static getScalewayCredentialsConfig () { + return { + access_key_id: process.env.OBJECT_STORAGE_SCALEWAY_KEY_ID, + secret_access_key: process.env.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY + } + } + + static getScalewayEndpointHost () { + return 's3.fr-par.scw.cloud' + } + + static getScalewayRegion () { + return 'fr-par' + } + + static getScalewayBaseUrl () { + return `https://${this.DEFAULT_SCALEWAY_BUCKET}.${this.getScalewayEndpointHost()}/` + } +} diff --git a/packages/server-commands/src/server/plugins-command.ts b/packages/server-commands/src/server/plugins-command.ts new file mode 100644 index 000000000..f85ef0330 --- /dev/null +++ b/packages/server-commands/src/server/plugins-command.ts @@ -0,0 +1,258 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { readJSON, writeJSON } from 'fs-extra/esm' +import { join } from 'path' +import { + HttpStatusCode, + HttpStatusCodeType, + PeerTubePlugin, + PeerTubePluginIndex, + PeertubePluginIndexList, + PluginPackageJSON, + PluginTranslation, + PluginType_Type, + PublicServerSetting, + RegisteredServerSettings, + ResultList +} from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class PluginsCommand extends AbstractCommand { + + static getPluginTestPath (suffix = '') { + return buildAbsoluteFixturePath('peertube-plugin-test' + suffix) + } + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + pluginType?: PluginType_Type + uninstalled?: boolean + }) { + const { start, count, sort, pluginType, uninstalled } = options + const path = '/api/v1/plugins' + + return this.getRequestBody>({ + ...options, + + path, + query: { + start, + count, + sort, + pluginType, + uninstalled + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listAvailable (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + pluginType?: PluginType_Type + currentPeerTubeEngine?: string + search?: string + expectedStatus?: HttpStatusCodeType + }) { + const { start, count, sort, pluginType, search, currentPeerTubeEngine } = options + const path = '/api/v1/plugins/available' + + const query: PeertubePluginIndexList = { + start, + count, + sort, + pluginType, + currentPeerTubeEngine, + search + } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + npmName: string + }) { + const path = '/api/v1/plugins/' + options.npmName + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateSettings (options: OverrideCommandOptions & { + npmName: string + settings: any + }) { + const { npmName, settings } = options + const path = '/api/v1/plugins/' + npmName + '/settings' + + return this.putBodyRequest({ + ...options, + + path, + fields: { settings }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getRegisteredSettings (options: OverrideCommandOptions & { + npmName: string + }) { + const path = '/api/v1/plugins/' + options.npmName + '/registered-settings' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getPublicSettings (options: OverrideCommandOptions & { + npmName: string + }) { + const { npmName } = options + const path = '/api/v1/plugins/' + npmName + '/public-settings' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getTranslations (options: OverrideCommandOptions & { + locale: string + }) { + const { locale } = options + const path = '/plugins/translations/' + locale + '.json' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + install (options: OverrideCommandOptions & { + path?: string + npmName?: string + pluginVersion?: string + }) { + const { npmName, path, pluginVersion } = options + const apiPath = '/api/v1/plugins/install' + + return this.postBodyRequest({ + ...options, + + path: apiPath, + fields: { npmName, path, pluginVersion }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + update (options: OverrideCommandOptions & { + path?: string + npmName?: string + }) { + const { npmName, path } = options + const apiPath = '/api/v1/plugins/update' + + return this.postBodyRequest({ + ...options, + + path: apiPath, + fields: { npmName, path }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + uninstall (options: OverrideCommandOptions & { + npmName: string + }) { + const { npmName } = options + const apiPath = '/api/v1/plugins/uninstall' + + return this.postBodyRequest({ + ...options, + + path: apiPath, + fields: { npmName }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getCSS (options: OverrideCommandOptions = {}) { + const path = '/plugins/global.css' + + return this.getRequestText({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getExternalAuth (options: OverrideCommandOptions & { + npmName: string + npmVersion: string + authName: string + query?: any + }) { + const { npmName, npmVersion, authName, query } = options + + const path = '/plugins/' + npmName + '/' + npmVersion + '/auth/' + authName + + return this.getRequest({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200, + redirects: 0 + }) + } + + updatePackageJSON (npmName: string, json: any) { + const path = this.getPackageJSONPath(npmName) + + return writeJSON(path, json) + } + + getPackageJSON (npmName: string): Promise { + const path = this.getPackageJSONPath(npmName) + + return readJSON(path) + } + + private getPackageJSONPath (npmName: string) { + return this.server.servers.buildDirectory(join('plugins', 'node_modules', npmName, 'package.json')) + } +} diff --git a/packages/server-commands/src/server/redundancy-command.ts b/packages/server-commands/src/server/redundancy-command.ts new file mode 100644 index 000000000..a0ec3e80e --- /dev/null +++ b/packages/server-commands/src/server/redundancy-command.ts @@ -0,0 +1,80 @@ +import { HttpStatusCode, ResultList, VideoRedundanciesTarget, VideoRedundancy } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class RedundancyCommand extends AbstractCommand { + + updateRedundancy (options: OverrideCommandOptions & { + host: string + redundancyAllowed: boolean + }) { + const { host, redundancyAllowed } = options + const path = '/api/v1/server/redundancy/' + host + + return this.putBodyRequest({ + ...options, + + path, + fields: { redundancyAllowed }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + listVideos (options: OverrideCommandOptions & { + target: VideoRedundanciesTarget + start?: number + count?: number + sort?: string + }) { + const path = '/api/v1/server/redundancy/videos' + + const { target, start, count, sort } = options + + return this.getRequestBody>({ + ...options, + + path, + + query: { + start: start ?? 0, + count: count ?? 5, + sort: sort ?? 'name', + target + }, + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + addVideo (options: OverrideCommandOptions & { + videoId: number + }) { + const path = '/api/v1/server/redundancy/videos' + const { videoId } = options + + return this.postBodyRequest({ + ...options, + + path, + fields: { videoId }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeVideo (options: OverrideCommandOptions & { + redundancyId: number + }) { + const { redundancyId } = options + const path = '/api/v1/server/redundancy/videos/' + redundancyId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/server/server.ts b/packages/server-commands/src/server/server.ts new file mode 100644 index 000000000..57a897c17 --- /dev/null +++ b/packages/server-commands/src/server/server.ts @@ -0,0 +1,451 @@ +import { ChildProcess, fork } from 'child_process' +import { copy } from 'fs-extra/esm' +import { join } from 'path' +import { randomInt } from '@peertube/peertube-core-utils' +import { Video, VideoChannel, VideoChannelSync, VideoCreateResult, VideoDetails } from '@peertube/peertube-models' +import { parallelTests, root } from '@peertube/peertube-node-utils' +import { BulkCommand } from '../bulk/index.js' +import { CLICommand } from '../cli/index.js' +import { CustomPagesCommand } from '../custom-pages/index.js' +import { FeedCommand } from '../feeds/index.js' +import { LogsCommand } from '../logs/index.js' +import { AbusesCommand } from '../moderation/index.js' +import { OverviewsCommand } from '../overviews/index.js' +import { RunnerJobsCommand, RunnerRegistrationTokensCommand, RunnersCommand } from '../runners/index.js' +import { SearchCommand } from '../search/index.js' +import { SocketIOCommand } from '../socket/index.js' +import { + AccountsCommand, + BlocklistCommand, + LoginCommand, + NotificationsCommand, + RegistrationsCommand, + SubscriptionsCommand, + TwoFactorCommand, + UsersCommand +} from '../users/index.js' +import { + BlacklistCommand, + CaptionsCommand, + ChangeOwnershipCommand, + ChannelsCommand, + ChannelSyncsCommand, + CommentsCommand, + HistoryCommand, + ImportsCommand, + LiveCommand, + PlaylistsCommand, + ServicesCommand, + StoryboardCommand, + StreamingPlaylistsCommand, + VideoPasswordsCommand, + VideosCommand, + VideoStatsCommand, + VideoStudioCommand, + VideoTokenCommand, + ViewsCommand +} from '../videos/index.js' +import { ConfigCommand } from './config-command.js' +import { ContactFormCommand } from './contact-form-command.js' +import { DebugCommand } from './debug-command.js' +import { FollowsCommand } from './follows-command.js' +import { JobsCommand } from './jobs-command.js' +import { MetricsCommand } from './metrics-command.js' +import { PluginsCommand } from './plugins-command.js' +import { RedundancyCommand } from './redundancy-command.js' +import { ServersCommand } from './servers-command.js' +import { StatsCommand } from './stats-command.js' + +export type RunServerOptions = { + hideLogs?: boolean + nodeArgs?: string[] + peertubeArgs?: string[] + env?: { [ id: string ]: string } +} + +export class PeerTubeServer { + app?: ChildProcess + + url: string + host?: string + hostname?: string + port?: number + + rtmpPort?: number + rtmpsPort?: number + + parallel?: boolean + internalServerNumber: number + + serverNumber?: number + customConfigFile?: string + + store?: { + client?: { + id?: string + secret?: string + } + + user?: { + username: string + password: string + email?: string + } + + channel?: VideoChannel + videoChannelSync?: Partial + + video?: Video + videoCreated?: VideoCreateResult + videoDetails?: VideoDetails + + videos?: { id: number, uuid: string }[] + } + + accessToken?: string + refreshToken?: string + + bulk?: BulkCommand + cli?: CLICommand + customPage?: CustomPagesCommand + feed?: FeedCommand + logs?: LogsCommand + abuses?: AbusesCommand + overviews?: OverviewsCommand + search?: SearchCommand + contactForm?: ContactFormCommand + debug?: DebugCommand + follows?: FollowsCommand + jobs?: JobsCommand + metrics?: MetricsCommand + plugins?: PluginsCommand + redundancy?: RedundancyCommand + stats?: StatsCommand + config?: ConfigCommand + socketIO?: SocketIOCommand + accounts?: AccountsCommand + blocklist?: BlocklistCommand + subscriptions?: SubscriptionsCommand + live?: LiveCommand + services?: ServicesCommand + blacklist?: BlacklistCommand + captions?: CaptionsCommand + changeOwnership?: ChangeOwnershipCommand + playlists?: PlaylistsCommand + history?: HistoryCommand + imports?: ImportsCommand + channelSyncs?: ChannelSyncsCommand + streamingPlaylists?: StreamingPlaylistsCommand + channels?: ChannelsCommand + comments?: CommentsCommand + notifications?: NotificationsCommand + servers?: ServersCommand + login?: LoginCommand + users?: UsersCommand + videoStudio?: VideoStudioCommand + videos?: VideosCommand + videoStats?: VideoStatsCommand + views?: ViewsCommand + twoFactor?: TwoFactorCommand + videoToken?: VideoTokenCommand + registrations?: RegistrationsCommand + videoPasswords?: VideoPasswordsCommand + + storyboard?: StoryboardCommand + + runners?: RunnersCommand + runnerRegistrationTokens?: RunnerRegistrationTokensCommand + runnerJobs?: RunnerJobsCommand + + constructor (options: { serverNumber: number } | { url: string }) { + if ((options as any).url) { + this.setUrl((options as any).url) + } else { + this.setServerNumber((options as any).serverNumber) + } + + this.store = { + client: { + id: null, + secret: null + }, + user: { + username: null, + password: null + } + } + + this.assignCommands() + } + + setServerNumber (serverNumber: number) { + this.serverNumber = serverNumber + + this.parallel = parallelTests() + + this.internalServerNumber = this.parallel ? this.randomServer() : this.serverNumber + this.rtmpPort = this.parallel ? this.randomRTMP() : 1936 + this.rtmpsPort = this.parallel ? this.randomRTMP() : 1937 + this.port = 9000 + this.internalServerNumber + + this.url = `http://127.0.0.1:${this.port}` + this.host = `127.0.0.1:${this.port}` + this.hostname = '127.0.0.1' + } + + setUrl (url: string) { + const parsed = new URL(url) + + this.url = url + this.host = parsed.host + this.hostname = parsed.hostname + this.port = parseInt(parsed.port) + } + + getDirectoryPath (directoryName: string) { + const testDirectory = 'test' + this.internalServerNumber + + return join(root(), testDirectory, directoryName) + } + + async flushAndRun (configOverride?: object, options: RunServerOptions = {}) { + await ServersCommand.flushTests(this.internalServerNumber) + + return this.run(configOverride, options) + } + + async run (configOverrideArg?: any, options: RunServerOptions = {}) { + // These actions are async so we need to be sure that they have both been done + const serverRunString = { + 'HTTP server listening': false + } + const key = 'Database peertube_test' + this.internalServerNumber + ' is ready' + serverRunString[key] = false + + const regexps = { + client_id: 'Client id: (.+)', + client_secret: 'Client secret: (.+)', + user_username: 'Username: (.+)', + user_password: 'User password: (.+)' + } + + await this.assignCustomConfigFile() + + const configOverride = this.buildConfigOverride() + + if (configOverrideArg !== undefined) { + Object.assign(configOverride, configOverrideArg) + } + + // Share the environment + const env = { ...process.env } + env['NODE_ENV'] = 'test' + env['NODE_APP_INSTANCE'] = this.internalServerNumber.toString() + env['NODE_CONFIG'] = JSON.stringify(configOverride) + + if (options.env) { + Object.assign(env, options.env) + } + + const execArgv = options.nodeArgs || [] + // FIXME: too slow :/ + // execArgv.push('--enable-source-maps') + + const forkOptions = { + silent: true, + env, + detached: false, + execArgv + } + + const peertubeArgs = options.peertubeArgs || [] + + return new Promise((res, rej) => { + const self = this + let aggregatedLogs = '' + + this.app = fork(join(root(), 'dist', 'server.js'), peertubeArgs, forkOptions) + + const onPeerTubeExit = () => rej(new Error('Process exited:\n' + aggregatedLogs)) + const onParentExit = () => { + if (!this.app?.pid) return + + try { + process.kill(self.app.pid) + } catch { /* empty */ } + } + + this.app.on('exit', onPeerTubeExit) + process.on('exit', onParentExit) + + this.app.stdout.on('data', function onStdout (data) { + let dontContinue = false + + const log: string = data.toString() + aggregatedLogs += log + + // Capture things if we want to + for (const key of Object.keys(regexps)) { + const regexp = regexps[key] + const matches = log.match(regexp) + if (matches !== null) { + if (key === 'client_id') self.store.client.id = matches[1] + else if (key === 'client_secret') self.store.client.secret = matches[1] + else if (key === 'user_username') self.store.user.username = matches[1] + else if (key === 'user_password') self.store.user.password = matches[1] + } + } + + // Check if all required sentences are here + for (const key of Object.keys(serverRunString)) { + if (log.includes(key)) serverRunString[key] = true + if (serverRunString[key] === false) dontContinue = true + } + + // If no, there is maybe one thing not already initialized (client/user credentials generation...) + if (dontContinue === true) return + + if (options.hideLogs === false) { + console.log(log) + } else { + process.removeListener('exit', onParentExit) + self.app.stdout.removeListener('data', onStdout) + self.app.removeListener('exit', onPeerTubeExit) + } + + res() + }) + }) + } + + kill () { + if (!this.app) return Promise.resolve() + + process.kill(this.app.pid) + + this.app = null + + return Promise.resolve() + } + + private randomServer () { + const low = 2500 + const high = 10000 + + return randomInt(low, high) + } + + private randomRTMP () { + const low = 1900 + const high = 2100 + + return randomInt(low, high) + } + + private async assignCustomConfigFile () { + if (this.internalServerNumber === this.serverNumber) return + + const basePath = join(root(), 'config') + + const tmpConfigFile = join(basePath, `test-${this.internalServerNumber}.yaml`) + await copy(join(basePath, `test-${this.serverNumber}.yaml`), tmpConfigFile) + + this.customConfigFile = tmpConfigFile + } + + private buildConfigOverride () { + if (!this.parallel) return {} + + return { + listen: { + port: this.port + }, + webserver: { + port: this.port + }, + database: { + suffix: '_test' + this.internalServerNumber + }, + storage: { + tmp: this.getDirectoryPath('tmp') + '/', + tmp_persistent: this.getDirectoryPath('tmp-persistent') + '/', + bin: this.getDirectoryPath('bin') + '/', + avatars: this.getDirectoryPath('avatars') + '/', + web_videos: this.getDirectoryPath('web-videos') + '/', + streaming_playlists: this.getDirectoryPath('streaming-playlists') + '/', + redundancy: this.getDirectoryPath('redundancy') + '/', + logs: this.getDirectoryPath('logs') + '/', + previews: this.getDirectoryPath('previews') + '/', + thumbnails: this.getDirectoryPath('thumbnails') + '/', + storyboards: this.getDirectoryPath('storyboards') + '/', + torrents: this.getDirectoryPath('torrents') + '/', + captions: this.getDirectoryPath('captions') + '/', + cache: this.getDirectoryPath('cache') + '/', + plugins: this.getDirectoryPath('plugins') + '/', + well_known: this.getDirectoryPath('well-known') + '/' + }, + admin: { + email: `admin${this.internalServerNumber}@example.com` + }, + live: { + rtmp: { + port: this.rtmpPort + } + } + } + } + + private assignCommands () { + this.bulk = new BulkCommand(this) + this.cli = new CLICommand(this) + this.customPage = new CustomPagesCommand(this) + this.feed = new FeedCommand(this) + this.logs = new LogsCommand(this) + this.abuses = new AbusesCommand(this) + this.overviews = new OverviewsCommand(this) + this.search = new SearchCommand(this) + this.contactForm = new ContactFormCommand(this) + this.debug = new DebugCommand(this) + this.follows = new FollowsCommand(this) + this.jobs = new JobsCommand(this) + this.metrics = new MetricsCommand(this) + this.plugins = new PluginsCommand(this) + this.redundancy = new RedundancyCommand(this) + this.stats = new StatsCommand(this) + this.config = new ConfigCommand(this) + this.socketIO = new SocketIOCommand(this) + this.accounts = new AccountsCommand(this) + this.blocklist = new BlocklistCommand(this) + this.subscriptions = new SubscriptionsCommand(this) + this.live = new LiveCommand(this) + this.services = new ServicesCommand(this) + this.blacklist = new BlacklistCommand(this) + this.captions = new CaptionsCommand(this) + this.changeOwnership = new ChangeOwnershipCommand(this) + this.playlists = new PlaylistsCommand(this) + this.history = new HistoryCommand(this) + this.imports = new ImportsCommand(this) + this.channelSyncs = new ChannelSyncsCommand(this) + this.streamingPlaylists = new StreamingPlaylistsCommand(this) + this.channels = new ChannelsCommand(this) + this.comments = new CommentsCommand(this) + this.notifications = new NotificationsCommand(this) + this.servers = new ServersCommand(this) + this.login = new LoginCommand(this) + this.users = new UsersCommand(this) + this.videos = new VideosCommand(this) + this.videoStudio = new VideoStudioCommand(this) + this.videoStats = new VideoStatsCommand(this) + this.views = new ViewsCommand(this) + this.twoFactor = new TwoFactorCommand(this) + this.videoToken = new VideoTokenCommand(this) + this.registrations = new RegistrationsCommand(this) + + this.storyboard = new StoryboardCommand(this) + + this.runners = new RunnersCommand(this) + this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this) + this.runnerJobs = new RunnerJobsCommand(this) + this.videoPasswords = new VideoPasswordsCommand(this) + } +} diff --git a/packages/server-commands/src/server/servers-command.ts b/packages/server-commands/src/server/servers-command.ts new file mode 100644 index 000000000..0b722b62f --- /dev/null +++ b/packages/server-commands/src/server/servers-command.ts @@ -0,0 +1,104 @@ +import { exec } from 'child_process' +import { copy, ensureDir, remove } from 'fs-extra/esm' +import { readdir, readFile } from 'fs/promises' +import { basename, join } from 'path' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode } from '@peertube/peertube-models' +import { getFileSize, isGithubCI, root } from '@peertube/peertube-node-utils' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ServersCommand extends AbstractCommand { + + static flushTests (internalServerNumber: number) { + return new Promise((res, rej) => { + const suffix = ` -- ${internalServerNumber}` + + return exec('npm run clean:server:test' + suffix, (err, _stdout, stderr) => { + if (err || stderr) return rej(err || new Error(stderr)) + + return res() + }) + }) + } + + ping (options: OverrideCommandOptions = {}) { + return this.getRequestBody({ + ...options, + + path: '/api/v1/ping', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + cleanupTests () { + const promises: Promise[] = [] + + const saveGithubLogsIfNeeded = async () => { + if (!isGithubCI()) return + + await ensureDir('artifacts') + + const origin = this.buildDirectory('logs/peertube.log') + const destname = `peertube-${this.server.internalServerNumber}.log` + console.log('Saving logs %s.', destname) + + await copy(origin, join('artifacts', destname)) + } + + if (this.server.parallel) { + const promise = saveGithubLogsIfNeeded() + .then(() => ServersCommand.flushTests(this.server.internalServerNumber)) + + promises.push(promise) + } + + if (this.server.customConfigFile) { + promises.push(remove(this.server.customConfigFile)) + } + + return promises + } + + async waitUntilLog (str: string, count = 1, strictCount = true) { + const logfile = this.buildDirectory('logs/peertube.log') + + while (true) { + const buf = await readFile(logfile) + + const matches = buf.toString().match(new RegExp(str, 'g')) + if (matches && matches.length === count) return + if (matches && strictCount === false && matches.length >= count) return + + await wait(1000) + } + } + + buildDirectory (directory: string) { + return join(root(), 'test' + this.server.internalServerNumber, directory) + } + + async countFiles (directory: string) { + const files = await readdir(this.buildDirectory(directory)) + + return files.length + } + + buildWebVideoFilePath (fileUrl: string) { + return this.buildDirectory(join('web-videos', basename(fileUrl))) + } + + buildFragmentedFilePath (videoUUID: string, fileUrl: string) { + return this.buildDirectory(join('streaming-playlists', 'hls', videoUUID, basename(fileUrl))) + } + + getLogContent () { + return readFile(this.buildDirectory('logs/peertube.log')) + } + + async getServerFileSize (subPath: string) { + const path = this.server.servers.buildDirectory(subPath) + + return getFileSize(path) + } +} diff --git a/packages/server-commands/src/server/servers.ts b/packages/server-commands/src/server/servers.ts new file mode 100644 index 000000000..caf9866e1 --- /dev/null +++ b/packages/server-commands/src/server/servers.ts @@ -0,0 +1,68 @@ +import { ensureDir } from 'fs-extra/esm' +import { isGithubCI } from '@peertube/peertube-node-utils' +import { PeerTubeServer, RunServerOptions } from './server.js' + +async function createSingleServer (serverNumber: number, configOverride?: object, options: RunServerOptions = {}) { + const server = new PeerTubeServer({ serverNumber }) + + await server.flushAndRun(configOverride, options) + + return server +} + +function createMultipleServers (totalServers: number, configOverride?: object, options: RunServerOptions = {}) { + const serverPromises: Promise[] = [] + + for (let i = 1; i <= totalServers; i++) { + serverPromises.push(createSingleServer(i, configOverride, options)) + } + + return Promise.all(serverPromises) +} + +function killallServers (servers: PeerTubeServer[]) { + return Promise.all(servers.map(s => s.kill())) +} + +async function cleanupTests (servers: PeerTubeServer[]) { + await killallServers(servers) + + if (isGithubCI()) { + await ensureDir('artifacts') + } + + let p: Promise[] = [] + for (const server of servers) { + p = p.concat(server.servers.cleanupTests()) + } + + return Promise.all(p) +} + +function getServerImportConfig (mode: 'youtube-dl' | 'yt-dlp') { + return { + import: { + videos: { + http: { + youtube_dl_release: { + url: mode === 'youtube-dl' + ? 'https://yt-dl.org/downloads/latest/youtube-dl' + : 'https://api.github.com/repos/yt-dlp/yt-dlp/releases', + + name: mode + } + } + } + } + } +} + +// --------------------------------------------------------------------------- + +export { + createSingleServer, + createMultipleServers, + cleanupTests, + killallServers, + getServerImportConfig +} diff --git a/packages/server-commands/src/server/stats-command.ts b/packages/server-commands/src/server/stats-command.ts new file mode 100644 index 000000000..80acd7bdc --- /dev/null +++ b/packages/server-commands/src/server/stats-command.ts @@ -0,0 +1,25 @@ +import { HttpStatusCode, ServerStats } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class StatsCommand extends AbstractCommand { + + get (options: OverrideCommandOptions & { + useCache?: boolean // default false + } = {}) { + const { useCache = false } = options + const path = '/api/v1/server/stats' + + const query = { + t: useCache ? undefined : new Date().getTime() + } + + return this.getRequestBody({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/shared/abstract-command.ts b/packages/server-commands/src/shared/abstract-command.ts new file mode 100644 index 000000000..bb6522e07 --- /dev/null +++ b/packages/server-commands/src/shared/abstract-command.ts @@ -0,0 +1,225 @@ +import { isAbsolute } from 'path' +import { HttpStatusCodeType } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + makeDeleteRequest, + makeGetRequest, + makePostBodyRequest, + makePutBodyRequest, + makeUploadRequest, + unwrapBody, + unwrapText +} from '../requests/requests.js' + +import type { PeerTubeServer } from '../server/server.js' + +export interface OverrideCommandOptions { + token?: string + expectedStatus?: HttpStatusCodeType +} + +interface InternalCommonCommandOptions extends OverrideCommandOptions { + // Default to server.url + url?: string + + path: string + // If we automatically send the server token if the token is not provided + implicitToken: boolean + defaultExpectedStatus: HttpStatusCodeType + + // Common optional request parameters + contentType?: string + accept?: string + redirects?: number + range?: string + host?: string + headers?: { [ name: string ]: string } + requestType?: string + responseType?: string + xForwardedFor?: string +} + +interface InternalGetCommandOptions extends InternalCommonCommandOptions { + query?: { [ id: string ]: any } +} + +interface InternalDeleteCommandOptions extends InternalCommonCommandOptions { + query?: { [ id: string ]: any } + rawQuery?: string +} + +abstract class AbstractCommand { + + constructor ( + protected server: PeerTubeServer + ) { + + } + + protected getRequestBody (options: InternalGetCommandOptions) { + return unwrapBody(this.getRequest(options)) + } + + protected getRequestText (options: InternalGetCommandOptions) { + return unwrapText(this.getRequest(options)) + } + + protected getRawRequest (options: Omit) { + const { url, range } = options + const { host, protocol, pathname } = new URL(url) + + return this.getRequest({ + ...options, + + token: this.buildCommonRequestToken(options), + defaultExpectedStatus: this.buildExpectedStatus(options), + + url: `${protocol}//${host}`, + path: pathname, + range + }) + } + + protected getRequest (options: InternalGetCommandOptions) { + const { query } = options + + return makeGetRequest({ + ...this.buildCommonRequestOptions(options), + + query + }) + } + + protected deleteRequest (options: InternalDeleteCommandOptions) { + const { query, rawQuery } = options + + return makeDeleteRequest({ + ...this.buildCommonRequestOptions(options), + + query, + rawQuery + }) + } + + protected putBodyRequest (options: InternalCommonCommandOptions & { + fields?: { [ fieldName: string ]: any } + headers?: { [name: string]: string } + }) { + const { fields, headers } = options + + return makePutBodyRequest({ + ...this.buildCommonRequestOptions(options), + + fields, + headers + }) + } + + protected postBodyRequest (options: InternalCommonCommandOptions & { + fields?: { [ fieldName: string ]: any } + headers?: { [name: string]: string } + }) { + const { fields, headers } = options + + return makePostBodyRequest({ + ...this.buildCommonRequestOptions(options), + + fields, + headers + }) + } + + protected postUploadRequest (options: InternalCommonCommandOptions & { + fields?: { [ fieldName: string ]: any } + attaches?: { [ fieldName: string ]: any } + }) { + const { fields, attaches } = options + + return makeUploadRequest({ + ...this.buildCommonRequestOptions(options), + + method: 'POST', + fields, + attaches + }) + } + + protected putUploadRequest (options: InternalCommonCommandOptions & { + fields?: { [ fieldName: string ]: any } + attaches?: { [ fieldName: string ]: any } + }) { + const { fields, attaches } = options + + return makeUploadRequest({ + ...this.buildCommonRequestOptions(options), + + method: 'PUT', + fields, + attaches + }) + } + + protected updateImageRequest (options: InternalCommonCommandOptions & { + fixture: string + fieldname: string + }) { + const filePath = isAbsolute(options.fixture) + ? options.fixture + : buildAbsoluteFixturePath(options.fixture) + + return this.postUploadRequest({ + ...options, + + fields: {}, + attaches: { [options.fieldname]: filePath } + }) + } + + protected buildCommonRequestOptions (options: InternalCommonCommandOptions) { + const { url, path, redirects, contentType, accept, range, host, headers, requestType, xForwardedFor, responseType } = options + + return { + url: url ?? this.server.url, + path, + + token: this.buildCommonRequestToken(options), + expectedStatus: this.buildExpectedStatus(options), + + redirects, + contentType, + range, + host, + accept, + headers, + type: requestType, + responseType, + xForwardedFor + } + } + + protected buildCommonRequestToken (options: Pick) { + const { token } = options + + const fallbackToken = options.implicitToken + ? this.server.accessToken + : undefined + + return token !== undefined ? token : fallbackToken + } + + protected buildExpectedStatus (options: Pick) { + const { expectedStatus, defaultExpectedStatus } = options + + return expectedStatus !== undefined ? expectedStatus : defaultExpectedStatus + } + + protected buildVideoPasswordHeader (videoPassword: string) { + return videoPassword !== undefined && videoPassword !== null + ? { 'x-peertube-video-password': videoPassword } + : undefined + } +} + +export { + AbstractCommand +} diff --git a/packages/server-commands/src/shared/index.ts b/packages/server-commands/src/shared/index.ts new file mode 100644 index 000000000..795db3d55 --- /dev/null +++ b/packages/server-commands/src/shared/index.ts @@ -0,0 +1 @@ +export * from './abstract-command.js' diff --git a/packages/server-commands/src/socket/index.ts b/packages/server-commands/src/socket/index.ts new file mode 100644 index 000000000..24b8f4b46 --- /dev/null +++ b/packages/server-commands/src/socket/index.ts @@ -0,0 +1 @@ +export * from './socket-io-command.js' diff --git a/packages/server-commands/src/socket/socket-io-command.ts b/packages/server-commands/src/socket/socket-io-command.ts new file mode 100644 index 000000000..9c18c2a1f --- /dev/null +++ b/packages/server-commands/src/socket/socket-io-command.ts @@ -0,0 +1,24 @@ +import { io } from 'socket.io-client' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class SocketIOCommand extends AbstractCommand { + + getUserNotificationSocket (options: OverrideCommandOptions = {}) { + return io(this.server.url + '/user-notifications', { + query: { accessToken: options.token ?? this.server.accessToken } + }) + } + + getLiveNotificationSocket () { + return io(this.server.url + '/live-videos') + } + + getRunnersSocket (options: { + runnerToken: string + }) { + return io(this.server.url + '/runners', { + reconnection: false, + auth: { runnerToken: options.runnerToken } + }) + } +} diff --git a/packages/server-commands/src/users/accounts-command.ts b/packages/server-commands/src/users/accounts-command.ts new file mode 100644 index 000000000..fd98b7eea --- /dev/null +++ b/packages/server-commands/src/users/accounts-command.ts @@ -0,0 +1,76 @@ +import { Account, AccountVideoRate, ActorFollow, HttpStatusCode, ResultList, VideoRateType } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class AccountsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + sort?: string // default -createdAt + } = {}) { + const { sort = '-createdAt' } = options + const path = '/api/v1/accounts' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + accountName: string + }) { + const path = '/api/v1/accounts/' + options.accountName + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listRatings (options: OverrideCommandOptions & { + accountName: string + rating?: VideoRateType + }) { + const { rating, accountName } = options + const path = '/api/v1/accounts/' + accountName + '/ratings' + + const query = { rating } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listFollowers (options: OverrideCommandOptions & { + accountName: string + start?: number + count?: number + sort?: string + search?: string + }) { + const { accountName, start, count, sort, search } = options + const path = '/api/v1/accounts/' + accountName + '/followers' + + const query = { start, count, sort, search } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/users/accounts.ts b/packages/server-commands/src/users/accounts.ts new file mode 100644 index 000000000..3b8b9d36a --- /dev/null +++ b/packages/server-commands/src/users/accounts.ts @@ -0,0 +1,15 @@ +import { PeerTubeServer } from '../server/server.js' + +async function setDefaultAccountAvatar (serversArg: PeerTubeServer | PeerTubeServer[], token?: string) { + const servers = Array.isArray(serversArg) + ? serversArg + : [ serversArg ] + + for (const server of servers) { + await server.users.updateMyAvatar({ fixture: 'avatar.png', token }) + } +} + +export { + setDefaultAccountAvatar +} diff --git a/packages/server-commands/src/users/blocklist-command.ts b/packages/server-commands/src/users/blocklist-command.ts new file mode 100644 index 000000000..c77c56131 --- /dev/null +++ b/packages/server-commands/src/users/blocklist-command.ts @@ -0,0 +1,165 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { AccountBlock, BlockStatus, HttpStatusCode, ResultList, ServerBlock } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +type ListBlocklistOptions = OverrideCommandOptions & { + start: number + count: number + + sort?: string // default -createdAt + + search?: string +} + +export class BlocklistCommand extends AbstractCommand { + + listMyAccountBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/users/me/blocklist/accounts' + + return this.listBlocklist(options, path) + } + + listMyServerBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/users/me/blocklist/servers' + + return this.listBlocklist(options, path) + } + + listServerAccountBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/server/blocklist/accounts' + + return this.listBlocklist(options, path) + } + + listServerServerBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/server/blocklist/servers' + + return this.listBlocklist(options, path) + } + + // --------------------------------------------------------------------------- + + getStatus (options: OverrideCommandOptions & { + accounts?: string[] + hosts?: string[] + }) { + const { accounts, hosts } = options + + const path = '/api/v1/blocklist/status' + + return this.getRequestBody({ + ...options, + + path, + query: { + accounts, + hosts + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + addToMyBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/users/me/blocklist/accounts' + : '/api/v1/users/me/blocklist/servers' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + accountName: account, + host: server + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + addToServerBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/server/blocklist/accounts' + : '/api/v1/server/blocklist/servers' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + accountName: account, + host: server + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + removeFromMyBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/users/me/blocklist/accounts/' + account + : '/api/v1/users/me/blocklist/servers/' + server + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeFromServerBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/server/blocklist/accounts/' + account + : '/api/v1/server/blocklist/servers/' + server + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + private listBlocklist (options: ListBlocklistOptions, path: string) { + const { start, count, search, sort = '-createdAt' } = options + + return this.getRequestBody>({ + ...options, + + path, + query: { start, count, sort, search }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + +} diff --git a/packages/server-commands/src/users/index.ts b/packages/server-commands/src/users/index.ts new file mode 100644 index 000000000..baa048a43 --- /dev/null +++ b/packages/server-commands/src/users/index.ts @@ -0,0 +1,10 @@ +export * from './accounts-command.js' +export * from './accounts.js' +export * from './blocklist-command.js' +export * from './login.js' +export * from './login-command.js' +export * from './notifications-command.js' +export * from './registrations-command.js' +export * from './subscriptions-command.js' +export * from './two-factor-command.js' +export * from './users-command.js' diff --git a/packages/server-commands/src/users/login-command.ts b/packages/server-commands/src/users/login-command.ts new file mode 100644 index 000000000..92d123dfc --- /dev/null +++ b/packages/server-commands/src/users/login-command.ts @@ -0,0 +1,159 @@ +import { HttpStatusCode, PeerTubeProblemDocument } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +type LoginOptions = OverrideCommandOptions & { + client?: { id?: string, secret?: string } + user?: { username: string, password?: string } + otpToken?: string +} + +export class LoginCommand extends AbstractCommand { + + async login (options: LoginOptions = {}) { + const res = await this._login(options) + + return this.unwrapLoginBody(res.body) + } + + async loginAndGetResponse (options: LoginOptions = {}) { + const res = await this._login(options) + + return { + res, + body: this.unwrapLoginBody(res.body) + } + } + + getAccessToken (arg1?: { username: string, password?: string }): Promise + getAccessToken (arg1: string, password?: string): Promise + async getAccessToken (arg1?: { username: string, password?: string } | string, password?: string) { + let user: { username: string, password?: string } + + if (!arg1) user = this.server.store.user + else if (typeof arg1 === 'object') user = arg1 + else user = { username: arg1, password } + + try { + const body = await this.login({ user }) + + return body.access_token + } catch (err) { + throw new Error(`Cannot authenticate. Please check your username/password. (${err})`) + } + } + + loginUsingExternalToken (options: OverrideCommandOptions & { + username: string + externalAuthToken: string + }) { + const { username, externalAuthToken } = options + const path = '/api/v1/users/token' + + const body = { + client_id: this.server.store.client.id, + client_secret: this.server.store.client.secret, + username, + response_type: 'code', + grant_type: 'password', + scope: 'upload', + externalAuthToken + } + + return this.postBodyRequest({ + ...options, + + path, + requestType: 'form', + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + logout (options: OverrideCommandOptions & { + token: string + }) { + const path = '/api/v1/users/revoke-token' + + return unwrapBody<{ redirectUrl: string }>(this.postBodyRequest({ + ...options, + + path, + requestType: 'form', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + refreshToken (options: OverrideCommandOptions & { + refreshToken: string + }) { + const path = '/api/v1/users/token' + + const body = { + client_id: this.server.store.client.id, + client_secret: this.server.store.client.secret, + refresh_token: options.refreshToken, + response_type: 'code', + grant_type: 'refresh_token' + } + + return this.postBodyRequest({ + ...options, + + path, + requestType: 'form', + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getClient (options: OverrideCommandOptions = {}) { + const path = '/api/v1/oauth-clients/local' + + return this.getRequestBody<{ client_id: string, client_secret: string }>({ + ...options, + + path, + host: this.server.host, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + private _login (options: LoginOptions) { + const { client = this.server.store.client, user = this.server.store.user, otpToken } = options + const path = '/api/v1/users/token' + + const body = { + client_id: client.id, + client_secret: client.secret, + username: user.username, + password: user.password ?? 'password', + response_type: 'code', + grant_type: 'password', + scope: 'upload' + } + + const headers = otpToken + ? { 'x-peertube-otp': otpToken } + : {} + + return this.postBodyRequest({ + ...options, + + path, + headers, + requestType: 'form', + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + private unwrapLoginBody (body: any) { + return body as { access_token: string, refresh_token: string } & PeerTubeProblemDocument + } +} diff --git a/packages/server-commands/src/users/login.ts b/packages/server-commands/src/users/login.ts new file mode 100644 index 000000000..c48c42c72 --- /dev/null +++ b/packages/server-commands/src/users/login.ts @@ -0,0 +1,19 @@ +import { PeerTubeServer } from '../server/server.js' + +function setAccessTokensToServers (servers: PeerTubeServer[]) { + const tasks: Promise[] = [] + + for (const server of servers) { + const p = server.login.getAccessToken() + .then(t => { server.accessToken = t }) + tasks.push(p) + } + + return Promise.all(tasks) +} + +// --------------------------------------------------------------------------- + +export { + setAccessTokensToServers +} diff --git a/packages/server-commands/src/users/notifications-command.ts b/packages/server-commands/src/users/notifications-command.ts new file mode 100644 index 000000000..d90d56900 --- /dev/null +++ b/packages/server-commands/src/users/notifications-command.ts @@ -0,0 +1,85 @@ +import { HttpStatusCode, ResultList, UserNotification, UserNotificationSetting } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class NotificationsCommand extends AbstractCommand { + + updateMySettings (options: OverrideCommandOptions & { + settings: UserNotificationSetting + }) { + const path = '/api/v1/users/me/notification-settings' + + return this.putBodyRequest({ + ...options, + + path, + fields: options.settings, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + start?: number + count?: number + unread?: boolean + sort?: string + }) { + const { start, count, unread, sort = '-createdAt' } = options + const path = '/api/v1/users/me/notifications' + + return this.getRequestBody>({ + ...options, + + path, + query: { + start, + count, + sort, + unread + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + markAsRead (options: OverrideCommandOptions & { + ids: number[] + }) { + const { ids } = options + const path = '/api/v1/users/me/notifications/read' + + return this.postBodyRequest({ + ...options, + + path, + fields: { ids }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + markAsReadAll (options: OverrideCommandOptions) { + const path = '/api/v1/users/me/notifications/read-all' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async getLatest (options: OverrideCommandOptions = {}) { + const { total, data } = await this.list({ + ...options, + start: 0, + count: 1, + sort: '-createdAt' + }) + + if (total === 0) return undefined + + return data[0] + } +} diff --git a/packages/server-commands/src/users/registrations-command.ts b/packages/server-commands/src/users/registrations-command.ts new file mode 100644 index 000000000..2111fbd39 --- /dev/null +++ b/packages/server-commands/src/users/registrations-command.ts @@ -0,0 +1,157 @@ +import { pick } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + ResultList, + UserRegistration, + UserRegistrationRequest, + UserRegistrationUpdateState +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class RegistrationsCommand extends AbstractCommand { + + register (options: OverrideCommandOptions & Partial & Pick) { + const { password = 'password', email = options.username + '@example.com' } = options + const path = '/api/v1/users/register' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + ...pick(options, [ 'username', 'displayName', 'channel' ]), + + password, + email + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + requestRegistration ( + options: OverrideCommandOptions & Partial & Pick + ) { + const { password = 'password', email = options.username + '@example.com' } = options + const path = '/api/v1/users/registrations/request' + + return unwrapBody(this.postBodyRequest({ + ...options, + + path, + fields: { + ...pick(options, [ 'username', 'displayName', 'channel', 'registrationReason' ]), + + password, + email + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + // --------------------------------------------------------------------------- + + accept (options: OverrideCommandOptions & { id: number } & UserRegistrationUpdateState) { + const { id } = options + const path = '/api/v1/users/registrations/' + id + '/accept' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'moderationResponse', 'preventEmailDelivery' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + reject (options: OverrideCommandOptions & { id: number } & UserRegistrationUpdateState) { + const { id } = options + const path = '/api/v1/users/registrations/' + id + '/reject' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'moderationResponse', 'preventEmailDelivery' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + delete (options: OverrideCommandOptions & { + id: number + }) { + const { id } = options + const path = '/api/v1/users/registrations/' + id + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + } = {}) { + const path = '/api/v1/users/registrations' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'search' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + askSendVerifyEmail (options: OverrideCommandOptions & { + email: string + }) { + const { email } = options + const path = '/api/v1/users/registrations/ask-send-verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { email }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + verifyEmail (options: OverrideCommandOptions & { + registrationId: number + verificationString: string + }) { + const { registrationId, verificationString } = options + const path = '/api/v1/users/registrations/' + registrationId + '/verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + verificationString + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/users/subscriptions-command.ts b/packages/server-commands/src/users/subscriptions-command.ts new file mode 100644 index 000000000..52a1f0e51 --- /dev/null +++ b/packages/server-commands/src/users/subscriptions-command.ts @@ -0,0 +1,83 @@ +import { HttpStatusCode, ResultList, VideoChannel } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class SubscriptionsCommand extends AbstractCommand { + + add (options: OverrideCommandOptions & { + targetUri: string + }) { + const path = '/api/v1/users/me/subscriptions' + + return this.postBodyRequest({ + ...options, + + path, + fields: { uri: options.targetUri }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + sort?: string // default -createdAt + search?: string + } = {}) { + const { sort = '-createdAt', search } = options + const path = '/api/v1/users/me/subscriptions' + + return this.getRequestBody>({ + ...options, + + path, + query: { + sort, + search + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + uri: string + }) { + const path = '/api/v1/users/me/subscriptions/' + options.uri + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + remove (options: OverrideCommandOptions & { + uri: string + }) { + const path = '/api/v1/users/me/subscriptions/' + options.uri + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + exist (options: OverrideCommandOptions & { + uris: string[] + }) { + const path = '/api/v1/users/me/subscriptions/exist' + + return this.getRequestBody<{ [id: string ]: boolean }>({ + ...options, + + path, + query: { 'uris[]': options.uris }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/users/two-factor-command.ts b/packages/server-commands/src/users/two-factor-command.ts new file mode 100644 index 000000000..cf3d6cb68 --- /dev/null +++ b/packages/server-commands/src/users/two-factor-command.ts @@ -0,0 +1,92 @@ +import { TOTP } from 'otpauth' +import { HttpStatusCode, TwoFactorEnableResult } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class TwoFactorCommand extends AbstractCommand { + + static buildOTP (options: { + secret: string + }) { + const { secret } = options + + return new TOTP({ + issuer: 'PeerTube', + algorithm: 'SHA1', + digits: 6, + period: 30, + secret + }) + } + + request (options: OverrideCommandOptions & { + userId: number + currentPassword?: string + }) { + const { currentPassword, userId } = options + + const path = '/api/v1/users/' + userId + '/two-factor/request' + + return unwrapBody(this.postBodyRequest({ + ...options, + + path, + fields: { currentPassword }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + confirmRequest (options: OverrideCommandOptions & { + userId: number + requestToken: string + otpToken: string + }) { + const { userId, requestToken, otpToken } = options + + const path = '/api/v1/users/' + userId + '/two-factor/confirm-request' + + return this.postBodyRequest({ + ...options, + + path, + fields: { requestToken, otpToken }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + disable (options: OverrideCommandOptions & { + userId: number + currentPassword?: string + }) { + const { userId, currentPassword } = options + const path = '/api/v1/users/' + userId + '/two-factor/disable' + + return this.postBodyRequest({ + ...options, + + path, + fields: { currentPassword }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async requestAndConfirm (options: OverrideCommandOptions & { + userId: number + currentPassword?: string + }) { + const { userId, currentPassword } = options + + const { otpRequest } = await this.request({ userId, currentPassword }) + + await this.confirmRequest({ + userId, + requestToken: otpRequest.requestToken, + otpToken: TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate() + }) + + return otpRequest + } +} diff --git a/packages/server-commands/src/users/users-command.ts b/packages/server-commands/src/users/users-command.ts new file mode 100644 index 000000000..d3b11939e --- /dev/null +++ b/packages/server-commands/src/users/users-command.ts @@ -0,0 +1,389 @@ +import { omit, pick } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + MyUser, + ResultList, + ScopedToken, + User, + UserAdminFlagType, + UserCreateResult, + UserRole, + UserRoleType, + UserUpdate, + UserUpdateMe, + UserVideoQuota, + UserVideoRate +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class UsersCommand extends AbstractCommand { + + askResetPassword (options: OverrideCommandOptions & { + email: string + }) { + const { email } = options + const path = '/api/v1/users/ask-reset-password' + + return this.postBodyRequest({ + ...options, + + path, + fields: { email }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + resetPassword (options: OverrideCommandOptions & { + userId: number + verificationString: string + password: string + }) { + const { userId, verificationString, password } = options + const path = '/api/v1/users/' + userId + '/reset-password' + + return this.postBodyRequest({ + ...options, + + path, + fields: { password, verificationString }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + askSendVerifyEmail (options: OverrideCommandOptions & { + email: string + }) { + const { email } = options + const path = '/api/v1/users/ask-send-verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { email }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + verifyEmail (options: OverrideCommandOptions & { + userId: number + verificationString: string + isPendingEmail?: boolean // default false + }) { + const { userId, verificationString, isPendingEmail = false } = options + const path = '/api/v1/users/' + userId + '/verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + verificationString, + isPendingEmail + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + banUser (options: OverrideCommandOptions & { + userId: number + reason?: string + }) { + const { userId, reason } = options + const path = '/api/v1/users' + '/' + userId + '/block' + + return this.postBodyRequest({ + ...options, + + path, + fields: { reason }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + unbanUser (options: OverrideCommandOptions & { + userId: number + }) { + const { userId } = options + const path = '/api/v1/users' + '/' + userId + '/unblock' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + getMyScopedTokens (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/scoped-tokens' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + renewMyScopedTokens (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/scoped-tokens' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + create (options: OverrideCommandOptions & { + username: string + password?: string + videoQuota?: number + videoQuotaDaily?: number + role?: UserRoleType + adminFlags?: UserAdminFlagType + }) { + const { + username, + adminFlags, + password = 'password', + videoQuota, + videoQuotaDaily, + role = UserRole.USER + } = options + + const path = '/api/v1/users' + + return unwrapBody<{ user: UserCreateResult }>(this.postBodyRequest({ + ...options, + + path, + fields: { + username, + password, + role, + adminFlags, + email: username + '@example.com', + videoQuota, + videoQuotaDaily + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })).then(res => res.user) + } + + async generate (username: string, role?: UserRoleType) { + const password = 'password' + const user = await this.create({ username, password, role }) + + const token = await this.server.login.getAccessToken({ username, password }) + + const me = await this.getMyInfo({ token }) + + return { + token, + userId: user.id, + userChannelId: me.videoChannels[0].id, + userChannelName: me.videoChannels[0].name, + password + } + } + + async generateUserAndToken (username: string, role?: UserRoleType) { + const password = 'password' + await this.create({ username, password, role }) + + return this.server.login.getAccessToken({ username, password }) + } + + // --------------------------------------------------------------------------- + + getMyInfo (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/me' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getMyQuotaUsed (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/me/video-quota-used' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getMyRating (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + const path = '/api/v1/users/me/videos/' + videoId + '/rating' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + deleteMe (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/me' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + updateMe (options: OverrideCommandOptions & UserUpdateMe) { + const path = '/api/v1/users/me' + + const toSend: UserUpdateMe = omit(options, [ 'expectedStatus', 'token' ]) + + return this.putBodyRequest({ + ...options, + + path, + fields: toSend, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + updateMyAvatar (options: OverrideCommandOptions & { + fixture: string + }) { + const { fixture } = options + const path = '/api/v1/users/me/avatar/pick' + + return this.updateImageRequest({ + ...options, + + path, + fixture, + fieldname: 'avatarfile', + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + get (options: OverrideCommandOptions & { + userId: number + withStats?: boolean // default false + }) { + const { userId, withStats } = options + const path = '/api/v1/users/' + userId + + return this.getRequestBody({ + ...options, + + path, + query: { withStats }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + blocked?: boolean + } = {}) { + const path = '/api/v1/users' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'search', 'blocked' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + remove (options: OverrideCommandOptions & { + userId: number + }) { + const { userId } = options + const path = '/api/v1/users/' + userId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + update (options: OverrideCommandOptions & { + userId: number + email?: string + emailVerified?: boolean + videoQuota?: number + videoQuotaDaily?: number + password?: string + adminFlags?: UserAdminFlagType + pluginAuth?: string + role?: UserRoleType + }) { + const path = '/api/v1/users/' + options.userId + + const toSend: UserUpdate = {} + if (options.password !== undefined && options.password !== null) toSend.password = options.password + if (options.email !== undefined && options.email !== null) toSend.email = options.email + if (options.emailVerified !== undefined && options.emailVerified !== null) toSend.emailVerified = options.emailVerified + if (options.videoQuota !== undefined && options.videoQuota !== null) toSend.videoQuota = options.videoQuota + if (options.videoQuotaDaily !== undefined && options.videoQuotaDaily !== null) toSend.videoQuotaDaily = options.videoQuotaDaily + if (options.role !== undefined && options.role !== null) toSend.role = options.role + if (options.adminFlags !== undefined && options.adminFlags !== null) toSend.adminFlags = options.adminFlags + if (options.pluginAuth !== undefined) toSend.pluginAuth = options.pluginAuth + + return this.putBodyRequest({ + ...options, + + path, + fields: toSend, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/blacklist-command.ts b/packages/server-commands/src/videos/blacklist-command.ts new file mode 100644 index 000000000..d41001e26 --- /dev/null +++ b/packages/server-commands/src/videos/blacklist-command.ts @@ -0,0 +1,74 @@ +import { HttpStatusCode, ResultList, VideoBlacklist, VideoBlacklistType_Type } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class BlacklistCommand extends AbstractCommand { + + add (options: OverrideCommandOptions & { + videoId: number | string + reason?: string + unfederate?: boolean + }) { + const { videoId, reason, unfederate } = options + const path = '/api/v1/videos/' + videoId + '/blacklist' + + return this.postBodyRequest({ + ...options, + + path, + fields: { reason, unfederate }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + update (options: OverrideCommandOptions & { + videoId: number | string + reason?: string + }) { + const { videoId, reason } = options + const path = '/api/v1/videos/' + videoId + '/blacklist' + + return this.putBodyRequest({ + ...options, + + path, + fields: { reason }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + remove (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + const path = '/api/v1/videos/' + videoId + '/blacklist' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + sort?: string + type?: VideoBlacklistType_Type + } = {}) { + const { sort, type } = options + const path = '/api/v1/videos/blacklist/' + + const query = { sort, type } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/videos/captions-command.ts b/packages/server-commands/src/videos/captions-command.ts new file mode 100644 index 000000000..a8336aa27 --- /dev/null +++ b/packages/server-commands/src/videos/captions-command.ts @@ -0,0 +1,67 @@ +import { HttpStatusCode, ResultList, VideoCaption } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class CaptionsCommand extends AbstractCommand { + + add (options: OverrideCommandOptions & { + videoId: string | number + language: string + fixture: string + mimeType?: string + }) { + const { videoId, language, fixture, mimeType } = options + + const path = '/api/v1/videos/' + videoId + '/captions/' + language + + const captionfile = buildAbsoluteFixturePath(fixture) + const captionfileAttach = mimeType + ? [ captionfile, { contentType: mimeType } ] + : captionfile + + return this.putUploadRequest({ + ...options, + + path, + fields: {}, + attaches: { + captionfile: captionfileAttach + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + videoId: string | number + videoPassword?: string + }) { + const { videoId, videoPassword } = options + const path = '/api/v1/videos/' + videoId + '/captions' + + return this.getRequestBody>({ + ...options, + + path, + headers: this.buildVideoPasswordHeader(videoPassword), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + delete (options: OverrideCommandOptions & { + videoId: string | number + language: string + }) { + const { videoId, language } = options + const path = '/api/v1/videos/' + videoId + '/captions/' + language + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/change-ownership-command.ts b/packages/server-commands/src/videos/change-ownership-command.ts new file mode 100644 index 000000000..1dc7c2c0f --- /dev/null +++ b/packages/server-commands/src/videos/change-ownership-command.ts @@ -0,0 +1,67 @@ +import { HttpStatusCode, ResultList, VideoChangeOwnership } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ChangeOwnershipCommand extends AbstractCommand { + + create (options: OverrideCommandOptions & { + videoId: number | string + username: string + }) { + const { videoId, username } = options + const path = '/api/v1/videos/' + videoId + '/give-ownership' + + return this.postBodyRequest({ + ...options, + + path, + fields: { username }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/ownership' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort: '-createdAt' }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + accept (options: OverrideCommandOptions & { + ownershipId: number + channelId: number + }) { + const { ownershipId, channelId } = options + const path = '/api/v1/videos/ownership/' + ownershipId + '/accept' + + return this.postBodyRequest({ + ...options, + + path, + fields: { channelId }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + refuse (options: OverrideCommandOptions & { + ownershipId: number + }) { + const { ownershipId } = options + const path = '/api/v1/videos/ownership/' + ownershipId + '/refuse' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/channel-syncs-command.ts b/packages/server-commands/src/videos/channel-syncs-command.ts new file mode 100644 index 000000000..718000c8a --- /dev/null +++ b/packages/server-commands/src/videos/channel-syncs-command.ts @@ -0,0 +1,55 @@ +import { HttpStatusCode, ResultList, VideoChannelSync, VideoChannelSyncCreate } from '@peertube/peertube-models' +import { pick } from '@peertube/peertube-core-utils' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ChannelSyncsCommand extends AbstractCommand { + private static readonly API_PATH = '/api/v1/video-channel-syncs' + + listByAccount (options: OverrideCommandOptions & { + accountName: string + start?: number + count?: number + sort?: string + }) { + const { accountName, sort = 'createdAt' } = options + + const path = `/api/v1/accounts/${accountName}/video-channel-syncs` + + return this.getRequestBody>({ + ...options, + + path, + query: { sort, ...pick(options, [ 'start', 'count' ]) }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async create (options: OverrideCommandOptions & { + attributes: VideoChannelSyncCreate + }) { + return unwrapBody<{ videoChannelSync: VideoChannelSync }>(this.postBodyRequest({ + ...options, + + path: ChannelSyncsCommand.API_PATH, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + delete (options: OverrideCommandOptions & { + channelSyncId: number + }) { + const path = `${ChannelSyncsCommand.API_PATH}/${options.channelSyncId}` + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/channels-command.ts b/packages/server-commands/src/videos/channels-command.ts new file mode 100644 index 000000000..772677d39 --- /dev/null +++ b/packages/server-commands/src/videos/channels-command.ts @@ -0,0 +1,202 @@ +import { pick } from '@peertube/peertube-core-utils' +import { + ActorFollow, + HttpStatusCode, + ResultList, + VideoChannel, + VideoChannelCreate, + VideoChannelCreateResult, + VideoChannelUpdate, + VideosImportInChannelCreate +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ChannelsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + withStats?: boolean + } = {}) { + const path = '/api/v1/video-channels' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'withStats' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listByAccount (options: OverrideCommandOptions & { + accountName: string + start?: number + count?: number + sort?: string + withStats?: boolean + search?: string + }) { + const { accountName, sort = 'createdAt' } = options + const path = '/api/v1/accounts/' + accountName + '/video-channels' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort, ...pick(options, [ 'start', 'count', 'withStats', 'search' ]) }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async create (options: OverrideCommandOptions & { + attributes: Partial + }) { + const path = '/api/v1/video-channels/' + + // Default attributes + const defaultAttributes = { + displayName: 'my super video channel', + description: 'my super channel description', + support: 'my super channel support' + } + const attributes = { ...defaultAttributes, ...options.attributes } + + const body = await unwrapBody<{ videoChannel: VideoChannelCreateResult }>(this.postBodyRequest({ + ...options, + + path, + fields: attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return body.videoChannel + } + + update (options: OverrideCommandOptions & { + channelName: string + attributes: VideoChannelUpdate + }) { + const { channelName, attributes } = options + const path = '/api/v1/video-channels/' + channelName + + return this.putBodyRequest({ + ...options, + + path, + fields: attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + delete (options: OverrideCommandOptions & { + channelName: string + }) { + const path = '/api/v1/video-channels/' + options.channelName + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + get (options: OverrideCommandOptions & { + channelName: string + }) { + const path = '/api/v1/video-channels/' + options.channelName + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateImage (options: OverrideCommandOptions & { + fixture: string + channelName: string | number + type: 'avatar' | 'banner' + }) { + const { channelName, fixture, type } = options + + const path = `/api/v1/video-channels/${channelName}/${type}/pick` + + return this.updateImageRequest({ + ...options, + + path, + fixture, + fieldname: type + 'file', + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + deleteImage (options: OverrideCommandOptions & { + channelName: string | number + type: 'avatar' | 'banner' + }) { + const { channelName, type } = options + + const path = `/api/v1/video-channels/${channelName}/${type}` + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + listFollowers (options: OverrideCommandOptions & { + channelName: string + start?: number + count?: number + sort?: string + search?: string + }) { + const { channelName, start, count, sort, search } = options + const path = '/api/v1/video-channels/' + channelName + '/followers' + + const query = { start, count, sort, search } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + importVideos (options: OverrideCommandOptions & VideosImportInChannelCreate & { + channelName: string + }) { + const { channelName, externalChannelUrl, videoChannelSyncId } = options + + const path = `/api/v1/video-channels/${channelName}/import-videos` + + return this.postBodyRequest({ + ...options, + + path, + fields: { externalChannelUrl, videoChannelSyncId }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/channels.ts b/packages/server-commands/src/videos/channels.ts new file mode 100644 index 000000000..e3487d024 --- /dev/null +++ b/packages/server-commands/src/videos/channels.ts @@ -0,0 +1,29 @@ +import { PeerTubeServer } from '../server/server.js' + +function setDefaultVideoChannel (servers: PeerTubeServer[]) { + const tasks: Promise[] = [] + + for (const server of servers) { + const p = server.users.getMyInfo() + .then(user => { server.store.channel = user.videoChannels[0] }) + + tasks.push(p) + } + + return Promise.all(tasks) +} + +async function setDefaultChannelAvatar (serversArg: PeerTubeServer | PeerTubeServer[], channelName: string = 'root_channel') { + const servers = Array.isArray(serversArg) + ? serversArg + : [ serversArg ] + + for (const server of servers) { + await server.channels.updateImage({ channelName, fixture: 'avatar.png', type: 'avatar' }) + } +} + +export { + setDefaultVideoChannel, + setDefaultChannelAvatar +} diff --git a/packages/server-commands/src/videos/comments-command.ts b/packages/server-commands/src/videos/comments-command.ts new file mode 100644 index 000000000..4835ae1fb --- /dev/null +++ b/packages/server-commands/src/videos/comments-command.ts @@ -0,0 +1,159 @@ +import { pick } from '@peertube/peertube-core-utils' +import { HttpStatusCode, ResultList, VideoComment, VideoCommentThreads, VideoCommentThreadTree } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class CommentsCommand extends AbstractCommand { + + private lastVideoId: number | string + private lastThreadId: number + private lastReplyId: number + + listForAdmin (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + isLocal?: boolean + onLocalVideo?: boolean + search?: string + searchAccount?: string + searchVideo?: string + } = {}) { + const { sort = '-createdAt' } = options + const path = '/api/v1/videos/comments' + + const query = { sort, ...pick(options, [ 'start', 'count', 'isLocal', 'onLocalVideo', 'search', 'searchAccount', 'searchVideo' ]) } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listThreads (options: OverrideCommandOptions & { + videoId: number | string + videoPassword?: string + start?: number + count?: number + sort?: string + }) { + const { start, count, sort, videoId, videoPassword } = options + const path = '/api/v1/videos/' + videoId + '/comment-threads' + + return this.getRequestBody({ + ...options, + + path, + query: { start, count, sort }, + headers: this.buildVideoPasswordHeader(videoPassword), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getThread (options: OverrideCommandOptions & { + videoId: number | string + threadId: number + }) { + const { videoId, threadId } = options + const path = '/api/v1/videos/' + videoId + '/comment-threads/' + threadId + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async createThread (options: OverrideCommandOptions & { + videoId: number | string + text: string + videoPassword?: string + }) { + const { videoId, text, videoPassword } = options + const path = '/api/v1/videos/' + videoId + '/comment-threads' + + const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ + ...options, + + path, + fields: { text }, + headers: this.buildVideoPasswordHeader(videoPassword), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + this.lastThreadId = body.comment?.id + this.lastVideoId = videoId + + return body.comment + } + + async addReply (options: OverrideCommandOptions & { + videoId: number | string + toCommentId: number + text: string + videoPassword?: string + }) { + const { videoId, toCommentId, text, videoPassword } = options + const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId + + const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ + ...options, + + path, + fields: { text }, + headers: this.buildVideoPasswordHeader(videoPassword), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + this.lastReplyId = body.comment?.id + + return body.comment + } + + async addReplyToLastReply (options: OverrideCommandOptions & { + text: string + }) { + return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastReplyId }) + } + + async addReplyToLastThread (options: OverrideCommandOptions & { + text: string + }) { + return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastThreadId }) + } + + async findCommentId (options: OverrideCommandOptions & { + videoId: number | string + text: string + }) { + const { videoId, text } = options + const { data } = await this.listThreads({ videoId, count: 25, sort: '-createdAt' }) + + return data.find(c => c.text === text).id + } + + delete (options: OverrideCommandOptions & { + videoId: number | string + commentId: number + }) { + const { videoId, commentId } = options + const path = '/api/v1/videos/' + videoId + '/comments/' + commentId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/history-command.ts b/packages/server-commands/src/videos/history-command.ts new file mode 100644 index 000000000..fd032504a --- /dev/null +++ b/packages/server-commands/src/videos/history-command.ts @@ -0,0 +1,54 @@ +import { HttpStatusCode, ResultList, Video } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class HistoryCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + search?: string + } = {}) { + const { search } = options + const path = '/api/v1/users/me/history/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: { + search + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + removeElement (options: OverrideCommandOptions & { + videoId: number + }) { + const { videoId } = options + const path = '/api/v1/users/me/history/videos/' + videoId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeAll (options: OverrideCommandOptions & { + beforeDate?: string + } = {}) { + const { beforeDate } = options + const path = '/api/v1/users/me/history/videos/remove' + + return this.postBodyRequest({ + ...options, + + path, + fields: { beforeDate }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/imports-command.ts b/packages/server-commands/src/videos/imports-command.ts new file mode 100644 index 000000000..1a1931d64 --- /dev/null +++ b/packages/server-commands/src/videos/imports-command.ts @@ -0,0 +1,76 @@ + +import { HttpStatusCode, ResultList, VideoImport, VideoImportCreate } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ImportsCommand extends AbstractCommand { + + importVideo (options: OverrideCommandOptions & { + attributes: (VideoImportCreate | { torrentfile?: string, previewfile?: string, thumbnailfile?: string }) + }) { + const { attributes } = options + const path = '/api/v1/videos/imports' + + let attaches: any = {} + if (attributes.torrentfile) attaches = { torrentfile: attributes.torrentfile } + if (attributes.thumbnailfile) attaches = { thumbnailfile: attributes.thumbnailfile } + if (attributes.previewfile) attaches = { previewfile: attributes.previewfile } + + return unwrapBody(this.postUploadRequest({ + ...options, + + path, + attaches, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + delete (options: OverrideCommandOptions & { + importId: number + }) { + const path = '/api/v1/videos/imports/' + options.importId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + cancel (options: OverrideCommandOptions & { + importId: number + }) { + const path = '/api/v1/videos/imports/' + options.importId + '/cancel' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getMyVideoImports (options: OverrideCommandOptions & { + sort?: string + targetUrl?: string + videoChannelSyncId?: number + search?: string + } = {}) { + const { sort, targetUrl, videoChannelSyncId, search } = options + const path = '/api/v1/users/me/videos/imports' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort, targetUrl, videoChannelSyncId, search }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/videos/index.ts b/packages/server-commands/src/videos/index.ts new file mode 100644 index 000000000..970026d51 --- /dev/null +++ b/packages/server-commands/src/videos/index.ts @@ -0,0 +1,22 @@ +export * from './blacklist-command.js' +export * from './captions-command.js' +export * from './change-ownership-command.js' +export * from './channels.js' +export * from './channels-command.js' +export * from './channel-syncs-command.js' +export * from './comments-command.js' +export * from './history-command.js' +export * from './imports-command.js' +export * from './live-command.js' +export * from './live.js' +export * from './playlists-command.js' +export * from './services-command.js' +export * from './storyboard-command.js' +export * from './streaming-playlists-command.js' +export * from './comments-command.js' +export * from './video-studio-command.js' +export * from './video-token-command.js' +export * from './views-command.js' +export * from './videos-command.js' +export * from './video-passwords-command.js' +export * from './video-stats-command.js' diff --git a/packages/server-commands/src/videos/live-command.ts b/packages/server-commands/src/videos/live-command.ts new file mode 100644 index 000000000..793b64f40 --- /dev/null +++ b/packages/server-commands/src/videos/live-command.ts @@ -0,0 +1,339 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { readdir } from 'fs/promises' +import { join } from 'path' +import { omit, wait } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + LiveVideo, + LiveVideoCreate, + LiveVideoSession, + LiveVideoUpdate, + ResultList, + VideoCreateResult, + VideoDetails, + VideoPrivacy, + VideoPrivacyType, + VideoState, + VideoStateType +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { ObjectStorageCommand, PeerTubeServer } from '../server/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' +import { sendRTMPStream, testFfmpegStreamError } from './live.js' + +export class LiveCommand extends AbstractCommand { + + get (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = '/api/v1/videos/live' + + return this.getRequestBody({ + ...options, + + path: path + '/' + options.videoId, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + listSessions (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = `/api/v1/videos/live/${options.videoId}/sessions` + + return this.getRequestBody>({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async findLatestSession (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { data: sessions } = await this.listSessions(options) + + return sessions[sessions.length - 1] + } + + getReplaySession (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = `/api/v1/videos/${options.videoId}/live-session` + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + update (options: OverrideCommandOptions & { + videoId: number | string + fields: LiveVideoUpdate + }) { + const { videoId, fields } = options + const path = '/api/v1/videos/live' + + return this.putBodyRequest({ + ...options, + + path: path + '/' + videoId, + fields, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async create (options: OverrideCommandOptions & { + fields: LiveVideoCreate + }) { + const { fields } = options + const path = '/api/v1/videos/live' + + const attaches: any = {} + if (fields.thumbnailfile) attaches.thumbnailfile = fields.thumbnailfile + if (fields.previewfile) attaches.previewfile = fields.previewfile + + const body = await unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({ + ...options, + + path, + attaches, + fields: omit(fields, [ 'thumbnailfile', 'previewfile' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return body.video + } + + async quickCreate (options: OverrideCommandOptions & { + saveReplay: boolean + permanentLive: boolean + privacy?: VideoPrivacyType + videoPasswords?: string[] + }) { + const { saveReplay, permanentLive, privacy = VideoPrivacy.PUBLIC, videoPasswords } = options + + const replaySettings = privacy === VideoPrivacy.PASSWORD_PROTECTED + ? { privacy: VideoPrivacy.PRIVATE } + : { privacy } + + const { uuid } = await this.create({ + ...options, + + fields: { + name: 'live', + permanentLive, + saveReplay, + replaySettings, + channelId: this.server.store.channel.id, + privacy, + videoPasswords + } + }) + + const video = await this.server.videos.getWithToken({ id: uuid }) + const live = await this.get({ videoId: uuid }) + + return { video, live } + } + + // --------------------------------------------------------------------------- + + async sendRTMPStreamInVideo (options: OverrideCommandOptions & { + videoId: number | string + fixtureName?: string + copyCodecs?: boolean + }) { + const { videoId, fixtureName, copyCodecs } = options + const videoLive = await this.get({ videoId }) + + return sendRTMPStream({ rtmpBaseUrl: videoLive.rtmpUrl, streamKey: videoLive.streamKey, fixtureName, copyCodecs }) + } + + async runAndTestStreamError (options: OverrideCommandOptions & { + videoId: number | string + shouldHaveError: boolean + }) { + const command = await this.sendRTMPStreamInVideo(options) + + return testFfmpegStreamError(command, options.shouldHaveError) + } + + // --------------------------------------------------------------------------- + + waitUntilPublished (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + return this.waitUntilState({ videoId, state: VideoState.PUBLISHED }) + } + + waitUntilWaiting (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + return this.waitUntilState({ videoId, state: VideoState.WAITING_FOR_LIVE }) + } + + waitUntilEnded (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + return this.waitUntilState({ videoId, state: VideoState.LIVE_ENDED }) + } + + async waitUntilSegmentGeneration (options: OverrideCommandOptions & { + server: PeerTubeServer + videoUUID: string + playlistNumber: number + segment: number + objectStorage?: ObjectStorageCommand + objectStorageBaseUrl?: string + }) { + const { + server, + objectStorage, + playlistNumber, + segment, + videoUUID, + objectStorageBaseUrl + } = options + + const segmentName = `${playlistNumber}-00000${segment}.ts` + const baseUrl = objectStorage + ? join(objectStorageBaseUrl || objectStorage.getMockPlaylistBaseUrl(), 'hls') + : server.url + '/static/streaming-playlists/hls' + + let error = true + + while (error) { + try { + // Check fragment exists + await this.getRawRequest({ + ...options, + + url: `${baseUrl}/${videoUUID}/${segmentName}`, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + + const video = await server.videos.get({ id: videoUUID }) + const hlsPlaylist = video.streamingPlaylists[0] + + // Check SHA generation + const shaBody = await server.streamingPlaylists.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url, withRetry: !!objectStorage }) + if (!shaBody[segmentName]) { + throw new Error('Segment SHA does not exist') + } + + // Check fragment is in m3u8 playlist + const subPlaylist = await server.streamingPlaylists.get({ url: `${baseUrl}/${video.uuid}/${playlistNumber}.m3u8` }) + if (!subPlaylist.includes(segmentName)) throw new Error('Fragment does not exist in playlist') + + error = false + } catch { + error = true + await wait(100) + } + } + } + + async waitUntilReplacedByReplay (options: OverrideCommandOptions & { + videoId: number | string + }) { + let video: VideoDetails + + do { + video = await this.server.videos.getWithToken({ token: options.token, id: options.videoId }) + + await wait(500) + } while (video.isLive === true || video.state.id !== VideoState.PUBLISHED) + } + + // --------------------------------------------------------------------------- + + getSegmentFile (options: OverrideCommandOptions & { + videoUUID: string + playlistNumber: number + segment: number + objectStorage?: ObjectStorageCommand + }) { + const { playlistNumber, segment, videoUUID, objectStorage } = options + + const segmentName = `${playlistNumber}-00000${segment}.ts` + const baseUrl = objectStorage + ? objectStorage.getMockPlaylistBaseUrl() + : `${this.server.url}/static/streaming-playlists/hls` + + const url = `${baseUrl}/${videoUUID}/${segmentName}` + + return this.getRawRequest({ + ...options, + + url, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getPlaylistFile (options: OverrideCommandOptions & { + videoUUID: string + playlistName: string + objectStorage?: ObjectStorageCommand + }) { + const { playlistName, videoUUID, objectStorage } = options + + const baseUrl = objectStorage + ? objectStorage.getMockPlaylistBaseUrl() + : `${this.server.url}/static/streaming-playlists/hls` + + const url = `${baseUrl}/${videoUUID}/${playlistName}` + + return this.getRawRequest({ + ...options, + + url, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + async countPlaylists (options: OverrideCommandOptions & { + videoUUID: string + }) { + const basePath = this.server.servers.buildDirectory('streaming-playlists') + const hlsPath = join(basePath, 'hls', options.videoUUID) + + const files = await readdir(hlsPath) + + return files.filter(f => f.endsWith('.m3u8')).length + } + + private async waitUntilState (options: OverrideCommandOptions & { + videoId: number | string + state: VideoStateType + }) { + let video: VideoDetails + + do { + video = await this.server.videos.getWithToken({ token: options.token, id: options.videoId }) + + await wait(500) + } while (video.state.id !== options.state) + } +} diff --git a/packages/server-commands/src/videos/live.ts b/packages/server-commands/src/videos/live.ts new file mode 100644 index 000000000..05bfa1113 --- /dev/null +++ b/packages/server-commands/src/videos/live.ts @@ -0,0 +1,129 @@ +import { wait } from '@peertube/peertube-core-utils' +import { VideoDetails, VideoInclude, VideoPrivacy } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' +import truncate from 'lodash-es/truncate.js' +import { PeerTubeServer } from '../server/server.js' + +function sendRTMPStream (options: { + rtmpBaseUrl: string + streamKey: string + fixtureName?: string // default video_short.mp4 + copyCodecs?: boolean // default false +}) { + const { rtmpBaseUrl, streamKey, fixtureName = 'video_short.mp4', copyCodecs = false } = options + + const fixture = buildAbsoluteFixturePath(fixtureName) + + const command = ffmpeg(fixture) + command.inputOption('-stream_loop -1') + command.inputOption('-re') + + if (copyCodecs) { + command.outputOption('-c copy') + } else { + command.outputOption('-c:v libx264') + command.outputOption('-g 120') + command.outputOption('-x264-params "no-scenecut=1"') + command.outputOption('-r 60') + } + + command.outputOption('-f flv') + + const rtmpUrl = rtmpBaseUrl + '/' + streamKey + command.output(rtmpUrl) + + command.on('error', err => { + if (err?.message?.includes('Exiting normally')) return + + if (process.env.DEBUG) console.error(err) + }) + + if (process.env.DEBUG) { + command.on('stderr', data => console.log(data)) + command.on('stdout', data => console.log(data)) + } + + command.run() + + return command +} + +function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) { + return new Promise((res, rej) => { + command.on('error', err => { + return rej(err) + }) + + setTimeout(() => { + res() + }, successAfterMS) + }) +} + +async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: boolean) { + let error: Error + + try { + await waitFfmpegUntilError(command, 45000) + } catch (err) { + error = err + } + + await stopFfmpeg(command) + + if (shouldHaveError && !error) throw new Error('Ffmpeg did not have an error') + if (!shouldHaveError && error) throw error +} + +async function stopFfmpeg (command: FfmpegCommand) { + command.kill('SIGINT') + + await wait(500) +} + +async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) { + for (const server of servers) { + await server.live.waitUntilPublished({ videoId }) + } +} + +async function waitUntilLiveWaitingOnAllServers (servers: PeerTubeServer[], videoId: string) { + for (const server of servers) { + await server.live.waitUntilWaiting({ videoId }) + } +} + +async function waitUntilLiveReplacedByReplayOnAllServers (servers: PeerTubeServer[], videoId: string) { + for (const server of servers) { + await server.live.waitUntilReplacedByReplay({ videoId }) + } +} + +async function findExternalSavedVideo (server: PeerTubeServer, liveDetails: VideoDetails) { + const include = VideoInclude.BLACKLISTED + const privacyOneOf = [ VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.PUBLIC, VideoPrivacy.UNLISTED ] + + const { data } = await server.videos.list({ token: server.accessToken, sort: '-publishedAt', include, privacyOneOf }) + + const videoNameSuffix = ` - ${new Date(liveDetails.publishedAt).toLocaleString()}` + const truncatedVideoName = truncate(liveDetails.name, { + length: 120 - videoNameSuffix.length + }) + const toFind = truncatedVideoName + videoNameSuffix + + return data.find(v => v.name === toFind) +} + +export { + sendRTMPStream, + waitFfmpegUntilError, + testFfmpegStreamError, + stopFfmpeg, + + waitUntilLivePublishedOnAllServers, + waitUntilLiveReplacedByReplayOnAllServers, + waitUntilLiveWaitingOnAllServers, + + findExternalSavedVideo +} diff --git a/packages/server-commands/src/videos/playlists-command.ts b/packages/server-commands/src/videos/playlists-command.ts new file mode 100644 index 000000000..2e483f318 --- /dev/null +++ b/packages/server-commands/src/videos/playlists-command.ts @@ -0,0 +1,281 @@ +import { omit, pick } from '@peertube/peertube-core-utils' +import { + BooleanBothQuery, + HttpStatusCode, + ResultList, + VideoExistInPlaylist, + VideoPlaylist, + VideoPlaylistCreate, + VideoPlaylistCreateResult, + VideoPlaylistElement, + VideoPlaylistElementCreate, + VideoPlaylistElementCreateResult, + VideoPlaylistElementUpdate, + VideoPlaylistReorder, + VideoPlaylistType_Type, + VideoPlaylistUpdate +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class PlaylistsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + playlistType?: VideoPlaylistType_Type + }) { + const path = '/api/v1/video-playlists' + const query = pick(options, [ 'start', 'count', 'sort', 'playlistType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listByChannel (options: OverrideCommandOptions & { + handle: string + start?: number + count?: number + sort?: string + playlistType?: VideoPlaylistType_Type + }) { + const path = '/api/v1/video-channels/' + options.handle + '/video-playlists' + const query = pick(options, [ 'start', 'count', 'sort', 'playlistType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listByAccount (options: OverrideCommandOptions & { + handle: string + start?: number + count?: number + sort?: string + search?: string + playlistType?: VideoPlaylistType_Type + }) { + const path = '/api/v1/accounts/' + options.handle + '/video-playlists' + const query = pick(options, [ 'start', 'count', 'sort', 'search', 'playlistType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + playlistId: number | string + }) { + const { playlistId } = options + const path = '/api/v1/video-playlists/' + playlistId + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listVideos (options: OverrideCommandOptions & { + playlistId: number | string + start?: number + count?: number + query?: { nsfw?: BooleanBothQuery } + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos' + const query = options.query ?? {} + + return this.getRequestBody>({ + ...options, + + path, + query: { + ...query, + start: options.start, + count: options.count + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + delete (options: OverrideCommandOptions & { + playlistId: number | string + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async create (options: OverrideCommandOptions & { + attributes: VideoPlaylistCreate + }) { + const path = '/api/v1/video-playlists' + + const fields = omit(options.attributes, [ 'thumbnailfile' ]) + + const attaches = options.attributes.thumbnailfile + ? { thumbnailfile: options.attributes.thumbnailfile } + : {} + + const body = await unwrapBody<{ videoPlaylist: VideoPlaylistCreateResult }>(this.postUploadRequest({ + ...options, + + path, + fields, + attaches, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return body.videoPlaylist + } + + update (options: OverrideCommandOptions & { + attributes: VideoPlaylistUpdate + playlistId: number | string + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + + const fields = omit(options.attributes, [ 'thumbnailfile' ]) + + const attaches = options.attributes.thumbnailfile + ? { thumbnailfile: options.attributes.thumbnailfile } + : {} + + return this.putUploadRequest({ + ...options, + + path, + fields, + attaches, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async addElement (options: OverrideCommandOptions & { + playlistId: number | string + attributes: VideoPlaylistElementCreate | { videoId: string } + }) { + const attributes = { + ...options.attributes, + + videoId: await this.server.videos.getId({ ...options, uuid: options.attributes.videoId }) + } + + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos' + + const body = await unwrapBody<{ videoPlaylistElement: VideoPlaylistElementCreateResult }>(this.postBodyRequest({ + ...options, + + path, + fields: attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return body.videoPlaylistElement + } + + updateElement (options: OverrideCommandOptions & { + playlistId: number | string + elementId: number | string + attributes: VideoPlaylistElementUpdate + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId + + return this.putBodyRequest({ + ...options, + + path, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeElement (options: OverrideCommandOptions & { + playlistId: number | string + elementId: number + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + reorderElements (options: OverrideCommandOptions & { + playlistId: number | string + attributes: VideoPlaylistReorder + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/reorder' + + return this.postBodyRequest({ + ...options, + + path, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getPrivacies (options: OverrideCommandOptions = {}) { + const path = '/api/v1/video-playlists/privacies' + + return this.getRequestBody<{ [ id: number ]: string }>({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + videosExist (options: OverrideCommandOptions & { + videoIds: number[] + }) { + const { videoIds } = options + const path = '/api/v1/users/me/video-playlists/videos-exist' + + return this.getRequestBody({ + ...options, + + path, + query: { videoIds }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/videos/services-command.ts b/packages/server-commands/src/videos/services-command.ts new file mode 100644 index 000000000..ade10cd3a --- /dev/null +++ b/packages/server-commands/src/videos/services-command.ts @@ -0,0 +1,29 @@ +import { HttpStatusCode } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ServicesCommand extends AbstractCommand { + + getOEmbed (options: OverrideCommandOptions & { + oembedUrl: string + format?: string + maxHeight?: number + maxWidth?: number + }) { + const path = '/services/oembed' + const query = { + url: options.oembedUrl, + format: options.format, + maxheight: options.maxHeight, + maxwidth: options.maxWidth + } + + return this.getRequest({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/videos/storyboard-command.ts b/packages/server-commands/src/videos/storyboard-command.ts new file mode 100644 index 000000000..a692ad612 --- /dev/null +++ b/packages/server-commands/src/videos/storyboard-command.ts @@ -0,0 +1,19 @@ +import { HttpStatusCode, Storyboard } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class StoryboardCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + id: number | string + }) { + const path = '/api/v1/videos/' + options.id + '/storyboards' + + return this.getRequestBody<{ storyboards: Storyboard[] }>({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/videos/streaming-playlists-command.ts b/packages/server-commands/src/videos/streaming-playlists-command.ts new file mode 100644 index 000000000..2406dd023 --- /dev/null +++ b/packages/server-commands/src/videos/streaming-playlists-command.ts @@ -0,0 +1,119 @@ +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode } from '@peertube/peertube-models' +import { unwrapBody, unwrapBodyOrDecodeToJSON, unwrapTextOrDecode } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class StreamingPlaylistsCommand extends AbstractCommand { + + async get (options: OverrideCommandOptions & { + url: string + + videoFileToken?: string + reinjectVideoFileToken?: boolean + + withRetry?: boolean // default false + currentRetry?: number + }): Promise { + const { videoFileToken, reinjectVideoFileToken, expectedStatus, withRetry = false, currentRetry = 1 } = options + + try { + const result = await unwrapTextOrDecode(this.getRawRequest({ + ...options, + + url: options.url, + query: { + videoFileToken, + reinjectVideoFileToken + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + // master.m3u8 could be empty + if (!result && (!expectedStatus || expectedStatus === HttpStatusCode.OK_200)) { + throw new Error('Empty result') + } + + return result + } catch (err) { + if (!withRetry || currentRetry > 10) throw err + + await wait(250) + + return this.get({ + ...options, + + withRetry, + currentRetry: currentRetry + 1 + }) + } + } + + async getFragmentedSegment (options: OverrideCommandOptions & { + url: string + range?: string + + withRetry?: boolean // default false + currentRetry?: number + }) { + const { withRetry = false, currentRetry = 1 } = options + + try { + const result = await unwrapBody(this.getRawRequest({ + ...options, + + url: options.url, + range: options.range, + implicitToken: false, + responseType: 'application/octet-stream', + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return result + } catch (err) { + if (!withRetry || currentRetry > 10) throw err + + await wait(250) + + return this.getFragmentedSegment({ + ...options, + + withRetry, + currentRetry: currentRetry + 1 + }) + } + } + + async getSegmentSha256 (options: OverrideCommandOptions & { + url: string + + withRetry?: boolean // default false + currentRetry?: number + }) { + const { withRetry = false, currentRetry = 1 } = options + + try { + const result = await unwrapBodyOrDecodeToJSON<{ [ id: string ]: string }>(this.getRawRequest({ + ...options, + + url: options.url, + contentType: 'application/json', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return result + } catch (err) { + if (!withRetry || currentRetry > 10) throw err + + await wait(250) + + return this.getSegmentSha256({ + ...options, + + withRetry, + currentRetry: currentRetry + 1 + }) + } + } +} diff --git a/packages/server-commands/src/videos/video-passwords-command.ts b/packages/server-commands/src/videos/video-passwords-command.ts new file mode 100644 index 000000000..7a56311ca --- /dev/null +++ b/packages/server-commands/src/videos/video-passwords-command.ts @@ -0,0 +1,56 @@ +import { HttpStatusCode, ResultList, VideoPassword } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class VideoPasswordsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + videoId: number | string + start?: number + count?: number + sort?: string + }) { + const { start, count, sort, videoId } = options + const path = '/api/v1/videos/' + videoId + '/passwords' + + return this.getRequestBody>({ + ...options, + + path, + query: { start, count, sort }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateAll (options: OverrideCommandOptions & { + videoId: number | string + passwords: string[] + }) { + const { videoId, passwords } = options + const path = `/api/v1/videos/${videoId}/passwords` + + return this.putBodyRequest({ + ...options, + path, + fields: { passwords }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + remove (options: OverrideCommandOptions & { + id: number + videoId: number | string + }) { + const { id, videoId } = options + const path = `/api/v1/videos/${videoId}/passwords/${id}` + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/video-stats-command.ts b/packages/server-commands/src/videos/video-stats-command.ts new file mode 100644 index 000000000..1b7a9b592 --- /dev/null +++ b/packages/server-commands/src/videos/video-stats-command.ts @@ -0,0 +1,62 @@ +import { pick } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + VideoStatsOverall, + VideoStatsRetention, + VideoStatsTimeserie, + VideoStatsTimeserieMetric +} from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class VideoStatsCommand extends AbstractCommand { + + getOverallStats (options: OverrideCommandOptions & { + videoId: number | string + startDate?: string + endDate?: string + }) { + const path = '/api/v1/videos/' + options.videoId + '/stats/overall' + + return this.getRequestBody({ + ...options, + path, + + query: pick(options, [ 'startDate', 'endDate' ]), + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getTimeserieStats (options: OverrideCommandOptions & { + videoId: number | string + metric: VideoStatsTimeserieMetric + startDate?: Date + endDate?: Date + }) { + const path = '/api/v1/videos/' + options.videoId + '/stats/timeseries/' + options.metric + + return this.getRequestBody({ + ...options, + path, + + query: pick(options, [ 'startDate', 'endDate' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getRetentionStats (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = '/api/v1/videos/' + options.videoId + '/stats/retention' + + return this.getRequestBody({ + ...options, + path, + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/videos/video-studio-command.ts b/packages/server-commands/src/videos/video-studio-command.ts new file mode 100644 index 000000000..8c5ff169a --- /dev/null +++ b/packages/server-commands/src/videos/video-studio-command.ts @@ -0,0 +1,67 @@ +import { HttpStatusCode, VideoStudioTask } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class VideoStudioCommand extends AbstractCommand { + + static getComplexTask (): VideoStudioTask[] { + return [ + // Total duration: 2 + { + name: 'cut', + options: { + start: 1, + end: 3 + } + }, + + // Total duration: 7 + { + name: 'add-outro', + options: { + file: 'video_short.webm' + } + }, + + { + name: 'add-watermark', + options: { + file: 'custom-thumbnail.png' + } + }, + + // Total duration: 9 + { + name: 'add-intro', + options: { + file: 'video_very_short_240p.mp4' + } + } + ] + } + + createEditionTasks (options: OverrideCommandOptions & { + videoId: number | string + tasks: VideoStudioTask[] + }) { + const path = '/api/v1/videos/' + options.videoId + '/studio/edit' + const attaches: { [id: string]: any } = {} + + for (let i = 0; i < options.tasks.length; i++) { + const task = options.tasks[i] + + if (task.name === 'add-intro' || task.name === 'add-outro' || task.name === 'add-watermark') { + attaches[`tasks[${i}][options][file]`] = task.options.file + } + } + + return this.postUploadRequest({ + ...options, + + path, + attaches, + fields: { tasks: options.tasks }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/video-token-command.ts b/packages/server-commands/src/videos/video-token-command.ts new file mode 100644 index 000000000..5812e484a --- /dev/null +++ b/packages/server-commands/src/videos/video-token-command.ts @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ + +import { HttpStatusCode, VideoToken } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class VideoTokenCommand extends AbstractCommand { + + create (options: OverrideCommandOptions & { + videoId: number | string + videoPassword?: string + }) { + const { videoId, videoPassword } = options + const path = '/api/v1/videos/' + videoId + '/token' + + return unwrapBody(this.postBodyRequest({ + ...options, + headers: this.buildVideoPasswordHeader(videoPassword), + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + async getVideoFileToken (options: OverrideCommandOptions & { + videoId: number | string + videoPassword?: string + }) { + const { files } = await this.create(options) + + return files.token + } +} diff --git a/packages/server-commands/src/videos/videos-command.ts b/packages/server-commands/src/videos/videos-command.ts new file mode 100644 index 000000000..72dc58a4b --- /dev/null +++ b/packages/server-commands/src/videos/videos-command.ts @@ -0,0 +1,831 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ + +import { expect } from 'chai' +import { createReadStream } from 'fs' +import { stat } from 'fs/promises' +import got, { Response as GotResponse } from 'got' +import validator from 'validator' +import { getAllPrivacies, omit, pick, wait } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + HttpStatusCodeType, + ResultList, + UserVideoRateType, + Video, + VideoCreate, + VideoCreateResult, + VideoDetails, + VideoFileMetadata, + VideoInclude, + VideoPrivacy, + VideoPrivacyType, + VideosCommonQuery, + VideoSource, + VideoTranscodingCreate +} from '@peertube/peertube-models' +import { buildAbsoluteFixturePath, buildUUID } from '@peertube/peertube-node-utils' +import { unwrapBody } from '../requests/index.js' +import { waitJobs } from '../server/jobs.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export type VideoEdit = Partial> & { + fixture?: string + thumbnailfile?: string + previewfile?: string +} + +export class VideosCommand extends AbstractCommand { + + getCategories (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/categories' + + return this.getRequestBody<{ [id: number]: string }>({ + ...options, + path, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getLicences (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/licences' + + return this.getRequestBody<{ [id: number]: string }>({ + ...options, + path, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getLanguages (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/languages' + + return this.getRequestBody<{ [id: string]: string }>({ + ...options, + path, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getPrivacies (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/privacies' + + return this.getRequestBody<{ [id in VideoPrivacyType]: string }>({ + ...options, + path, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + getDescription (options: OverrideCommandOptions & { + descriptionPath: string + }) { + return this.getRequestBody<{ description: string }>({ + ...options, + path: options.descriptionPath, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getFileMetadata (options: OverrideCommandOptions & { + url: string + }) { + return unwrapBody(this.getRawRequest({ + ...options, + + url: options.url, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + // --------------------------------------------------------------------------- + + rate (options: OverrideCommandOptions & { + id: number | string + rating: UserVideoRateType + videoPassword?: string + }) { + const { id, rating, videoPassword } = options + const path = '/api/v1/videos/' + id + '/rate' + + return this.putBodyRequest({ + ...options, + + path, + fields: { rating }, + headers: this.buildVideoPasswordHeader(videoPassword), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + get (options: OverrideCommandOptions & { + id: number | string + }) { + const path = '/api/v1/videos/' + options.id + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getWithToken (options: OverrideCommandOptions & { + id: number | string + }) { + return this.get({ + ...options, + + token: this.buildCommonRequestToken({ ...options, implicitToken: true }) + }) + } + + getWithPassword (options: OverrideCommandOptions & { + id: number | string + password?: string + }) { + const path = '/api/v1/videos/' + options.id + + return this.getRequestBody({ + ...options, + headers:{ + 'x-peertube-video-password': options.password + }, + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getSource (options: OverrideCommandOptions & { + id: number | string + }) { + const path = '/api/v1/videos/' + options.id + '/source' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async getId (options: OverrideCommandOptions & { + uuid: number | string + }) { + const { uuid } = options + + if (validator.default.isUUID('' + uuid) === false) return uuid as number + + const { id } = await this.get({ ...options, id: uuid }) + + return id + } + + async listFiles (options: OverrideCommandOptions & { + id: number | string + }) { + const video = await this.get(options) + + const files = video.files || [] + const hlsFiles = video.streamingPlaylists[0]?.files || [] + + return files.concat(hlsFiles) + } + + // --------------------------------------------------------------------------- + + listMyVideos (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + isLive?: boolean + channelId?: number + } = {}) { + const path = '/api/v1/users/me/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listMySubscriptionVideos (options: OverrideCommandOptions & VideosCommonQuery = {}) { + const { sort = '-createdAt' } = options + const path = '/api/v1/users/me/subscriptions/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort, ...this.buildListQuery(options) }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + list (options: OverrideCommandOptions & VideosCommonQuery = {}) { + const path = '/api/v1/videos' + + const query = this.buildListQuery(options) + + return this.getRequestBody>({ + ...options, + + path, + query: { sort: 'name', ...query }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) { + return this.list({ + ...options, + + token: this.buildCommonRequestToken({ ...options, implicitToken: true }) + }) + } + + listAllForAdmin (options: OverrideCommandOptions & VideosCommonQuery = {}) { + const include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED | VideoInclude.BLOCKED_OWNER + const nsfw = 'both' + const privacyOneOf = getAllPrivacies() + + return this.list({ + ...options, + + include, + nsfw, + privacyOneOf, + + token: this.buildCommonRequestToken({ ...options, implicitToken: true }) + }) + } + + listByAccount (options: OverrideCommandOptions & VideosCommonQuery & { + handle: string + }) { + const { handle, search } = options + const path = '/api/v1/accounts/' + handle + '/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: { search, ...this.buildListQuery(options) }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listByChannel (options: OverrideCommandOptions & VideosCommonQuery & { + handle: string + }) { + const { handle } = options + const path = '/api/v1/video-channels/' + handle + '/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: this.buildListQuery(options), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + async find (options: OverrideCommandOptions & { + name: string + }) { + const { data } = await this.list(options) + + return data.find(v => v.name === options.name) + } + + // --------------------------------------------------------------------------- + + update (options: OverrideCommandOptions & { + id: number | string + attributes?: VideoEdit + }) { + const { id, attributes = {} } = options + const path = '/api/v1/videos/' + id + + // Upload request + if (attributes.thumbnailfile || attributes.previewfile) { + const attaches: any = {} + if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile + if (attributes.previewfile) attaches.previewfile = attributes.previewfile + + return this.putUploadRequest({ + ...options, + + path, + fields: options.attributes, + attaches: { + thumbnailfile: attributes.thumbnailfile, + previewfile: attributes.previewfile + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + return this.putBodyRequest({ + ...options, + + path, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + remove (options: OverrideCommandOptions & { + id: number | string + }) { + const path = '/api/v1/videos/' + options.id + + return unwrapBody(this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + })) + } + + async removeAll () { + const { data } = await this.list() + + for (const v of data) { + await this.remove({ id: v.id }) + } + } + + // --------------------------------------------------------------------------- + + async upload (options: OverrideCommandOptions & { + attributes?: VideoEdit + mode?: 'legacy' | 'resumable' // default legacy + waitTorrentGeneration?: boolean // default true + completedExpectedStatus?: HttpStatusCodeType + } = {}) { + const { mode = 'legacy', waitTorrentGeneration = true } = options + let defaultChannelId = 1 + + try { + const { videoChannels } = await this.server.users.getMyInfo({ token: options.token }) + defaultChannelId = videoChannels[0].id + } catch (e) { /* empty */ } + + // Override default attributes + const attributes = { + name: 'my super video', + category: 5, + licence: 4, + language: 'zh', + channelId: defaultChannelId, + nsfw: true, + waitTranscoding: false, + description: 'my super description', + support: 'my super support text', + tags: [ 'tag' ], + privacy: VideoPrivacy.PUBLIC, + commentsEnabled: true, + downloadEnabled: true, + fixture: 'video_short.webm', + + ...options.attributes + } + + const created = mode === 'legacy' + ? await this.buildLegacyUpload({ ...options, attributes }) + : await this.buildResumeUpload({ ...options, path: '/api/v1/videos/upload-resumable', attributes }) + + // Wait torrent generation + const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 }) + if (expectedStatus === HttpStatusCode.OK_200 && waitTorrentGeneration) { + let video: VideoDetails + + do { + video = await this.getWithToken({ ...options, id: created.uuid }) + + await wait(50) + } while (!video.files[0].torrentUrl) + } + + return created + } + + async buildLegacyUpload (options: OverrideCommandOptions & { + attributes: VideoEdit + }): Promise { + const path = '/api/v1/videos/upload' + + return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({ + ...options, + + path, + fields: this.buildUploadFields(options.attributes), + attaches: this.buildUploadAttaches(options.attributes), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })).then(body => body.video || body as any) + } + + async buildResumeUpload (options: OverrideCommandOptions & { + path: string + attributes: { fixture?: string } & { [id: string]: any } + completedExpectedStatus?: HttpStatusCodeType // When the upload is finished + }): Promise { + const { path, attributes, expectedStatus = HttpStatusCode.OK_200, completedExpectedStatus } = options + + let size = 0 + let videoFilePath: string + let mimetype = 'video/mp4' + + if (attributes.fixture) { + videoFilePath = buildAbsoluteFixturePath(attributes.fixture) + size = (await stat(videoFilePath)).size + + if (videoFilePath.endsWith('.mkv')) { + mimetype = 'video/x-matroska' + } else if (videoFilePath.endsWith('.webm')) { + mimetype = 'video/webm' + } + } + + // Do not check status automatically, we'll check it manually + const initializeSessionRes = await this.prepareResumableUpload({ + ...options, + + path, + expectedStatus: null, + attributes, + size, + mimetype + }) + const initStatus = initializeSessionRes.status + + if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) { + const locationHeader = initializeSessionRes.header['location'] + expect(locationHeader).to.not.be.undefined + + const pathUploadId = locationHeader.split('?')[1] + + const result = await this.sendResumableChunks({ + ...options, + + path, + pathUploadId, + videoFilePath, + size, + expectedStatus: completedExpectedStatus + }) + + if (result.statusCode === HttpStatusCode.OK_200) { + await this.endResumableUpload({ + ...options, + + expectedStatus: HttpStatusCode.NO_CONTENT_204, + path, + pathUploadId + }) + } + + return result.body?.video || result.body as any + } + + const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200 + ? HttpStatusCode.CREATED_201 + : expectedStatus + + expect(initStatus).to.equal(expectedInitStatus) + + return initializeSessionRes.body.video || initializeSessionRes.body + } + + async prepareResumableUpload (options: OverrideCommandOptions & { + path: string + attributes: { fixture?: string } & { [id: string]: any } + size: number + mimetype: string + + originalName?: string + lastModified?: number + }) { + const { path, attributes, originalName, lastModified, size, mimetype } = options + + const attaches = this.buildUploadAttaches(omit(options.attributes, [ 'fixture' ])) + + const uploadOptions = { + ...options, + + path, + headers: { + 'X-Upload-Content-Type': mimetype, + 'X-Upload-Content-Length': size.toString() + }, + fields: { + filename: attributes.fixture, + originalName, + lastModified, + + ...this.buildUploadFields(options.attributes) + }, + + // Fixture will be sent later + attaches: this.buildUploadAttaches(omit(options.attributes, [ 'fixture' ])), + implicitToken: true, + + defaultExpectedStatus: null + } + + if (Object.keys(attaches).length === 0) return this.postBodyRequest(uploadOptions) + + return this.postUploadRequest(uploadOptions) + } + + sendResumableChunks (options: OverrideCommandOptions & { + pathUploadId: string + path: string + videoFilePath: string + size: number + contentLength?: number + contentRangeBuilder?: (start: number, chunk: any) => string + digestBuilder?: (chunk: any) => string + }) { + const { + path, + pathUploadId, + videoFilePath, + size, + contentLength, + contentRangeBuilder, + digestBuilder, + expectedStatus = HttpStatusCode.OK_200 + } = options + + let start = 0 + + const token = this.buildCommonRequestToken({ ...options, implicitToken: true }) + + const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 }) + const server = this.server + return new Promise>((resolve, reject) => { + readable.on('data', async function onData (chunk) { + try { + readable.pause() + + const byterangeStart = start + chunk.length - 1 + + const headers = { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/octet-stream', + 'Content-Range': contentRangeBuilder + ? contentRangeBuilder(start, chunk) + : `bytes ${start}-${byterangeStart}/${size}`, + 'Content-Length': contentLength ? contentLength + '' : chunk.length + '' + } + + if (digestBuilder) { + Object.assign(headers, { digest: digestBuilder(chunk) }) + } + + const res = await got<{ video: VideoCreateResult }>({ + url: new URL(path + '?' + pathUploadId, server.url).toString(), + method: 'put', + headers, + body: chunk, + responseType: 'json', + throwHttpErrors: false + }) + + start += chunk.length + + // Last request, check final status + if (byterangeStart + 1 === size) { + if (res.statusCode === expectedStatus) { + return resolve(res) + } + + if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) { + readable.off('data', onData) + + // eslint-disable-next-line max-len + const message = `Incorrect transient behaviour sending intermediary chunks. Status code is ${res.statusCode} instead of ${expectedStatus}` + return reject(new Error(message)) + } + } + + readable.resume() + } catch (err) { + reject(err) + } + }) + }) + } + + endResumableUpload (options: OverrideCommandOptions & { + path: string + pathUploadId: string + }) { + return this.deleteRequest({ + ...options, + + path: options.path, + rawQuery: options.pathUploadId, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + quickUpload (options: OverrideCommandOptions & { + name: string + nsfw?: boolean + privacy?: VideoPrivacyType + fixture?: string + videoPasswords?: string[] + }) { + const attributes: VideoEdit = { name: options.name } + if (options.nsfw) attributes.nsfw = options.nsfw + if (options.privacy) attributes.privacy = options.privacy + if (options.fixture) attributes.fixture = options.fixture + if (options.videoPasswords) attributes.videoPasswords = options.videoPasswords + + return this.upload({ ...options, attributes }) + } + + async randomUpload (options: OverrideCommandOptions & { + wait?: boolean // default true + additionalParams?: VideoEdit & { prefixName?: string } + } = {}) { + const { wait = true, additionalParams } = options + const prefixName = additionalParams?.prefixName || '' + const name = prefixName + buildUUID() + + const attributes = { name, ...additionalParams } + + const result = await this.upload({ ...options, attributes }) + + if (wait) await waitJobs([ this.server ]) + + return { ...result, name } + } + + // --------------------------------------------------------------------------- + + replaceSourceFile (options: OverrideCommandOptions & { + videoId: number | string + fixture: string + completedExpectedStatus?: HttpStatusCodeType + }) { + return this.buildResumeUpload({ + ...options, + + path: '/api/v1/videos/' + options.videoId + '/source/replace-resumable', + attributes: { fixture: options.fixture } + }) + } + + // --------------------------------------------------------------------------- + + removeHLSPlaylist (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = '/api/v1/videos/' + options.videoId + '/hls' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeHLSFile (options: OverrideCommandOptions & { + videoId: number | string + fileId: number + }) { + const path = '/api/v1/videos/' + options.videoId + '/hls/' + options.fileId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeAllWebVideoFiles (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = '/api/v1/videos/' + options.videoId + '/web-videos' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeWebVideoFile (options: OverrideCommandOptions & { + videoId: number | string + fileId: number + }) { + const path = '/api/v1/videos/' + options.videoId + '/web-videos/' + options.fileId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + runTranscoding (options: OverrideCommandOptions & VideoTranscodingCreate & { + videoId: number | string + }) { + const path = '/api/v1/videos/' + options.videoId + '/transcoding' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'transcodingType', 'forceTranscoding' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + private buildListQuery (options: VideosCommonQuery) { + return pick(options, [ + 'start', + 'count', + 'sort', + 'nsfw', + 'isLive', + 'categoryOneOf', + 'licenceOneOf', + 'languageOneOf', + 'privacyOneOf', + 'tagsOneOf', + 'tagsAllOf', + 'isLocal', + 'include', + 'skipCount' + ]) + } + + private buildUploadFields (attributes: VideoEdit) { + return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ]) + } + + private buildUploadAttaches (attributes: VideoEdit) { + const attaches: { [ name: string ]: string } = {} + + for (const key of [ 'thumbnailfile', 'previewfile' ]) { + if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key]) + } + + if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture) + + return attaches + } +} diff --git a/packages/server-commands/src/videos/views-command.ts b/packages/server-commands/src/videos/views-command.ts new file mode 100644 index 000000000..048bd3fda --- /dev/null +++ b/packages/server-commands/src/videos/views-command.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ +import { HttpStatusCode, VideoViewEvent } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ViewsCommand extends AbstractCommand { + + view (options: OverrideCommandOptions & { + id: number | string + currentTime: number + viewEvent?: VideoViewEvent + xForwardedFor?: string + }) { + const { id, xForwardedFor, viewEvent, currentTime } = options + const path = '/api/v1/videos/' + id + '/views' + + return this.postBodyRequest({ + ...options, + + path, + xForwardedFor, + fields: { + currentTime, + viewEvent + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async simulateView (options: OverrideCommandOptions & { + id: number | string + xForwardedFor?: string + }) { + await this.view({ ...options, currentTime: 0 }) + await this.view({ ...options, currentTime: 5 }) + } + + async simulateViewer (options: OverrideCommandOptions & { + id: number | string + currentTimes: number[] + xForwardedFor?: string + }) { + let viewEvent: VideoViewEvent = 'seek' + + for (const currentTime of options.currentTimes) { + await this.view({ ...options, currentTime, viewEvent }) + + viewEvent = undefined + } + } +} -- cgit v1.2.3