1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-04 10:19:35 +02:00

Merge branch 'feature/esm-and-nx' into develop

This commit is contained in:
Chocobozzz 2023-08-17 08:59:21 +02:00
commit c380e39285
No known key found for this signature in database
GPG key ID: 583A612D890159BE
2196 changed files with 12690 additions and 11574 deletions

View file

@ -0,0 +1,19 @@
{
"name": "@peertube/peertube-server-commands",
"private": true,
"version": "0.0.0",
"main": "dist/index.js",
"files": [ "dist" ],
"exports": {
"types": "./dist/index.d.ts",
"peertube:tsx": "./src/index.ts",
"default": "./dist/index.js"
},
"type": "module",
"devDependencies": {},
"scripts": {
"build": "tsc",
"watch": "tsc -w"
},
"dependencies": {}
}

View file

@ -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
})
}
}

View file

@ -0,0 +1 @@
export * from './bulk-command.js'

View file

@ -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<string>((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}`)
}
}

View file

@ -0,0 +1 @@
export * from './cli-command.js'

View file

@ -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<CustomPage>({
...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
})
}
}

View file

@ -0,0 +1 @@
export * from './custom-pages-command.js'

View file

@ -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
})
}
}

View file

@ -0,0 +1 @@
export * from './feeds-command.js'

View file

@ -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'

View file

@ -0,0 +1 @@
export * from './logs-command.js'

View file

@ -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<any[]>({
...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
})
}
}

View file

@ -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<ResultList<AdminAbuse>>({
...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<ResultList<UserAbuse>>({
...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<ResultList<AbuseMessage>>({
...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
})
}
}

View file

@ -0,0 +1 @@
export * from './abuses-command.js'

View file

@ -0,0 +1 @@
export * from './overviews-command.js'

View file

@ -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<VideosOverview>({
...options,
path,
query,
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
}

View file

@ -0,0 +1 @@
export * from './requests.js'

View file

@ -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 <T> (test: request.Test): Promise<T> {
return test.then(res => res.body)
}
function unwrapText (test: request.Test): Promise<string> {
return test.then(res => res.text)
}
function unwrapBodyOrDecodeToJSON <T> (test: request.Test): Promise<T> {
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<string> {
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])
}
}
}

View file

@ -0,0 +1,3 @@
export * from './runner-jobs-command.js'
export * from './runner-registration-tokens-command.js'
export * from './runners-command.js'

View file

@ -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<ResultList<RunnerJobAdmin>>({
...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<RequestRunnerJobResult>(this.postBodyRequest({
...options,
path,
fields: pick(options, [ 'runnerToken' ]),
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.OK_200
}))
}
async requestVOD (options: OverrideCommandOptions & RequestRunnerJobBody) {
const vodTypes = new Set<RunnerJobType>([ '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<RunnerJobVODPayload>
}
async requestLive (options: OverrideCommandOptions & RequestRunnerJobBody) {
const vodTypes = new Set<RunnerJobType>([ 'live-rtmp-hls-transcoding' ])
const { availableJobs } = await this.request(options)
return {
availableJobs: availableJobs.filter(j => vodTypes.has(j.type))
} as RequestRunnerJobResult<RunnerJobLiveRTMPHLSTranscodingPayload>
}
// ---------------------------------------------------------------------------
accept <T extends RunnerJobPayload = RunnerJobPayload> (options: OverrideCommandOptions & AcceptRunnerJobBody & { jobUUID: string }) {
const path = '/api/v1/runners/jobs/' + options.jobUUID + '/accept'
return unwrapBody<AcceptRunnerJobResult<T>>(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<RunnerJobStateType>([
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<RunnerJobLiveRTMPHLSTranscodingPayload>['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]
}
}

View file

@ -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<ResultList<RunnerRegistrationToken>>({
...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
}
}

View file

@ -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<ResultList<Runner>>({
...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<RegisterRunnerResult>(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
}
}

View file

@ -0,0 +1 @@
export * from './search-command.js'

View file

@ -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<ResultList<VideoChannel>>({
...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<ResultList<VideoPlaylist>>({
...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<ResultList<Video>>({
...options,
path,
query: search,
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
}

View file

@ -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<ServerConfig>({
...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('<script type="application/javascript">window.PeerTubeServerConfig = (".+?")</script>')
// 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<About>({
...options,
path,
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
getCustomConfig (options: OverrideCommandOptions = {}) {
const path = '/api/v1/config/custom'
return this.getRequestBody<CustomConfig>({
...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<CustomConfig>
}) {
const existing = await this.getCustomConfig({ ...options, expectedStatus: HttpStatusCode.OK_200 })
return this.updateCustomConfig({ ...options, newCustomConfig: merge({}, existing, options.newConfig) })
}
updateCustomSubConfig (options: OverrideCommandOptions & {
newConfig: DeepPartial<CustomConfig>
}) {
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 })
}
}

View file

@ -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
})
}
}

View file

@ -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<Debug>({
...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
})
}
}

View file

@ -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<ResultList<ActorFollow>>({
...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<ResultList<ActorFollow>>({
...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
})
}
}

View file

@ -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
}

View file

@ -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'

View file

@ -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<ResultList<Job>>({
...options,
path,
query,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
listFailed (options: OverrideCommandOptions & {
jobType?: JobType
}) {
const path = this.buildJobsUrl('failed')
return this.getRequestBody<ResultList<Job>>({
...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
}
}

View file

@ -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<any>[] = []
// 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
}

View file

@ -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
})
}
}

View file

@ -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()}/`
}
}

View file

@ -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<ResultList<PeerTubePlugin>>({
...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<ResultList<PeerTubePluginIndex>>({
...options,
path,
query,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
get (options: OverrideCommandOptions & {
npmName: string
}) {
const path = '/api/v1/plugins/' + options.npmName
return this.getRequestBody<PeerTubePlugin>({
...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<RegisteredServerSettings>({
...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<PublicServerSetting>({
...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<PluginTranslation>({
...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<PluginPackageJSON> {
const path = this.getPackageJSONPath(npmName)
return readJSON(path)
}
private getPackageJSONPath (npmName: string) {
return this.server.servers.buildDirectory(join('plugins', 'node_modules', npmName, 'package.json'))
}
}

View file

@ -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<ResultList<VideoRedundancy>>({
...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
})
}
}

View file

@ -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<VideoChannelSync>
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<void>((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)
}
}

View file

@ -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<void>((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<any>[] = []
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)
}
}

View file

@ -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<PeerTubeServer>[] = []
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<any>[] = []
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://api.github.com/repos/ytdl-org/youtube-dl/releases'
: 'https://api.github.com/repos/yt-dlp/yt-dlp/releases',
name: mode
}
}
}
}
}
}
// ---------------------------------------------------------------------------
export {
createSingleServer,
createMultipleServers,
cleanupTests,
killallServers,
getServerImportConfig
}

View file

@ -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<ServerStats>({
...options,
path,
query,
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
}

View file

@ -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 <T> (options: InternalGetCommandOptions) {
return unwrapBody<T>(this.getRequest(options))
}
protected getRequestText (options: InternalGetCommandOptions) {
return unwrapText(this.getRequest(options))
}
protected getRawRequest (options: Omit<InternalGetCommandOptions, 'path'>) {
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<InternalCommonCommandOptions, 'token' | 'implicitToken'>) {
const { token } = options
const fallbackToken = options.implicitToken
? this.server.accessToken
: undefined
return token !== undefined ? token : fallbackToken
}
protected buildExpectedStatus (options: Pick<InternalCommonCommandOptions, 'expectedStatus' | 'defaultExpectedStatus'>) {
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
}

View file

@ -0,0 +1 @@
export * from './abstract-command.js'

View file

@ -0,0 +1 @@
export * from './socket-io-command.js'

View file

@ -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 }
})
}
}

View file

@ -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<ResultList<Account>>({
...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<Account>({
...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<ResultList<AccountVideoRate>>({
...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<ResultList<ActorFollow>>({
...options,
path,
query,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
}

View file

@ -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
}

View file

@ -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<AccountBlock>(options, path)
}
listMyServerBlocklist (options: ListBlocklistOptions) {
const path = '/api/v1/users/me/blocklist/servers'
return this.listBlocklist<ServerBlock>(options, path)
}
listServerAccountBlocklist (options: ListBlocklistOptions) {
const path = '/api/v1/server/blocklist/accounts'
return this.listBlocklist<AccountBlock>(options, path)
}
listServerServerBlocklist (options: ListBlocklistOptions) {
const path = '/api/v1/server/blocklist/servers'
return this.listBlocklist<ServerBlock>(options, path)
}
// ---------------------------------------------------------------------------
getStatus (options: OverrideCommandOptions & {
accounts?: string[]
hosts?: string[]
}) {
const { accounts, hosts } = options
const path = '/api/v1/blocklist/status'
return this.getRequestBody<BlockStatus>({
...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 <T> (options: ListBlocklistOptions, path: string) {
const { start, count, search, sort = '-createdAt' } = options
return this.getRequestBody<ResultList<T>>({
...options,
path,
query: { start, count, sort, search },
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
}

View file

@ -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'

View file

@ -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<string>
getAccessToken (arg1: string, password?: string): Promise<string>
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
}
}

View file

@ -0,0 +1,19 @@
import { PeerTubeServer } from '../server/server.js'
function setAccessTokensToServers (servers: PeerTubeServer[]) {
const tasks: Promise<any>[] = []
for (const server of servers) {
const p = server.login.getAccessToken()
.then(t => { server.accessToken = t })
tasks.push(p)
}
return Promise.all(tasks)
}
// ---------------------------------------------------------------------------
export {
setAccessTokensToServers
}

View file

@ -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<ResultList<UserNotification>>({
...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]
}
}

View file

@ -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<UserRegistrationRequest> & Pick<UserRegistrationRequest, 'username'>) {
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<UserRegistrationRequest> & Pick<UserRegistrationRequest, 'username' | 'registrationReason'>
) {
const { password = 'password', email = options.username + '@example.com' } = options
const path = '/api/v1/users/registrations/request'
return unwrapBody<UserRegistration>(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<ResultList<UserRegistration>>({
...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
})
}
}

View file

@ -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<ResultList<VideoChannel>>({
...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<VideoChannel>({
...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
})
}
}

View file

@ -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<TwoFactorEnableResult>(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
}
}

View file

@ -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<ScopedToken>({
...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<MyUser>({
...options,
path,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
getMyQuotaUsed (options: OverrideCommandOptions = {}) {
const path = '/api/v1/users/me/video-quota-used'
return this.getRequestBody<UserVideoQuota>({
...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<UserVideoRate>({
...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<User>({
...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<ResultList<User>>({
...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
})
}
}

View file

@ -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<ResultList<VideoBlacklist>>({
...options,
path,
query,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
}

View file

@ -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<ResultList<VideoCaption>>({
...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
})
}
}

View file

@ -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<ResultList<VideoChangeOwnership>>({
...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
})
}
}

View file

@ -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<ResultList<VideoChannelSync>>({
...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
})
}
}

View file

@ -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<ResultList<VideoChannel>>({
...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<ResultList<VideoChannel>>({
...options,
path,
query: { sort, ...pick(options, [ 'start', 'count', 'withStats', 'search' ]) },
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
async create (options: OverrideCommandOptions & {
attributes: Partial<VideoChannelCreate>
}) {
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<VideoChannel>({
...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<ResultList<ActorFollow>>({
...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
})
}
}

View file

@ -0,0 +1,29 @@
import { PeerTubeServer } from '../server/server.js'
function setDefaultVideoChannel (servers: PeerTubeServer[]) {
const tasks: Promise<any>[] = []
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
}

View file

@ -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<ResultList<VideoComment>>({
...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<VideoCommentThreads>({
...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<VideoCommentThreadTree>({
...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
})
}
}

View file

@ -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<ResultList<Video>>({
...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
})
}
}

View file

@ -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<VideoImport>(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<ResultList<VideoImport>>({
...options,
path,
query: { sort, targetUrl, videoChannelSyncId, search },
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
}

View file

@ -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'

View file

@ -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<LiveVideo>({
...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<ResultList<LiveVideoSession>>({
...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<LiveVideoSession>({
...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)
}
}

View file

@ -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<void>((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
}

View file

@ -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<ResultList<VideoPlaylist>>({
...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<ResultList<VideoPlaylist>>({
...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<ResultList<VideoPlaylist>>({
...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<VideoPlaylist>({
...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<ResultList<VideoPlaylistElement>>({
...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<VideoExistInPlaylist>({
...options,
path,
query: { videoIds },
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
}

View file

@ -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
})
}
}

View file

@ -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
})
}
}

View file

@ -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<string> {
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<Buffer>(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
})
}
}
}

View file

@ -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<ResultList<VideoPassword>>({
...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
})
}
}

View file

@ -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<VideoStatsOverall>({
...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<VideoStatsTimeserie>({
...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<VideoStatsRetention>({
...options,
path,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
}

View file

@ -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
})
}
}

View file

@ -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<VideoToken>(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
}
}

View file

@ -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<Omit<VideoCreate, 'thumbnailfile' | 'previewfile'>> & {
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<VideoFileMetadata>(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<VideoDetails>({
...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<VideoDetails>({
...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<VideoSource>({
...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<ResultList<Video>>({
...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<ResultList<Video>>({
...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<ResultList<Video>>({
...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<ResultList<Video>>({
...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<ResultList<Video>>({
...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<VideoCreateResult> {
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<VideoCreateResult> {
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<GotResponse<{ video: VideoCreateResult }>>((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
}
}

View file

@ -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
}
}
}

View file

@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist",
"rootDir": "src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"references": [
{ "path": "../models" },
{ "path": "../core-utils" },
{ "path": "../typescript-utils" }
]
}