aboutsummaryrefslogtreecommitdiffhomepage
path: root/shared/server-commands
diff options
context:
space:
mode:
Diffstat (limited to 'shared/server-commands')
-rw-r--r--shared/server-commands/bulk/bulk-command.ts20
-rw-r--r--shared/server-commands/bulk/index.ts1
-rw-r--r--shared/server-commands/cli/cli-command.ts27
-rw-r--r--shared/server-commands/cli/index.ts1
-rw-r--r--shared/server-commands/custom-pages/custom-pages-command.ts33
-rw-r--r--shared/server-commands/custom-pages/index.ts1
-rw-r--r--shared/server-commands/feeds/feeds-command.ts44
-rw-r--r--shared/server-commands/feeds/index.ts1
-rw-r--r--shared/server-commands/index.ts14
-rw-r--r--shared/server-commands/logs/index.ts1
-rw-r--r--shared/server-commands/logs/logs-command.ts43
-rw-r--r--shared/server-commands/miscs/index.ts2
-rw-r--r--shared/server-commands/miscs/sql-command.ts141
-rw-r--r--shared/server-commands/miscs/webtorrent.ts46
-rw-r--r--shared/server-commands/moderation/abuses-command.ts228
-rw-r--r--shared/server-commands/moderation/index.ts1
-rw-r--r--shared/server-commands/overviews/index.ts1
-rw-r--r--shared/server-commands/overviews/overviews-command.ts23
-rw-r--r--shared/server-commands/requests/index.ts1
-rw-r--r--shared/server-commands/requests/requests.ts208
-rw-r--r--shared/server-commands/search/index.ts1
-rw-r--r--shared/server-commands/search/search-command.ts98
-rw-r--r--shared/server-commands/server/config-command.ts352
-rw-r--r--shared/server-commands/server/contact-form-command.ts31
-rw-r--r--shared/server-commands/server/debug-command.ts33
-rw-r--r--shared/server-commands/server/follows-command.ts139
-rw-r--r--shared/server-commands/server/follows.ts20
-rw-r--r--shared/server-commands/server/index.ts14
-rw-r--r--shared/server-commands/server/jobs-command.ts60
-rw-r--r--shared/server-commands/server/jobs.ts84
-rw-r--r--shared/server-commands/server/object-storage-command.ts77
-rw-r--r--shared/server-commands/server/plugins-command.ts257
-rw-r--r--shared/server-commands/server/redundancy-command.ts80
-rw-r--r--shared/server-commands/server/server.ts398
-rw-r--r--shared/server-commands/server/servers-command.ts92
-rw-r--r--shared/server-commands/server/servers.ts49
-rw-r--r--shared/server-commands/server/stats-command.ts25
-rw-r--r--shared/server-commands/shared/abstract-command.ts211
-rw-r--r--shared/server-commands/shared/index.ts1
-rw-r--r--shared/server-commands/socket/index.ts1
-rw-r--r--shared/server-commands/socket/socket-io-command.ts15
-rw-r--r--shared/server-commands/users/accounts-command.ts76
-rw-r--r--shared/server-commands/users/blocklist-command.ts162
-rw-r--r--shared/server-commands/users/index.ts7
-rw-r--r--shared/server-commands/users/login-command.ts132
-rw-r--r--shared/server-commands/users/login.ts19
-rw-r--r--shared/server-commands/users/notifications-command.ts85
-rw-r--r--shared/server-commands/users/subscriptions-command.ts99
-rw-r--r--shared/server-commands/users/users-command.ts416
-rw-r--r--shared/server-commands/videos/blacklist-command.ts75
-rw-r--r--shared/server-commands/videos/captions-command.ts65
-rw-r--r--shared/server-commands/videos/change-ownership-command.ts68
-rw-r--r--shared/server-commands/videos/channels-command.ts184
-rw-r--r--shared/server-commands/videos/channels.ts18
-rw-r--r--shared/server-commands/videos/comments-command.ts152
-rw-r--r--shared/server-commands/videos/history-command.ts58
-rw-r--r--shared/server-commands/videos/imports-command.ts47
-rw-r--r--shared/server-commands/videos/index.ts15
-rw-r--r--shared/server-commands/videos/live-command.ts155
-rw-r--r--shared/server-commands/videos/live.ts100
-rw-r--r--shared/server-commands/videos/playlists-command.ts280
-rw-r--r--shared/server-commands/videos/services-command.ts29
-rw-r--r--shared/server-commands/videos/streaming-playlists-command.ts44
-rw-r--r--shared/server-commands/videos/videos-command.ts678
64 files changed, 5839 insertions, 0 deletions
diff --git a/shared/server-commands/bulk/bulk-command.ts b/shared/server-commands/bulk/bulk-command.ts
new file mode 100644
index 000000000..b5c5673ce
--- /dev/null
+++ b/shared/server-commands/bulk/bulk-command.ts
@@ -0,0 +1,20 @@
1import { BulkRemoveCommentsOfBody, HttpStatusCode } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class BulkCommand extends AbstractCommand {
5
6 removeCommentsOf (options: OverrideCommandOptions & {
7 attributes: BulkRemoveCommentsOfBody
8 }) {
9 const { attributes } = options
10
11 return this.postBodyRequest({
12 ...options,
13
14 path: '/api/v1/bulk/remove-comments-of',
15 fields: attributes,
16 implicitToken: true,
17 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
18 })
19 }
20}
diff --git a/shared/server-commands/bulk/index.ts b/shared/server-commands/bulk/index.ts
new file mode 100644
index 000000000..391597243
--- /dev/null
+++ b/shared/server-commands/bulk/index.ts
@@ -0,0 +1 @@
export * from './bulk-command'
diff --git a/shared/server-commands/cli/cli-command.ts b/shared/server-commands/cli/cli-command.ts
new file mode 100644
index 000000000..ab9738174
--- /dev/null
+++ b/shared/server-commands/cli/cli-command.ts
@@ -0,0 +1,27 @@
1import { exec } from 'child_process'
2import { AbstractCommand } from '../shared'
3
4export class CLICommand extends AbstractCommand {
5
6 static exec (command: string) {
7 return new Promise<string>((res, rej) => {
8 exec(command, (err, stdout, _stderr) => {
9 if (err) return rej(err)
10
11 return res(stdout)
12 })
13 })
14 }
15
16 getEnv () {
17 return `NODE_ENV=test NODE_APP_INSTANCE=${this.server.internalServerNumber}`
18 }
19
20 async execWithEnv (command: string, configOverride?: any) {
21 const prefix = configOverride
22 ? `NODE_CONFIG='${JSON.stringify(configOverride)}'`
23 : ''
24
25 return CLICommand.exec(`${prefix} ${this.getEnv()} ${command}`)
26 }
27}
diff --git a/shared/server-commands/cli/index.ts b/shared/server-commands/cli/index.ts
new file mode 100644
index 000000000..91b5abfbe
--- /dev/null
+++ b/shared/server-commands/cli/index.ts
@@ -0,0 +1 @@
export * from './cli-command'
diff --git a/shared/server-commands/custom-pages/custom-pages-command.ts b/shared/server-commands/custom-pages/custom-pages-command.ts
new file mode 100644
index 000000000..cd869a8de
--- /dev/null
+++ b/shared/server-commands/custom-pages/custom-pages-command.ts
@@ -0,0 +1,33 @@
1import { CustomPage, HttpStatusCode } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class CustomPagesCommand extends AbstractCommand {
5
6 getInstanceHomepage (options: OverrideCommandOptions = {}) {
7 const path = '/api/v1/custom-pages/homepage/instance'
8
9 return this.getRequestBody<CustomPage>({
10 ...options,
11
12 path,
13 implicitToken: false,
14 defaultExpectedStatus: HttpStatusCode.OK_200
15 })
16 }
17
18 updateInstanceHomepage (options: OverrideCommandOptions & {
19 content: string
20 }) {
21 const { content } = options
22 const path = '/api/v1/custom-pages/homepage/instance'
23
24 return this.putBodyRequest({
25 ...options,
26
27 path,
28 fields: { content },
29 implicitToken: true,
30 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
31 })
32 }
33}
diff --git a/shared/server-commands/custom-pages/index.ts b/shared/server-commands/custom-pages/index.ts
new file mode 100644
index 000000000..58aed04f2
--- /dev/null
+++ b/shared/server-commands/custom-pages/index.ts
@@ -0,0 +1 @@
export * from './custom-pages-command'
diff --git a/shared/server-commands/feeds/feeds-command.ts b/shared/server-commands/feeds/feeds-command.ts
new file mode 100644
index 000000000..3c95f9536
--- /dev/null
+++ b/shared/server-commands/feeds/feeds-command.ts
@@ -0,0 +1,44 @@
1
2import { HttpStatusCode } from '@shared/models'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5type FeedType = 'videos' | 'video-comments' | 'subscriptions'
6
7export class FeedCommand extends AbstractCommand {
8
9 getXML (options: OverrideCommandOptions & {
10 feed: FeedType
11 format?: string
12 }) {
13 const { feed, format } = options
14 const path = '/feeds/' + feed + '.xml'
15
16 return this.getRequestText({
17 ...options,
18
19 path,
20 query: format ? { format } : undefined,
21 accept: 'application/xml',
22 implicitToken: false,
23 defaultExpectedStatus: HttpStatusCode.OK_200
24 })
25 }
26
27 getJSON (options: OverrideCommandOptions & {
28 feed: FeedType
29 query?: { [ id: string ]: any }
30 }) {
31 const { feed, query } = options
32 const path = '/feeds/' + feed + '.json'
33
34 return this.getRequestText({
35 ...options,
36
37 path,
38 query,
39 accept: 'application/json',
40 implicitToken: false,
41 defaultExpectedStatus: HttpStatusCode.OK_200
42 })
43 }
44}
diff --git a/shared/server-commands/feeds/index.ts b/shared/server-commands/feeds/index.ts
new file mode 100644
index 000000000..662a22b6f
--- /dev/null
+++ b/shared/server-commands/feeds/index.ts
@@ -0,0 +1 @@
export * from './feeds-command'
diff --git a/shared/server-commands/index.ts b/shared/server-commands/index.ts
new file mode 100644
index 000000000..c24ebb2df
--- /dev/null
+++ b/shared/server-commands/index.ts
@@ -0,0 +1,14 @@
1export * from './bulk'
2export * from './cli'
3export * from './custom-pages'
4export * from './feeds'
5export * from './logs'
6export * from './miscs'
7export * from './moderation'
8export * from './overviews'
9export * from './requests'
10export * from './search'
11export * from './server'
12export * from './socket'
13export * from './users'
14export * from './videos'
diff --git a/shared/server-commands/logs/index.ts b/shared/server-commands/logs/index.ts
new file mode 100644
index 000000000..69452d7f0
--- /dev/null
+++ b/shared/server-commands/logs/index.ts
@@ -0,0 +1 @@
export * from './logs-command'
diff --git a/shared/server-commands/logs/logs-command.ts b/shared/server-commands/logs/logs-command.ts
new file mode 100644
index 000000000..8f63383ea
--- /dev/null
+++ b/shared/server-commands/logs/logs-command.ts
@@ -0,0 +1,43 @@
1import { HttpStatusCode, LogLevel } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class LogsCommand extends AbstractCommand {
5
6 getLogs (options: OverrideCommandOptions & {
7 startDate: Date
8 endDate?: Date
9 level?: LogLevel
10 tagsOneOf?: string[]
11 }) {
12 const { startDate, endDate, tagsOneOf, level } = options
13 const path = '/api/v1/server/logs'
14
15 return this.getRequestBody<any[]>({
16 ...options,
17
18 path,
19 query: { startDate, endDate, level, tagsOneOf },
20 implicitToken: true,
21 defaultExpectedStatus: HttpStatusCode.OK_200
22 })
23 }
24
25 getAuditLogs (options: OverrideCommandOptions & {
26 startDate: Date
27 endDate?: Date
28 }) {
29 const { startDate, endDate } = options
30
31 const path = '/api/v1/server/audit-logs'
32
33 return this.getRequestBody({
34 ...options,
35
36 path,
37 query: { startDate, endDate },
38 implicitToken: true,
39 defaultExpectedStatus: HttpStatusCode.OK_200
40 })
41 }
42
43}
diff --git a/shared/server-commands/miscs/index.ts b/shared/server-commands/miscs/index.ts
new file mode 100644
index 000000000..a1d14e998
--- /dev/null
+++ b/shared/server-commands/miscs/index.ts
@@ -0,0 +1,2 @@
1export * from './sql-command'
2export * from './webtorrent'
diff --git a/shared/server-commands/miscs/sql-command.ts b/shared/server-commands/miscs/sql-command.ts
new file mode 100644
index 000000000..09a99f834
--- /dev/null
+++ b/shared/server-commands/miscs/sql-command.ts
@@ -0,0 +1,141 @@
1import { QueryTypes, Sequelize } from 'sequelize'
2import { AbstractCommand } from '../shared'
3
4export class SQLCommand extends AbstractCommand {
5 private sequelize: Sequelize
6
7 deleteAll (table: string) {
8 const seq = this.getSequelize()
9
10 const options = { type: QueryTypes.DELETE }
11
12 return seq.query(`DELETE FROM "${table}"`, options)
13 }
14
15 async getCount (table: string) {
16 const seq = this.getSequelize()
17
18 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
19
20 const [ { total } ] = await seq.query<{ total: string }>(`SELECT COUNT(*) as total FROM "${table}"`, options)
21 if (total === null) return 0
22
23 return parseInt(total, 10)
24 }
25
26 setActorField (to: string, field: string, value: string) {
27 const seq = this.getSequelize()
28
29 const options = { type: QueryTypes.UPDATE }
30
31 return seq.query(`UPDATE actor SET "${field}" = '${value}' WHERE url = '${to}'`, options)
32 }
33
34 setVideoField (uuid: string, field: string, value: string) {
35 const seq = this.getSequelize()
36
37 const options = { type: QueryTypes.UPDATE }
38
39 return seq.query(`UPDATE video SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
40 }
41
42 setPlaylistField (uuid: string, field: string, value: string) {
43 const seq = this.getSequelize()
44
45 const options = { type: QueryTypes.UPDATE }
46
47 return seq.query(`UPDATE "videoPlaylist" SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
48 }
49
50 async countVideoViewsOf (uuid: string) {
51 const seq = this.getSequelize()
52
53 const query = 'SELECT SUM("videoView"."views") AS "total" FROM "videoView" ' +
54 `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = '${uuid}'`
55
56 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
57 const [ { total } ] = await seq.query<{ total: number }>(query, options)
58
59 if (!total) return 0
60
61 return parseInt(total + '', 10)
62 }
63
64 getActorImage (filename: string) {
65 return this.selectQuery(`SELECT * FROM "actorImage" WHERE filename = '${filename}'`)
66 .then(rows => rows[0])
67 }
68
69 selectQuery (query: string) {
70 const seq = this.getSequelize()
71 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
72
73 return seq.query<any>(query, options)
74 }
75
76 updateQuery (query: string) {
77 const seq = this.getSequelize()
78 const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE }
79
80 return seq.query(query, options)
81 }
82
83 setPluginField (pluginName: string, field: string, value: string) {
84 const seq = this.getSequelize()
85
86 const options = { type: QueryTypes.UPDATE }
87
88 return seq.query(`UPDATE "plugin" SET "${field}" = '${value}' WHERE "name" = '${pluginName}'`, options)
89 }
90
91 setPluginVersion (pluginName: string, newVersion: string) {
92 return this.setPluginField(pluginName, 'version', newVersion)
93 }
94
95 setPluginLatestVersion (pluginName: string, newVersion: string) {
96 return this.setPluginField(pluginName, 'latestVersion', newVersion)
97 }
98
99 setActorFollowScores (newScore: number) {
100 const seq = this.getSequelize()
101
102 const options = { type: QueryTypes.UPDATE }
103
104 return seq.query(`UPDATE "actorFollow" SET "score" = ${newScore}`, options)
105 }
106
107 setTokenField (accessToken: string, field: string, value: string) {
108 const seq = this.getSequelize()
109
110 const options = { type: QueryTypes.UPDATE }
111
112 return seq.query(`UPDATE "oAuthToken" SET "${field}" = '${value}' WHERE "accessToken" = '${accessToken}'`, options)
113 }
114
115 async cleanup () {
116 if (!this.sequelize) return
117
118 await this.sequelize.close()
119 this.sequelize = undefined
120 }
121
122 private getSequelize () {
123 if (this.sequelize) return this.sequelize
124
125 const dbname = 'peertube_test' + this.server.internalServerNumber
126 const username = 'peertube'
127 const password = 'peertube'
128 const host = 'localhost'
129 const port = 5432
130
131 this.sequelize = new Sequelize(dbname, username, password, {
132 dialect: 'postgres',
133 host,
134 port,
135 logging: false
136 })
137
138 return this.sequelize
139 }
140
141}
diff --git a/shared/server-commands/miscs/webtorrent.ts b/shared/server-commands/miscs/webtorrent.ts
new file mode 100644
index 000000000..0683f8893
--- /dev/null
+++ b/shared/server-commands/miscs/webtorrent.ts
@@ -0,0 +1,46 @@
1import { readFile } from 'fs-extra'
2import parseTorrent from 'parse-torrent'
3import { basename, join } from 'path'
4import * as WebTorrent from 'webtorrent'
5import { VideoFile } from '@shared/models'
6import { PeerTubeServer } from '../server'
7
8let webtorrent: WebTorrent.Instance
9
10function webtorrentAdd (torrentId: string, refreshWebTorrent = false) {
11 const WebTorrent = require('webtorrent')
12
13 if (webtorrent && refreshWebTorrent) webtorrent.destroy()
14 if (!webtorrent || refreshWebTorrent) webtorrent = new WebTorrent()
15
16 webtorrent.on('error', err => console.error('Error in webtorrent', err))
17
18 return new Promise<WebTorrent.Torrent>(res => {
19 const torrent = webtorrent.add(torrentId, res)
20
21 torrent.on('error', err => console.error('Error in webtorrent torrent', err))
22 torrent.on('warning', warn => {
23 const msg = typeof warn === 'string'
24 ? warn
25 : warn.message
26
27 if (msg.includes('Unsupported')) return
28
29 console.error('Warning in webtorrent torrent', warn)
30 })
31 })
32}
33
34async function parseTorrentVideo (server: PeerTubeServer, file: VideoFile) {
35 const torrentName = basename(file.torrentUrl)
36 const torrentPath = server.servers.buildDirectory(join('torrents', torrentName))
37
38 const data = await readFile(torrentPath)
39
40 return parseTorrent(data)
41}
42
43export {
44 webtorrentAdd,
45 parseTorrentVideo
46}
diff --git a/shared/server-commands/moderation/abuses-command.ts b/shared/server-commands/moderation/abuses-command.ts
new file mode 100644
index 000000000..0db32ba46
--- /dev/null
+++ b/shared/server-commands/moderation/abuses-command.ts
@@ -0,0 +1,228 @@
1import { pick } from '@shared/core-utils'
2import {
3 AbuseFilter,
4 AbuseMessage,
5 AbusePredefinedReasonsString,
6 AbuseState,
7 AbuseUpdate,
8 AbuseVideoIs,
9 AdminAbuse,
10 HttpStatusCode,
11 ResultList,
12 UserAbuse
13} from '@shared/models'
14import { unwrapBody } from '../requests/requests'
15import { AbstractCommand, OverrideCommandOptions } from '../shared'
16
17export class AbusesCommand extends AbstractCommand {
18
19 report (options: OverrideCommandOptions & {
20 reason: string
21
22 accountId?: number
23 videoId?: number
24 commentId?: number
25
26 predefinedReasons?: AbusePredefinedReasonsString[]
27
28 startAt?: number
29 endAt?: number
30 }) {
31 const path = '/api/v1/abuses'
32
33 const video = options.videoId
34 ? {
35 id: options.videoId,
36 startAt: options.startAt,
37 endAt: options.endAt
38 }
39 : undefined
40
41 const comment = options.commentId
42 ? { id: options.commentId }
43 : undefined
44
45 const account = options.accountId
46 ? { id: options.accountId }
47 : undefined
48
49 const body = {
50 account,
51 video,
52 comment,
53
54 reason: options.reason,
55 predefinedReasons: options.predefinedReasons
56 }
57
58 return unwrapBody<{ abuse: { id: number } }>(this.postBodyRequest({
59 ...options,
60
61 path,
62 fields: body,
63 implicitToken: true,
64 defaultExpectedStatus: HttpStatusCode.OK_200
65 }))
66 }
67
68 getAdminList (options: OverrideCommandOptions & {
69 start?: number
70 count?: number
71 sort?: string
72
73 id?: number
74 predefinedReason?: AbusePredefinedReasonsString
75 search?: string
76 filter?: AbuseFilter
77 state?: AbuseState
78 videoIs?: AbuseVideoIs
79 searchReporter?: string
80 searchReportee?: string
81 searchVideo?: string
82 searchVideoChannel?: string
83 } = {}) {
84 const toPick: (keyof typeof options)[] = [
85 'count',
86 'filter',
87 'id',
88 'predefinedReason',
89 'search',
90 'searchReportee',
91 'searchReporter',
92 'searchVideo',
93 'searchVideoChannel',
94 'sort',
95 'start',
96 'state',
97 'videoIs'
98 ]
99
100 const path = '/api/v1/abuses'
101
102 const defaultQuery = { sort: 'createdAt' }
103 const query = { ...defaultQuery, ...pick(options, toPick) }
104
105 return this.getRequestBody<ResultList<AdminAbuse>>({
106 ...options,
107
108 path,
109 query,
110 implicitToken: true,
111 defaultExpectedStatus: HttpStatusCode.OK_200
112 })
113 }
114
115 getUserList (options: OverrideCommandOptions & {
116 start?: number
117 count?: number
118 sort?: string
119
120 id?: number
121 search?: string
122 state?: AbuseState
123 }) {
124 const toPick: (keyof typeof options)[] = [
125 'id',
126 'search',
127 'state',
128 'start',
129 'count',
130 'sort'
131 ]
132
133 const path = '/api/v1/users/me/abuses'
134
135 const defaultQuery = { sort: 'createdAt' }
136 const query = { ...defaultQuery, ...pick(options, toPick) }
137
138 return this.getRequestBody<ResultList<UserAbuse>>({
139 ...options,
140
141 path,
142 query,
143 implicitToken: true,
144 defaultExpectedStatus: HttpStatusCode.OK_200
145 })
146 }
147
148 update (options: OverrideCommandOptions & {
149 abuseId: number
150 body: AbuseUpdate
151 }) {
152 const { abuseId, body } = options
153 const path = '/api/v1/abuses/' + abuseId
154
155 return this.putBodyRequest({
156 ...options,
157
158 path,
159 fields: body,
160 implicitToken: true,
161 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
162 })
163 }
164
165 delete (options: OverrideCommandOptions & {
166 abuseId: number
167 }) {
168 const { abuseId } = options
169 const path = '/api/v1/abuses/' + abuseId
170
171 return this.deleteRequest({
172 ...options,
173
174 path,
175 implicitToken: true,
176 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
177 })
178 }
179
180 listMessages (options: OverrideCommandOptions & {
181 abuseId: number
182 }) {
183 const { abuseId } = options
184 const path = '/api/v1/abuses/' + abuseId + '/messages'
185
186 return this.getRequestBody<ResultList<AbuseMessage>>({
187 ...options,
188
189 path,
190 implicitToken: true,
191 defaultExpectedStatus: HttpStatusCode.OK_200
192 })
193 }
194
195 deleteMessage (options: OverrideCommandOptions & {
196 abuseId: number
197 messageId: number
198 }) {
199 const { abuseId, messageId } = options
200 const path = '/api/v1/abuses/' + abuseId + '/messages/' + messageId
201
202 return this.deleteRequest({
203 ...options,
204
205 path,
206 implicitToken: true,
207 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
208 })
209 }
210
211 addMessage (options: OverrideCommandOptions & {
212 abuseId: number
213 message: string
214 }) {
215 const { abuseId, message } = options
216 const path = '/api/v1/abuses/' + abuseId + '/messages'
217
218 return this.postBodyRequest({
219 ...options,
220
221 path,
222 fields: { message },
223 implicitToken: true,
224 defaultExpectedStatus: HttpStatusCode.OK_200
225 })
226 }
227
228}
diff --git a/shared/server-commands/moderation/index.ts b/shared/server-commands/moderation/index.ts
new file mode 100644
index 000000000..b37643956
--- /dev/null
+++ b/shared/server-commands/moderation/index.ts
@@ -0,0 +1 @@
export * from './abuses-command'
diff --git a/shared/server-commands/overviews/index.ts b/shared/server-commands/overviews/index.ts
new file mode 100644
index 000000000..e19551907
--- /dev/null
+++ b/shared/server-commands/overviews/index.ts
@@ -0,0 +1 @@
export * from './overviews-command'
diff --git a/shared/server-commands/overviews/overviews-command.ts b/shared/server-commands/overviews/overviews-command.ts
new file mode 100644
index 000000000..06b4892d2
--- /dev/null
+++ b/shared/server-commands/overviews/overviews-command.ts
@@ -0,0 +1,23 @@
1import { HttpStatusCode, VideosOverview } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class OverviewsCommand extends AbstractCommand {
5
6 getVideos (options: OverrideCommandOptions & {
7 page: number
8 }) {
9 const { page } = options
10 const path = '/api/v1/overviews/videos'
11
12 const query = { page }
13
14 return this.getRequestBody<VideosOverview>({
15 ...options,
16
17 path,
18 query,
19 implicitToken: false,
20 defaultExpectedStatus: HttpStatusCode.OK_200
21 })
22 }
23}
diff --git a/shared/server-commands/requests/index.ts b/shared/server-commands/requests/index.ts
new file mode 100644
index 000000000..802982301
--- /dev/null
+++ b/shared/server-commands/requests/index.ts
@@ -0,0 +1 @@
export * from './requests'
diff --git a/shared/server-commands/requests/requests.ts b/shared/server-commands/requests/requests.ts
new file mode 100644
index 000000000..95e4fe6b1
--- /dev/null
+++ b/shared/server-commands/requests/requests.ts
@@ -0,0 +1,208 @@
1/* eslint-disable @typescript-eslint/no-floating-promises */
2
3import { decode } from 'querystring'
4import request from 'supertest'
5import { URL } from 'url'
6import { buildAbsoluteFixturePath } from '@shared/core-utils'
7import { HttpStatusCode } from '@shared/models'
8
9export type CommonRequestParams = {
10 url: string
11 path?: string
12 contentType?: string
13 range?: string
14 redirects?: number
15 accept?: string
16 host?: string
17 token?: string
18 headers?: { [ name: string ]: string }
19 type?: string
20 xForwardedFor?: string
21 expectedStatus?: HttpStatusCode
22}
23
24function makeRawRequest (url: string, expectedStatus?: HttpStatusCode, range?: string) {
25 const { host, protocol, pathname } = new URL(url)
26
27 return makeGetRequest({ url: `${protocol}//${host}`, path: pathname, expectedStatus, range })
28}
29
30function makeGetRequest (options: CommonRequestParams & {
31 query?: any
32 rawQuery?: string
33}) {
34 const req = request(options.url).get(options.path)
35
36 if (options.query) req.query(options.query)
37 if (options.rawQuery) req.query(options.rawQuery)
38
39 return buildRequest(req, { contentType: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options })
40}
41
42function makeHTMLRequest (url: string, path: string) {
43 return makeGetRequest({
44 url,
45 path,
46 accept: 'text/html',
47 expectedStatus: HttpStatusCode.OK_200
48 })
49}
50
51function makeActivityPubGetRequest (url: string, path: string, expectedStatus = HttpStatusCode.OK_200) {
52 return makeGetRequest({
53 url,
54 path,
55 expectedStatus: expectedStatus,
56 accept: 'application/activity+json,text/html;q=0.9,\\*/\\*;q=0.8'
57 })
58}
59
60function makeDeleteRequest (options: CommonRequestParams & {
61 query?: any
62 rawQuery?: string
63}) {
64 const req = request(options.url).delete(options.path)
65
66 if (options.query) req.query(options.query)
67 if (options.rawQuery) req.query(options.rawQuery)
68
69 return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options })
70}
71
72function makeUploadRequest (options: CommonRequestParams & {
73 method?: 'POST' | 'PUT'
74
75 fields: { [ fieldName: string ]: any }
76 attaches?: { [ attachName: string ]: any | any[] }
77}) {
78 let req = options.method === 'PUT'
79 ? request(options.url).put(options.path)
80 : request(options.url).post(options.path)
81
82 req = buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options })
83
84 buildFields(req, options.fields)
85
86 Object.keys(options.attaches || {}).forEach(attach => {
87 const value = options.attaches[attach]
88 if (!value) return
89
90 if (Array.isArray(value)) {
91 req.attach(attach, buildAbsoluteFixturePath(value[0]), value[1])
92 } else {
93 req.attach(attach, buildAbsoluteFixturePath(value))
94 }
95 })
96
97 return req
98}
99
100function makePostBodyRequest (options: CommonRequestParams & {
101 fields?: { [ fieldName: string ]: any }
102}) {
103 const req = request(options.url).post(options.path)
104 .send(options.fields)
105
106 return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options })
107}
108
109function makePutBodyRequest (options: {
110 url: string
111 path: string
112 token?: string
113 fields: { [ fieldName: string ]: any }
114 expectedStatus?: HttpStatusCode
115}) {
116 const req = request(options.url).put(options.path)
117 .send(options.fields)
118
119 return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options })
120}
121
122function decodeQueryString (path: string) {
123 return decode(path.split('?')[1])
124}
125
126function unwrapBody <T> (test: request.Test): Promise<T> {
127 return test.then(res => res.body)
128}
129
130function unwrapText (test: request.Test): Promise<string> {
131 return test.then(res => res.text)
132}
133
134function unwrapBodyOrDecodeToJSON <T> (test: request.Test): Promise<T> {
135 return test.then(res => {
136 if (res.body instanceof Buffer) {
137 return JSON.parse(new TextDecoder().decode(res.body))
138 }
139
140 return res.body
141 })
142}
143
144function unwrapTextOrDecode (test: request.Test): Promise<string> {
145 return test.then(res => res.text || new TextDecoder().decode(res.body))
146}
147
148// ---------------------------------------------------------------------------
149
150export {
151 makeHTMLRequest,
152 makeGetRequest,
153 decodeQueryString,
154 makeUploadRequest,
155 makePostBodyRequest,
156 makePutBodyRequest,
157 makeDeleteRequest,
158 makeRawRequest,
159 makeActivityPubGetRequest,
160 unwrapBody,
161 unwrapTextOrDecode,
162 unwrapBodyOrDecodeToJSON,
163 unwrapText
164}
165
166// ---------------------------------------------------------------------------
167
168function buildRequest (req: request.Test, options: CommonRequestParams) {
169 if (options.contentType) req.set('Accept', options.contentType)
170 if (options.token) req.set('Authorization', 'Bearer ' + options.token)
171 if (options.range) req.set('Range', options.range)
172 if (options.accept) req.set('Accept', options.accept)
173 if (options.host) req.set('Host', options.host)
174 if (options.redirects) req.redirects(options.redirects)
175 if (options.expectedStatus) req.expect(options.expectedStatus)
176 if (options.xForwardedFor) req.set('X-Forwarded-For', options.xForwardedFor)
177 if (options.type) req.type(options.type)
178
179 Object.keys(options.headers || {}).forEach(name => {
180 req.set(name, options.headers[name])
181 })
182
183 return req
184}
185
186function buildFields (req: request.Test, fields: { [ fieldName: string ]: any }, namespace?: string) {
187 if (!fields) return
188
189 let formKey: string
190
191 for (const key of Object.keys(fields)) {
192 if (namespace) formKey = `${namespace}[${key}]`
193 else formKey = key
194
195 if (fields[key] === undefined) continue
196
197 if (Array.isArray(fields[key]) && fields[key].length === 0) {
198 req.field(key, [])
199 continue
200 }
201
202 if (fields[key] !== null && typeof fields[key] === 'object') {
203 buildFields(req, fields[key], formKey)
204 } else {
205 req.field(formKey, fields[key])
206 }
207 }
208}
diff --git a/shared/server-commands/search/index.ts b/shared/server-commands/search/index.ts
new file mode 100644
index 000000000..48dbe8ae9
--- /dev/null
+++ b/shared/server-commands/search/index.ts
@@ -0,0 +1 @@
export * from './search-command'
diff --git a/shared/server-commands/search/search-command.ts b/shared/server-commands/search/search-command.ts
new file mode 100644
index 000000000..0fbbcd6ef
--- /dev/null
+++ b/shared/server-commands/search/search-command.ts
@@ -0,0 +1,98 @@
1import {
2 HttpStatusCode,
3 ResultList,
4 Video,
5 VideoChannel,
6 VideoChannelsSearchQuery,
7 VideoPlaylist,
8 VideoPlaylistsSearchQuery,
9 VideosSearchQuery
10} from '@shared/models'
11import { AbstractCommand, OverrideCommandOptions } from '../shared'
12
13export class SearchCommand extends AbstractCommand {
14
15 searchChannels (options: OverrideCommandOptions & {
16 search: string
17 }) {
18 return this.advancedChannelSearch({
19 ...options,
20
21 search: { search: options.search }
22 })
23 }
24
25 advancedChannelSearch (options: OverrideCommandOptions & {
26 search: VideoChannelsSearchQuery
27 }) {
28 const { search } = options
29 const path = '/api/v1/search/video-channels'
30
31 return this.getRequestBody<ResultList<VideoChannel>>({
32 ...options,
33
34 path,
35 query: search,
36 implicitToken: false,
37 defaultExpectedStatus: HttpStatusCode.OK_200
38 })
39 }
40
41 searchPlaylists (options: OverrideCommandOptions & {
42 search: string
43 }) {
44 return this.advancedPlaylistSearch({
45 ...options,
46
47 search: { search: options.search }
48 })
49 }
50
51 advancedPlaylistSearch (options: OverrideCommandOptions & {
52 search: VideoPlaylistsSearchQuery
53 }) {
54 const { search } = options
55 const path = '/api/v1/search/video-playlists'
56
57 return this.getRequestBody<ResultList<VideoPlaylist>>({
58 ...options,
59
60 path,
61 query: search,
62 implicitToken: false,
63 defaultExpectedStatus: HttpStatusCode.OK_200
64 })
65 }
66
67 searchVideos (options: OverrideCommandOptions & {
68 search: string
69 sort?: string
70 }) {
71 const { search, sort } = options
72
73 return this.advancedVideoSearch({
74 ...options,
75
76 search: {
77 search: search,
78 sort: sort ?? '-publishedAt'
79 }
80 })
81 }
82
83 advancedVideoSearch (options: OverrideCommandOptions & {
84 search: VideosSearchQuery
85 }) {
86 const { search } = options
87 const path = '/api/v1/search/videos'
88
89 return this.getRequestBody<ResultList<Video>>({
90 ...options,
91
92 path,
93 query: search,
94 implicitToken: false,
95 defaultExpectedStatus: HttpStatusCode.OK_200
96 })
97 }
98}
diff --git a/shared/server-commands/server/config-command.ts b/shared/server-commands/server/config-command.ts
new file mode 100644
index 000000000..797231b1d
--- /dev/null
+++ b/shared/server-commands/server/config-command.ts
@@ -0,0 +1,352 @@
1import { merge } from 'lodash'
2import { About, CustomConfig, HttpStatusCode, ServerConfig } from '@shared/models'
3import { DeepPartial } from '@shared/typescript-utils'
4import { AbstractCommand, OverrideCommandOptions } from '../shared/abstract-command'
5
6export class ConfigCommand extends AbstractCommand {
7
8 static getCustomConfigResolutions (enabled: boolean) {
9 return {
10 '144p': enabled,
11 '240p': enabled,
12 '360p': enabled,
13 '480p': enabled,
14 '720p': enabled,
15 '1080p': enabled,
16 '1440p': enabled,
17 '2160p': enabled
18 }
19 }
20
21 enableImports () {
22 return this.updateExistingSubConfig({
23 newConfig: {
24 import: {
25 videos: {
26 http: {
27 enabled: true
28 },
29
30 torrent: {
31 enabled: true
32 }
33 }
34 }
35 }
36 })
37 }
38
39 enableLive (options: {
40 allowReplay?: boolean
41 transcoding?: boolean
42 } = {}) {
43 return this.updateExistingSubConfig({
44 newConfig: {
45 live: {
46 enabled: true,
47 allowReplay: options.allowReplay ?? true,
48 transcoding: {
49 enabled: options.transcoding ?? true,
50 resolutions: ConfigCommand.getCustomConfigResolutions(true)
51 }
52 }
53 }
54 })
55 }
56
57 disableTranscoding () {
58 return this.updateExistingSubConfig({
59 newConfig: {
60 transcoding: {
61 enabled: false
62 }
63 }
64 })
65 }
66
67 enableTranscoding (webtorrent = true, hls = true) {
68 return this.updateExistingSubConfig({
69 newConfig: {
70 transcoding: {
71 enabled: true,
72 resolutions: ConfigCommand.getCustomConfigResolutions(true),
73
74 webtorrent: {
75 enabled: webtorrent
76 },
77 hls: {
78 enabled: hls
79 }
80 }
81 }
82 })
83 }
84
85 getConfig (options: OverrideCommandOptions = {}) {
86 const path = '/api/v1/config'
87
88 return this.getRequestBody<ServerConfig>({
89 ...options,
90
91 path,
92 implicitToken: false,
93 defaultExpectedStatus: HttpStatusCode.OK_200
94 })
95 }
96
97 getAbout (options: OverrideCommandOptions = {}) {
98 const path = '/api/v1/config/about'
99
100 return this.getRequestBody<About>({
101 ...options,
102
103 path,
104 implicitToken: false,
105 defaultExpectedStatus: HttpStatusCode.OK_200
106 })
107 }
108
109 getCustomConfig (options: OverrideCommandOptions = {}) {
110 const path = '/api/v1/config/custom'
111
112 return this.getRequestBody<CustomConfig>({
113 ...options,
114
115 path,
116 implicitToken: true,
117 defaultExpectedStatus: HttpStatusCode.OK_200
118 })
119 }
120
121 updateCustomConfig (options: OverrideCommandOptions & {
122 newCustomConfig: CustomConfig
123 }) {
124 const path = '/api/v1/config/custom'
125
126 return this.putBodyRequest({
127 ...options,
128
129 path,
130 fields: options.newCustomConfig,
131 implicitToken: true,
132 defaultExpectedStatus: HttpStatusCode.OK_200
133 })
134 }
135
136 deleteCustomConfig (options: OverrideCommandOptions = {}) {
137 const path = '/api/v1/config/custom'
138
139 return this.deleteRequest({
140 ...options,
141
142 path,
143 implicitToken: true,
144 defaultExpectedStatus: HttpStatusCode.OK_200
145 })
146 }
147
148 async updateExistingSubConfig (options: OverrideCommandOptions & {
149 newConfig: DeepPartial<CustomConfig>
150 }) {
151 const existing = await this.getCustomConfig(options)
152
153 return this.updateCustomConfig({ ...options, newCustomConfig: merge({}, existing, options.newConfig) })
154 }
155
156 updateCustomSubConfig (options: OverrideCommandOptions & {
157 newConfig: DeepPartial<CustomConfig>
158 }) {
159 const newCustomConfig: CustomConfig = {
160 instance: {
161 name: 'PeerTube updated',
162 shortDescription: 'my short description',
163 description: 'my super description',
164 terms: 'my super terms',
165 codeOfConduct: 'my super coc',
166
167 creationReason: 'my super creation reason',
168 moderationInformation: 'my super moderation information',
169 administrator: 'Kuja',
170 maintenanceLifetime: 'forever',
171 businessModel: 'my super business model',
172 hardwareInformation: '2vCore 3GB RAM',
173
174 languages: [ 'en', 'es' ],
175 categories: [ 1, 2 ],
176
177 isNSFW: true,
178 defaultNSFWPolicy: 'blur',
179
180 defaultClientRoute: '/videos/recently-added',
181
182 customizations: {
183 javascript: 'alert("coucou")',
184 css: 'body { background-color: red; }'
185 }
186 },
187 theme: {
188 default: 'default'
189 },
190 services: {
191 twitter: {
192 username: '@MySuperUsername',
193 whitelisted: true
194 }
195 },
196 client: {
197 videos: {
198 miniature: {
199 preferAuthorDisplayName: false
200 }
201 },
202 menu: {
203 login: {
204 redirectOnSingleExternalAuth: false
205 }
206 }
207 },
208 cache: {
209 previews: {
210 size: 2
211 },
212 captions: {
213 size: 3
214 },
215 torrents: {
216 size: 4
217 }
218 },
219 signup: {
220 enabled: false,
221 limit: 5,
222 requiresEmailVerification: false,
223 minimumAge: 16
224 },
225 admin: {
226 email: 'superadmin1@example.com'
227 },
228 contactForm: {
229 enabled: true
230 },
231 user: {
232 videoQuota: 5242881,
233 videoQuotaDaily: 318742
234 },
235 videoChannels: {
236 maxPerUser: 20
237 },
238 transcoding: {
239 enabled: true,
240 allowAdditionalExtensions: true,
241 allowAudioFiles: true,
242 threads: 1,
243 concurrency: 3,
244 profile: 'default',
245 resolutions: {
246 '0p': false,
247 '144p': false,
248 '240p': false,
249 '360p': true,
250 '480p': true,
251 '720p': false,
252 '1080p': false,
253 '1440p': false,
254 '2160p': false
255 },
256 webtorrent: {
257 enabled: true
258 },
259 hls: {
260 enabled: false
261 }
262 },
263 live: {
264 enabled: true,
265 allowReplay: false,
266 maxDuration: -1,
267 maxInstanceLives: -1,
268 maxUserLives: 50,
269 transcoding: {
270 enabled: true,
271 threads: 4,
272 profile: 'default',
273 resolutions: {
274 '144p': true,
275 '240p': true,
276 '360p': true,
277 '480p': true,
278 '720p': true,
279 '1080p': true,
280 '1440p': true,
281 '2160p': true
282 }
283 }
284 },
285 import: {
286 videos: {
287 concurrency: 3,
288 http: {
289 enabled: false
290 },
291 torrent: {
292 enabled: false
293 }
294 }
295 },
296 trending: {
297 videos: {
298 algorithms: {
299 enabled: [ 'best', 'hot', 'most-viewed', 'most-liked' ],
300 default: 'hot'
301 }
302 }
303 },
304 autoBlacklist: {
305 videos: {
306 ofUsers: {
307 enabled: false
308 }
309 }
310 },
311 followers: {
312 instance: {
313 enabled: true,
314 manualApproval: false
315 }
316 },
317 followings: {
318 instance: {
319 autoFollowBack: {
320 enabled: false
321 },
322 autoFollowIndex: {
323 indexUrl: 'https://instances.joinpeertube.org/api/v1/instances/hosts',
324 enabled: false
325 }
326 }
327 },
328 broadcastMessage: {
329 enabled: true,
330 level: 'warning',
331 message: 'hello',
332 dismissable: true
333 },
334 search: {
335 remoteUri: {
336 users: true,
337 anonymous: true
338 },
339 searchIndex: {
340 enabled: true,
341 url: 'https://search.joinpeertube.org',
342 disableLocalSearch: true,
343 isDefaultSearch: true
344 }
345 }
346 }
347
348 merge(newCustomConfig, options.newConfig)
349
350 return this.updateCustomConfig({ ...options, newCustomConfig })
351 }
352}
diff --git a/shared/server-commands/server/contact-form-command.ts b/shared/server-commands/server/contact-form-command.ts
new file mode 100644
index 000000000..0e8fd6d84
--- /dev/null
+++ b/shared/server-commands/server/contact-form-command.ts
@@ -0,0 +1,31 @@
1import { HttpStatusCode } from '@shared/models'
2import { ContactForm } from '../../models/server'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class ContactFormCommand extends AbstractCommand {
6
7 send (options: OverrideCommandOptions & {
8 fromEmail: string
9 fromName: string
10 subject: string
11 body: string
12 }) {
13 const path = '/api/v1/server/contact'
14
15 const body: ContactForm = {
16 fromEmail: options.fromEmail,
17 fromName: options.fromName,
18 subject: options.subject,
19 body: options.body
20 }
21
22 return this.postBodyRequest({
23 ...options,
24
25 path,
26 fields: body,
27 implicitToken: false,
28 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
29 })
30 }
31}
diff --git a/shared/server-commands/server/debug-command.ts b/shared/server-commands/server/debug-command.ts
new file mode 100644
index 000000000..3c5a785bb
--- /dev/null
+++ b/shared/server-commands/server/debug-command.ts
@@ -0,0 +1,33 @@
1import { Debug, HttpStatusCode, SendDebugCommand } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class DebugCommand extends AbstractCommand {
5
6 getDebug (options: OverrideCommandOptions = {}) {
7 const path = '/api/v1/server/debug'
8
9 return this.getRequestBody<Debug>({
10 ...options,
11
12 path,
13 implicitToken: true,
14 defaultExpectedStatus: HttpStatusCode.OK_200
15 })
16 }
17
18 sendCommand (options: OverrideCommandOptions & {
19 body: SendDebugCommand
20 }) {
21 const { body } = options
22 const path = '/api/v1/server/debug/run-command'
23
24 return this.postBodyRequest({
25 ...options,
26
27 path,
28 fields: body,
29 implicitToken: true,
30 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
31 })
32 }
33}
diff --git a/shared/server-commands/server/follows-command.ts b/shared/server-commands/server/follows-command.ts
new file mode 100644
index 000000000..01ef6f179
--- /dev/null
+++ b/shared/server-commands/server/follows-command.ts
@@ -0,0 +1,139 @@
1import { pick } from '@shared/core-utils'
2import { ActivityPubActorType, ActorFollow, FollowState, HttpStatusCode, ResultList, ServerFollowCreate } from '@shared/models'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4import { PeerTubeServer } from './server'
5
6export class FollowsCommand extends AbstractCommand {
7
8 getFollowers (options: OverrideCommandOptions & {
9 start: number
10 count: number
11 sort: string
12 search?: string
13 actorType?: ActivityPubActorType
14 state?: FollowState
15 }) {
16 const path = '/api/v1/server/followers'
17
18 const query = pick(options, [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ])
19
20 return this.getRequestBody<ResultList<ActorFollow>>({
21 ...options,
22
23 path,
24 query,
25 implicitToken: false,
26 defaultExpectedStatus: HttpStatusCode.OK_200
27 })
28 }
29
30 getFollowings (options: OverrideCommandOptions & {
31 start?: number
32 count?: number
33 sort?: string
34 search?: string
35 actorType?: ActivityPubActorType
36 state?: FollowState
37 } = {}) {
38 const path = '/api/v1/server/following'
39
40 const query = pick(options, [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ])
41
42 return this.getRequestBody<ResultList<ActorFollow>>({
43 ...options,
44
45 path,
46 query,
47 implicitToken: false,
48 defaultExpectedStatus: HttpStatusCode.OK_200
49 })
50 }
51
52 follow (options: OverrideCommandOptions & {
53 hosts?: string[]
54 handles?: string[]
55 }) {
56 const path = '/api/v1/server/following'
57
58 const fields: ServerFollowCreate = {}
59
60 if (options.hosts) {
61 fields.hosts = options.hosts.map(f => f.replace(/^http:\/\//, ''))
62 }
63
64 if (options.handles) {
65 fields.handles = options.handles
66 }
67
68 return this.postBodyRequest({
69 ...options,
70
71 path,
72 fields,
73 implicitToken: true,
74 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
75 })
76 }
77
78 async unfollow (options: OverrideCommandOptions & {
79 target: PeerTubeServer | string
80 }) {
81 const { target } = options
82
83 const handle = typeof target === 'string'
84 ? target
85 : target.host
86
87 const path = '/api/v1/server/following/' + handle
88
89 return this.deleteRequest({
90 ...options,
91
92 path,
93 implicitToken: true,
94 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
95 })
96 }
97
98 acceptFollower (options: OverrideCommandOptions & {
99 follower: string
100 }) {
101 const path = '/api/v1/server/followers/' + options.follower + '/accept'
102
103 return this.postBodyRequest({
104 ...options,
105
106 path,
107 implicitToken: true,
108 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
109 })
110 }
111
112 rejectFollower (options: OverrideCommandOptions & {
113 follower: string
114 }) {
115 const path = '/api/v1/server/followers/' + options.follower + '/reject'
116
117 return this.postBodyRequest({
118 ...options,
119
120 path,
121 implicitToken: true,
122 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
123 })
124 }
125
126 removeFollower (options: OverrideCommandOptions & {
127 follower: PeerTubeServer
128 }) {
129 const path = '/api/v1/server/followers/peertube@' + options.follower.host
130
131 return this.deleteRequest({
132 ...options,
133
134 path,
135 implicitToken: true,
136 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
137 })
138 }
139}
diff --git a/shared/server-commands/server/follows.ts b/shared/server-commands/server/follows.ts
new file mode 100644
index 000000000..698238f29
--- /dev/null
+++ b/shared/server-commands/server/follows.ts
@@ -0,0 +1,20 @@
1import { waitJobs } from './jobs'
2import { PeerTubeServer } from './server'
3
4async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) {
5 await Promise.all([
6 server1.follows.follow({ hosts: [ server2.url ] }),
7 server2.follows.follow({ hosts: [ server1.url ] })
8 ])
9
10 // Wait request propagation
11 await waitJobs([ server1, server2 ])
12
13 return true
14}
15
16// ---------------------------------------------------------------------------
17
18export {
19 doubleFollow
20}
diff --git a/shared/server-commands/server/index.ts b/shared/server-commands/server/index.ts
new file mode 100644
index 000000000..0a4b21fc4
--- /dev/null
+++ b/shared/server-commands/server/index.ts
@@ -0,0 +1,14 @@
1export * from './config-command'
2export * from './contact-form-command'
3export * from './debug-command'
4export * from './follows-command'
5export * from './follows'
6export * from './jobs'
7export * from './jobs-command'
8export * from './object-storage-command'
9export * from './plugins-command'
10export * from './redundancy-command'
11export * from './server'
12export * from './servers-command'
13export * from './servers'
14export * from './stats-command'
diff --git a/shared/server-commands/server/jobs-command.ts b/shared/server-commands/server/jobs-command.ts
new file mode 100644
index 000000000..ac62157d1
--- /dev/null
+++ b/shared/server-commands/server/jobs-command.ts
@@ -0,0 +1,60 @@
1import { pick } from '@shared/core-utils'
2import { HttpStatusCode, Job, JobState, JobType, ResultList } from '@shared/models'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class JobsCommand extends AbstractCommand {
6
7 async getLatest (options: OverrideCommandOptions & {
8 jobType: JobType
9 }) {
10 const { data } = await this.list({ ...options, start: 0, count: 1, sort: '-createdAt' })
11
12 if (data.length === 0) return undefined
13
14 return data[0]
15 }
16
17 list (options: OverrideCommandOptions & {
18 state?: JobState
19 jobType?: JobType
20 start?: number
21 count?: number
22 sort?: string
23 } = {}) {
24 const path = this.buildJobsUrl(options.state)
25
26 const query = pick(options, [ 'start', 'count', 'sort', 'jobType' ])
27
28 return this.getRequestBody<ResultList<Job>>({
29 ...options,
30
31 path,
32 query,
33 implicitToken: true,
34 defaultExpectedStatus: HttpStatusCode.OK_200
35 })
36 }
37
38 listFailed (options: OverrideCommandOptions & {
39 jobType?: JobType
40 }) {
41 const path = this.buildJobsUrl('failed')
42
43 return this.getRequestBody<ResultList<Job>>({
44 ...options,
45
46 path,
47 query: { start: 0, count: 50 },
48 implicitToken: true,
49 defaultExpectedStatus: HttpStatusCode.OK_200
50 })
51 }
52
53 private buildJobsUrl (state?: JobState) {
54 let path = '/api/v1/jobs'
55
56 if (state) path += '/' + state
57
58 return path
59 }
60}
diff --git a/shared/server-commands/server/jobs.ts b/shared/server-commands/server/jobs.ts
new file mode 100644
index 000000000..fc65a873b
--- /dev/null
+++ b/shared/server-commands/server/jobs.ts
@@ -0,0 +1,84 @@
1
2import { expect } from 'chai'
3import { wait } from '@shared/core-utils'
4import { JobState, JobType } from '../../models'
5import { PeerTubeServer } from './server'
6
7async function waitJobs (serversArg: PeerTubeServer[] | PeerTubeServer, skipDelayed = false) {
8 const pendingJobWait = process.env.NODE_PENDING_JOB_WAIT
9 ? parseInt(process.env.NODE_PENDING_JOB_WAIT, 10)
10 : 250
11
12 let servers: PeerTubeServer[]
13
14 if (Array.isArray(serversArg) === false) servers = [ serversArg as PeerTubeServer ]
15 else servers = serversArg as PeerTubeServer[]
16
17 const states: JobState[] = [ 'waiting', 'active' ]
18 if (!skipDelayed) states.push('delayed')
19
20 const repeatableJobs: JobType[] = [ 'videos-views-stats', 'activitypub-cleaner' ]
21 let pendingRequests: boolean
22
23 function tasksBuilder () {
24 const tasks: Promise<any>[] = []
25
26 // Check if each server has pending request
27 for (const server of servers) {
28 for (const state of states) {
29 const p = server.jobs.list({
30 state,
31 start: 0,
32 count: 10,
33 sort: '-createdAt'
34 }).then(body => body.data)
35 .then(jobs => jobs.filter(j => !repeatableJobs.includes(j.type)))
36 .then(jobs => {
37 if (jobs.length !== 0) {
38 pendingRequests = true
39 }
40 })
41
42 tasks.push(p)
43 }
44
45 const p = server.debug.getDebug()
46 .then(obj => {
47 if (obj.activityPubMessagesWaiting !== 0) {
48 pendingRequests = true
49 }
50 })
51
52 tasks.push(p)
53 }
54
55 return tasks
56 }
57
58 do {
59 pendingRequests = false
60 await Promise.all(tasksBuilder())
61
62 // Retry, in case of new jobs were created
63 if (pendingRequests === false) {
64 await wait(pendingJobWait)
65 await Promise.all(tasksBuilder())
66 }
67
68 if (pendingRequests) {
69 await wait(pendingJobWait)
70 }
71 } while (pendingRequests)
72}
73
74async function expectNoFailedTranscodingJob (server: PeerTubeServer) {
75 const { data } = await server.jobs.listFailed({ jobType: 'video-transcoding' })
76 expect(data).to.have.lengthOf(0)
77}
78
79// ---------------------------------------------------------------------------
80
81export {
82 waitJobs,
83 expectNoFailedTranscodingJob
84}
diff --git a/shared/server-commands/server/object-storage-command.ts b/shared/server-commands/server/object-storage-command.ts
new file mode 100644
index 000000000..b4de8f4cb
--- /dev/null
+++ b/shared/server-commands/server/object-storage-command.ts
@@ -0,0 +1,77 @@
1
2import { HttpStatusCode } from '@shared/models'
3import { makePostBodyRequest } from '../requests'
4import { AbstractCommand } from '../shared'
5
6export class ObjectStorageCommand extends AbstractCommand {
7 static readonly DEFAULT_PLAYLIST_BUCKET = 'streaming-playlists'
8 static readonly DEFAULT_WEBTORRENT_BUCKET = 'videos'
9
10 static getDefaultConfig () {
11 return {
12 object_storage: {
13 enabled: true,
14 endpoint: 'http://' + this.getEndpointHost(),
15 region: this.getRegion(),
16
17 credentials: this.getCredentialsConfig(),
18
19 streaming_playlists: {
20 bucket_name: this.DEFAULT_PLAYLIST_BUCKET
21 },
22
23 videos: {
24 bucket_name: this.DEFAULT_WEBTORRENT_BUCKET
25 }
26 }
27 }
28 }
29
30 static getCredentialsConfig () {
31 return {
32 access_key_id: 'AKIAIOSFODNN7EXAMPLE',
33 secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'
34 }
35 }
36
37 static getEndpointHost () {
38 return 'localhost:9444'
39 }
40
41 static getRegion () {
42 return 'us-east-1'
43 }
44
45 static getWebTorrentBaseUrl () {
46 return `http://${this.DEFAULT_WEBTORRENT_BUCKET}.${this.getEndpointHost()}/`
47 }
48
49 static getPlaylistBaseUrl () {
50 return `http://${this.DEFAULT_PLAYLIST_BUCKET}.${this.getEndpointHost()}/`
51 }
52
53 static async prepareDefaultBuckets () {
54 await this.createBucket(this.DEFAULT_PLAYLIST_BUCKET)
55 await this.createBucket(this.DEFAULT_WEBTORRENT_BUCKET)
56 }
57
58 static async createBucket (name: string) {
59 await makePostBodyRequest({
60 url: this.getEndpointHost(),
61 path: '/ui/' + name + '?delete',
62 expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307
63 })
64
65 await makePostBodyRequest({
66 url: this.getEndpointHost(),
67 path: '/ui/' + name + '?create',
68 expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307
69 })
70
71 await makePostBodyRequest({
72 url: this.getEndpointHost(),
73 path: '/ui/' + name + '?make-public',
74 expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307
75 })
76 }
77}
diff --git a/shared/server-commands/server/plugins-command.ts b/shared/server-commands/server/plugins-command.ts
new file mode 100644
index 000000000..bb1277a7c
--- /dev/null
+++ b/shared/server-commands/server/plugins-command.ts
@@ -0,0 +1,257 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { readJSON, writeJSON } from 'fs-extra'
4import { join } from 'path'
5import { root } from '@shared/core-utils'
6import {
7 HttpStatusCode,
8 PeerTubePlugin,
9 PeerTubePluginIndex,
10 PeertubePluginIndexList,
11 PluginPackageJSON,
12 PluginTranslation,
13 PluginType,
14 PublicServerSetting,
15 RegisteredServerSettings,
16 ResultList
17} from '@shared/models'
18import { AbstractCommand, OverrideCommandOptions } from '../shared'
19
20export class PluginsCommand extends AbstractCommand {
21
22 static getPluginTestPath (suffix = '') {
23 return join(root(), 'server', 'tests', 'fixtures', 'peertube-plugin-test' + suffix)
24 }
25
26 list (options: OverrideCommandOptions & {
27 start?: number
28 count?: number
29 sort?: string
30 pluginType?: PluginType
31 uninstalled?: boolean
32 }) {
33 const { start, count, sort, pluginType, uninstalled } = options
34 const path = '/api/v1/plugins'
35
36 return this.getRequestBody<ResultList<PeerTubePlugin>>({
37 ...options,
38
39 path,
40 query: {
41 start,
42 count,
43 sort,
44 pluginType,
45 uninstalled
46 },
47 implicitToken: true,
48 defaultExpectedStatus: HttpStatusCode.OK_200
49 })
50 }
51
52 listAvailable (options: OverrideCommandOptions & {
53 start?: number
54 count?: number
55 sort?: string
56 pluginType?: PluginType
57 currentPeerTubeEngine?: string
58 search?: string
59 expectedStatus?: HttpStatusCode
60 }) {
61 const { start, count, sort, pluginType, search, currentPeerTubeEngine } = options
62 const path = '/api/v1/plugins/available'
63
64 const query: PeertubePluginIndexList = {
65 start,
66 count,
67 sort,
68 pluginType,
69 currentPeerTubeEngine,
70 search
71 }
72
73 return this.getRequestBody<ResultList<PeerTubePluginIndex>>({
74 ...options,
75
76 path,
77 query,
78 implicitToken: true,
79 defaultExpectedStatus: HttpStatusCode.OK_200
80 })
81 }
82
83 get (options: OverrideCommandOptions & {
84 npmName: string
85 }) {
86 const path = '/api/v1/plugins/' + options.npmName
87
88 return this.getRequestBody<PeerTubePlugin>({
89 ...options,
90
91 path,
92 implicitToken: true,
93 defaultExpectedStatus: HttpStatusCode.OK_200
94 })
95 }
96
97 updateSettings (options: OverrideCommandOptions & {
98 npmName: string
99 settings: any
100 }) {
101 const { npmName, settings } = options
102 const path = '/api/v1/plugins/' + npmName + '/settings'
103
104 return this.putBodyRequest({
105 ...options,
106
107 path,
108 fields: { settings },
109 implicitToken: true,
110 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
111 })
112 }
113
114 getRegisteredSettings (options: OverrideCommandOptions & {
115 npmName: string
116 }) {
117 const path = '/api/v1/plugins/' + options.npmName + '/registered-settings'
118
119 return this.getRequestBody<RegisteredServerSettings>({
120 ...options,
121
122 path,
123 implicitToken: true,
124 defaultExpectedStatus: HttpStatusCode.OK_200
125 })
126 }
127
128 getPublicSettings (options: OverrideCommandOptions & {
129 npmName: string
130 }) {
131 const { npmName } = options
132 const path = '/api/v1/plugins/' + npmName + '/public-settings'
133
134 return this.getRequestBody<PublicServerSetting>({
135 ...options,
136
137 path,
138 implicitToken: false,
139 defaultExpectedStatus: HttpStatusCode.OK_200
140 })
141 }
142
143 getTranslations (options: OverrideCommandOptions & {
144 locale: string
145 }) {
146 const { locale } = options
147 const path = '/plugins/translations/' + locale + '.json'
148
149 return this.getRequestBody<PluginTranslation>({
150 ...options,
151
152 path,
153 implicitToken: false,
154 defaultExpectedStatus: HttpStatusCode.OK_200
155 })
156 }
157
158 install (options: OverrideCommandOptions & {
159 path?: string
160 npmName?: string
161 pluginVersion?: string
162 }) {
163 const { npmName, path, pluginVersion } = options
164 const apiPath = '/api/v1/plugins/install'
165
166 return this.postBodyRequest({
167 ...options,
168
169 path: apiPath,
170 fields: { npmName, path, pluginVersion },
171 implicitToken: true,
172 defaultExpectedStatus: HttpStatusCode.OK_200
173 })
174 }
175
176 update (options: OverrideCommandOptions & {
177 path?: string
178 npmName?: string
179 }) {
180 const { npmName, path } = options
181 const apiPath = '/api/v1/plugins/update'
182
183 return this.postBodyRequest({
184 ...options,
185
186 path: apiPath,
187 fields: { npmName, path },
188 implicitToken: true,
189 defaultExpectedStatus: HttpStatusCode.OK_200
190 })
191 }
192
193 uninstall (options: OverrideCommandOptions & {
194 npmName: string
195 }) {
196 const { npmName } = options
197 const apiPath = '/api/v1/plugins/uninstall'
198
199 return this.postBodyRequest({
200 ...options,
201
202 path: apiPath,
203 fields: { npmName },
204 implicitToken: true,
205 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
206 })
207 }
208
209 getCSS (options: OverrideCommandOptions = {}) {
210 const path = '/plugins/global.css'
211
212 return this.getRequestText({
213 ...options,
214
215 path,
216 implicitToken: false,
217 defaultExpectedStatus: HttpStatusCode.OK_200
218 })
219 }
220
221 getExternalAuth (options: OverrideCommandOptions & {
222 npmName: string
223 npmVersion: string
224 authName: string
225 query?: any
226 }) {
227 const { npmName, npmVersion, authName, query } = options
228
229 const path = '/plugins/' + npmName + '/' + npmVersion + '/auth/' + authName
230
231 return this.getRequest({
232 ...options,
233
234 path,
235 query,
236 implicitToken: false,
237 defaultExpectedStatus: HttpStatusCode.OK_200,
238 redirects: 0
239 })
240 }
241
242 updatePackageJSON (npmName: string, json: any) {
243 const path = this.getPackageJSONPath(npmName)
244
245 return writeJSON(path, json)
246 }
247
248 getPackageJSON (npmName: string): Promise<PluginPackageJSON> {
249 const path = this.getPackageJSONPath(npmName)
250
251 return readJSON(path)
252 }
253
254 private getPackageJSONPath (npmName: string) {
255 return this.server.servers.buildDirectory(join('plugins', 'node_modules', npmName, 'package.json'))
256 }
257}
diff --git a/shared/server-commands/server/redundancy-command.ts b/shared/server-commands/server/redundancy-command.ts
new file mode 100644
index 000000000..e7a8b3c29
--- /dev/null
+++ b/shared/server-commands/server/redundancy-command.ts
@@ -0,0 +1,80 @@
1import { HttpStatusCode, ResultList, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class RedundancyCommand extends AbstractCommand {
5
6 updateRedundancy (options: OverrideCommandOptions & {
7 host: string
8 redundancyAllowed: boolean
9 }) {
10 const { host, redundancyAllowed } = options
11 const path = '/api/v1/server/redundancy/' + host
12
13 return this.putBodyRequest({
14 ...options,
15
16 path,
17 fields: { redundancyAllowed },
18 implicitToken: true,
19 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
20 })
21 }
22
23 listVideos (options: OverrideCommandOptions & {
24 target: VideoRedundanciesTarget
25 start?: number
26 count?: number
27 sort?: string
28 }) {
29 const path = '/api/v1/server/redundancy/videos'
30
31 const { target, start, count, sort } = options
32
33 return this.getRequestBody<ResultList<VideoRedundancy>>({
34 ...options,
35
36 path,
37
38 query: {
39 start: start ?? 0,
40 count: count ?? 5,
41 sort: sort ?? 'name',
42 target
43 },
44
45 implicitToken: true,
46 defaultExpectedStatus: HttpStatusCode.OK_200
47 })
48 }
49
50 addVideo (options: OverrideCommandOptions & {
51 videoId: number
52 }) {
53 const path = '/api/v1/server/redundancy/videos'
54 const { videoId } = options
55
56 return this.postBodyRequest({
57 ...options,
58
59 path,
60 fields: { videoId },
61 implicitToken: true,
62 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
63 })
64 }
65
66 removeVideo (options: OverrideCommandOptions & {
67 redundancyId: number
68 }) {
69 const { redundancyId } = options
70 const path = '/api/v1/server/redundancy/videos/' + redundancyId
71
72 return this.deleteRequest({
73 ...options,
74
75 path,
76 implicitToken: true,
77 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
78 })
79 }
80}
diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts
new file mode 100644
index 000000000..da89fd876
--- /dev/null
+++ b/shared/server-commands/server/server.ts
@@ -0,0 +1,398 @@
1import { ChildProcess, fork } from 'child_process'
2import { copy } from 'fs-extra'
3import { join } from 'path'
4import { parallelTests, randomInt, root } from '@shared/core-utils'
5import { Video, VideoChannel, VideoCreateResult, VideoDetails } from '@shared/models'
6import { BulkCommand } from '../bulk'
7import { CLICommand } from '../cli'
8import { CustomPagesCommand } from '../custom-pages'
9import { FeedCommand } from '../feeds'
10import { LogsCommand } from '../logs'
11import { SQLCommand } from '../miscs'
12import { AbusesCommand } from '../moderation'
13import { OverviewsCommand } from '../overviews'
14import { SearchCommand } from '../search'
15import { SocketIOCommand } from '../socket'
16import { AccountsCommand, BlocklistCommand, LoginCommand, NotificationsCommand, SubscriptionsCommand, UsersCommand } from '../users'
17import {
18 BlacklistCommand,
19 CaptionsCommand,
20 ChangeOwnershipCommand,
21 ChannelsCommand,
22 HistoryCommand,
23 ImportsCommand,
24 LiveCommand,
25 PlaylistsCommand,
26 ServicesCommand,
27 StreamingPlaylistsCommand,
28 VideosCommand
29} from '../videos'
30import { CommentsCommand } from '../videos/comments-command'
31import { ConfigCommand } from './config-command'
32import { ContactFormCommand } from './contact-form-command'
33import { DebugCommand } from './debug-command'
34import { FollowsCommand } from './follows-command'
35import { JobsCommand } from './jobs-command'
36import { ObjectStorageCommand } from './object-storage-command'
37import { PluginsCommand } from './plugins-command'
38import { RedundancyCommand } from './redundancy-command'
39import { ServersCommand } from './servers-command'
40import { StatsCommand } from './stats-command'
41
42export type RunServerOptions = {
43 hideLogs?: boolean
44 nodeArgs?: string[]
45 peertubeArgs?: string[]
46 env?: { [ id: string ]: string }
47}
48
49export class PeerTubeServer {
50 app?: ChildProcess
51
52 url: string
53 host?: string
54 hostname?: string
55 port?: number
56
57 rtmpPort?: number
58 rtmpsPort?: number
59
60 parallel?: boolean
61 internalServerNumber: number
62
63 serverNumber?: number
64 customConfigFile?: string
65
66 store?: {
67 client?: {
68 id?: string
69 secret?: string
70 }
71
72 user?: {
73 username: string
74 password: string
75 email?: string
76 }
77
78 channel?: VideoChannel
79
80 video?: Video
81 videoCreated?: VideoCreateResult
82 videoDetails?: VideoDetails
83
84 videos?: { id: number, uuid: string }[]
85 }
86
87 accessToken?: string
88 refreshToken?: string
89
90 bulk?: BulkCommand
91 cli?: CLICommand
92 customPage?: CustomPagesCommand
93 feed?: FeedCommand
94 logs?: LogsCommand
95 abuses?: AbusesCommand
96 overviews?: OverviewsCommand
97 search?: SearchCommand
98 contactForm?: ContactFormCommand
99 debug?: DebugCommand
100 follows?: FollowsCommand
101 jobs?: JobsCommand
102 plugins?: PluginsCommand
103 redundancy?: RedundancyCommand
104 stats?: StatsCommand
105 config?: ConfigCommand
106 socketIO?: SocketIOCommand
107 accounts?: AccountsCommand
108 blocklist?: BlocklistCommand
109 subscriptions?: SubscriptionsCommand
110 live?: LiveCommand
111 services?: ServicesCommand
112 blacklist?: BlacklistCommand
113 captions?: CaptionsCommand
114 changeOwnership?: ChangeOwnershipCommand
115 playlists?: PlaylistsCommand
116 history?: HistoryCommand
117 imports?: ImportsCommand
118 streamingPlaylists?: StreamingPlaylistsCommand
119 channels?: ChannelsCommand
120 comments?: CommentsCommand
121 sql?: SQLCommand
122 notifications?: NotificationsCommand
123 servers?: ServersCommand
124 login?: LoginCommand
125 users?: UsersCommand
126 objectStorage?: ObjectStorageCommand
127 videos?: VideosCommand
128
129 constructor (options: { serverNumber: number } | { url: string }) {
130 if ((options as any).url) {
131 this.setUrl((options as any).url)
132 } else {
133 this.setServerNumber((options as any).serverNumber)
134 }
135
136 this.store = {
137 client: {
138 id: null,
139 secret: null
140 },
141 user: {
142 username: null,
143 password: null
144 }
145 }
146
147 this.assignCommands()
148 }
149
150 setServerNumber (serverNumber: number) {
151 this.serverNumber = serverNumber
152
153 this.parallel = parallelTests()
154
155 this.internalServerNumber = this.parallel ? this.randomServer() : this.serverNumber
156 this.rtmpPort = this.parallel ? this.randomRTMP() : 1936
157 this.rtmpsPort = this.parallel ? this.randomRTMP() : 1937
158 this.port = 9000 + this.internalServerNumber
159
160 this.url = `http://localhost:${this.port}`
161 this.host = `localhost:${this.port}`
162 this.hostname = 'localhost'
163 }
164
165 setUrl (url: string) {
166 const parsed = new URL(url)
167
168 this.url = url
169 this.host = parsed.host
170 this.hostname = parsed.hostname
171 this.port = parseInt(parsed.port)
172 }
173
174 async flushAndRun (configOverride?: Object, options: RunServerOptions = {}) {
175 await ServersCommand.flushTests(this.internalServerNumber)
176
177 return this.run(configOverride, options)
178 }
179
180 async run (configOverrideArg?: any, options: RunServerOptions = {}) {
181 // These actions are async so we need to be sure that they have both been done
182 const serverRunString = {
183 'HTTP server listening': false
184 }
185 const key = 'Database peertube_test' + this.internalServerNumber + ' is ready'
186 serverRunString[key] = false
187
188 const regexps = {
189 client_id: 'Client id: (.+)',
190 client_secret: 'Client secret: (.+)',
191 user_username: 'Username: (.+)',
192 user_password: 'User password: (.+)'
193 }
194
195 await this.assignCustomConfigFile()
196
197 const configOverride = this.buildConfigOverride()
198
199 if (configOverrideArg !== undefined) {
200 Object.assign(configOverride, configOverrideArg)
201 }
202
203 // Share the environment
204 const env = Object.create(process.env)
205 env['NODE_ENV'] = 'test'
206 env['NODE_APP_INSTANCE'] = this.internalServerNumber.toString()
207 env['NODE_CONFIG'] = JSON.stringify(configOverride)
208
209 if (options.env) {
210 Object.assign(env, options.env)
211 }
212
213 const execArgv = options.nodeArgs || []
214 // FIXME: too slow :/
215 // execArgv.push('--enable-source-maps')
216
217 const forkOptions = {
218 silent: true,
219 env,
220 detached: true,
221 execArgv
222 }
223
224 const peertubeArgs = options.peertubeArgs || []
225
226 return new Promise<void>((res, rej) => {
227 const self = this
228 let aggregatedLogs = ''
229
230 this.app = fork(join(root(), 'dist', 'server.js'), peertubeArgs, forkOptions)
231
232 const onPeerTubeExit = () => rej(new Error('Process exited:\n' + aggregatedLogs))
233 const onParentExit = () => {
234 if (!this.app || !this.app.pid) return
235
236 try {
237 process.kill(self.app.pid)
238 } catch { /* empty */ }
239 }
240
241 this.app.on('exit', onPeerTubeExit)
242 process.on('exit', onParentExit)
243
244 this.app.stdout.on('data', function onStdout (data) {
245 let dontContinue = false
246
247 const log: string = data.toString()
248 aggregatedLogs += log
249
250 // Capture things if we want to
251 for (const key of Object.keys(regexps)) {
252 const regexp = regexps[key]
253 const matches = log.match(regexp)
254 if (matches !== null) {
255 if (key === 'client_id') self.store.client.id = matches[1]
256 else if (key === 'client_secret') self.store.client.secret = matches[1]
257 else if (key === 'user_username') self.store.user.username = matches[1]
258 else if (key === 'user_password') self.store.user.password = matches[1]
259 }
260 }
261
262 // Check if all required sentences are here
263 for (const key of Object.keys(serverRunString)) {
264 if (log.includes(key)) serverRunString[key] = true
265 if (serverRunString[key] === false) dontContinue = true
266 }
267
268 // If no, there is maybe one thing not already initialized (client/user credentials generation...)
269 if (dontContinue === true) return
270
271 if (options.hideLogs === false) {
272 console.log(log)
273 } else {
274 process.removeListener('exit', onParentExit)
275 self.app.stdout.removeListener('data', onStdout)
276 self.app.removeListener('exit', onPeerTubeExit)
277 }
278
279 res()
280 })
281 })
282 }
283
284 async kill () {
285 if (!this.app) return
286
287 await this.sql.cleanup()
288
289 process.kill(-this.app.pid)
290
291 this.app = null
292 }
293
294 private randomServer () {
295 const low = 10
296 const high = 10000
297
298 return randomInt(low, high)
299 }
300
301 private randomRTMP () {
302 const low = 1900
303 const high = 2100
304
305 return randomInt(low, high)
306 }
307
308 private async assignCustomConfigFile () {
309 if (this.internalServerNumber === this.serverNumber) return
310
311 const basePath = join(root(), 'config')
312
313 const tmpConfigFile = join(basePath, `test-${this.internalServerNumber}.yaml`)
314 await copy(join(basePath, `test-${this.serverNumber}.yaml`), tmpConfigFile)
315
316 this.customConfigFile = tmpConfigFile
317 }
318
319 private buildConfigOverride () {
320 if (!this.parallel) return {}
321
322 return {
323 listen: {
324 port: this.port
325 },
326 webserver: {
327 port: this.port
328 },
329 database: {
330 suffix: '_test' + this.internalServerNumber
331 },
332 storage: {
333 tmp: `test${this.internalServerNumber}/tmp/`,
334 bin: `test${this.internalServerNumber}/bin/`,
335 avatars: `test${this.internalServerNumber}/avatars/`,
336 videos: `test${this.internalServerNumber}/videos/`,
337 streaming_playlists: `test${this.internalServerNumber}/streaming-playlists/`,
338 redundancy: `test${this.internalServerNumber}/redundancy/`,
339 logs: `test${this.internalServerNumber}/logs/`,
340 previews: `test${this.internalServerNumber}/previews/`,
341 thumbnails: `test${this.internalServerNumber}/thumbnails/`,
342 torrents: `test${this.internalServerNumber}/torrents/`,
343 captions: `test${this.internalServerNumber}/captions/`,
344 cache: `test${this.internalServerNumber}/cache/`,
345 plugins: `test${this.internalServerNumber}/plugins/`
346 },
347 admin: {
348 email: `admin${this.internalServerNumber}@example.com`
349 },
350 live: {
351 rtmp: {
352 port: this.rtmpPort
353 }
354 }
355 }
356 }
357
358 private assignCommands () {
359 this.bulk = new BulkCommand(this)
360 this.cli = new CLICommand(this)
361 this.customPage = new CustomPagesCommand(this)
362 this.feed = new FeedCommand(this)
363 this.logs = new LogsCommand(this)
364 this.abuses = new AbusesCommand(this)
365 this.overviews = new OverviewsCommand(this)
366 this.search = new SearchCommand(this)
367 this.contactForm = new ContactFormCommand(this)
368 this.debug = new DebugCommand(this)
369 this.follows = new FollowsCommand(this)
370 this.jobs = new JobsCommand(this)
371 this.plugins = new PluginsCommand(this)
372 this.redundancy = new RedundancyCommand(this)
373 this.stats = new StatsCommand(this)
374 this.config = new ConfigCommand(this)
375 this.socketIO = new SocketIOCommand(this)
376 this.accounts = new AccountsCommand(this)
377 this.blocklist = new BlocklistCommand(this)
378 this.subscriptions = new SubscriptionsCommand(this)
379 this.live = new LiveCommand(this)
380 this.services = new ServicesCommand(this)
381 this.blacklist = new BlacklistCommand(this)
382 this.captions = new CaptionsCommand(this)
383 this.changeOwnership = new ChangeOwnershipCommand(this)
384 this.playlists = new PlaylistsCommand(this)
385 this.history = new HistoryCommand(this)
386 this.imports = new ImportsCommand(this)
387 this.streamingPlaylists = new StreamingPlaylistsCommand(this)
388 this.channels = new ChannelsCommand(this)
389 this.comments = new CommentsCommand(this)
390 this.sql = new SQLCommand(this)
391 this.notifications = new NotificationsCommand(this)
392 this.servers = new ServersCommand(this)
393 this.login = new LoginCommand(this)
394 this.users = new UsersCommand(this)
395 this.videos = new VideosCommand(this)
396 this.objectStorage = new ObjectStorageCommand(this)
397 }
398}
diff --git a/shared/server-commands/server/servers-command.ts b/shared/server-commands/server/servers-command.ts
new file mode 100644
index 000000000..c5d8d18dc
--- /dev/null
+++ b/shared/server-commands/server/servers-command.ts
@@ -0,0 +1,92 @@
1import { exec } from 'child_process'
2import { copy, ensureDir, readFile, remove } from 'fs-extra'
3import { basename, join } from 'path'
4import { isGithubCI, root, wait } from '@shared/core-utils'
5import { getFileSize } from '@shared/extra-utils'
6import { HttpStatusCode } from '@shared/models'
7import { AbstractCommand, OverrideCommandOptions } from '../shared'
8
9export class ServersCommand extends AbstractCommand {
10
11 static flushTests (internalServerNumber: number) {
12 return new Promise<void>((res, rej) => {
13 const suffix = ` -- ${internalServerNumber}`
14
15 return exec('npm run clean:server:test' + suffix, (err, _stdout, stderr) => {
16 if (err || stderr) return rej(err || new Error(stderr))
17
18 return res()
19 })
20 })
21 }
22
23 ping (options: OverrideCommandOptions = {}) {
24 return this.getRequestBody({
25 ...options,
26
27 path: '/api/v1/ping',
28 implicitToken: false,
29 defaultExpectedStatus: HttpStatusCode.OK_200
30 })
31 }
32
33 async cleanupTests () {
34 const p: Promise<any>[] = []
35
36 if (isGithubCI()) {
37 await ensureDir('artifacts')
38
39 const origin = this.buildDirectory('logs/peertube.log')
40 const destname = `peertube-${this.server.internalServerNumber}.log`
41 console.log('Saving logs %s.', destname)
42
43 await copy(origin, join('artifacts', destname))
44 }
45
46 if (this.server.parallel) {
47 p.push(ServersCommand.flushTests(this.server.internalServerNumber))
48 }
49
50 if (this.server.customConfigFile) {
51 p.push(remove(this.server.customConfigFile))
52 }
53
54 return p
55 }
56
57 async waitUntilLog (str: string, count = 1, strictCount = true) {
58 const logfile = this.buildDirectory('logs/peertube.log')
59
60 while (true) {
61 const buf = await readFile(logfile)
62
63 const matches = buf.toString().match(new RegExp(str, 'g'))
64 if (matches && matches.length === count) return
65 if (matches && strictCount === false && matches.length >= count) return
66
67 await wait(1000)
68 }
69 }
70
71 buildDirectory (directory: string) {
72 return join(root(), 'test' + this.server.internalServerNumber, directory)
73 }
74
75 buildWebTorrentFilePath (fileUrl: string) {
76 return this.buildDirectory(join('videos', basename(fileUrl)))
77 }
78
79 buildFragmentedFilePath (videoUUID: string, fileUrl: string) {
80 return this.buildDirectory(join('streaming-playlists', 'hls', videoUUID, basename(fileUrl)))
81 }
82
83 getLogContent () {
84 return readFile(this.buildDirectory('logs/peertube.log'))
85 }
86
87 async getServerFileSize (subPath: string) {
88 const path = this.server.servers.buildDirectory(subPath)
89
90 return getFileSize(path)
91 }
92}
diff --git a/shared/server-commands/server/servers.ts b/shared/server-commands/server/servers.ts
new file mode 100644
index 000000000..0faee3a8d
--- /dev/null
+++ b/shared/server-commands/server/servers.ts
@@ -0,0 +1,49 @@
1import { ensureDir } from 'fs-extra'
2import { isGithubCI } from '@shared/core-utils'
3import { PeerTubeServer, RunServerOptions } from './server'
4
5async function createSingleServer (serverNumber: number, configOverride?: Object, options: RunServerOptions = {}) {
6 const server = new PeerTubeServer({ serverNumber })
7
8 await server.flushAndRun(configOverride, options)
9
10 return server
11}
12
13function createMultipleServers (totalServers: number, configOverride?: Object, options: RunServerOptions = {}) {
14 const serverPromises: Promise<PeerTubeServer>[] = []
15
16 for (let i = 1; i <= totalServers; i++) {
17 serverPromises.push(createSingleServer(i, configOverride, options))
18 }
19
20 return Promise.all(serverPromises)
21}
22
23async function killallServers (servers: PeerTubeServer[]) {
24 return Promise.all(servers.map(s => s.kill()))
25}
26
27async function cleanupTests (servers: PeerTubeServer[]) {
28 await killallServers(servers)
29
30 if (isGithubCI()) {
31 await ensureDir('artifacts')
32 }
33
34 let p: Promise<any>[] = []
35 for (const server of servers) {
36 p = p.concat(server.servers.cleanupTests())
37 }
38
39 return Promise.all(p)
40}
41
42// ---------------------------------------------------------------------------
43
44export {
45 createSingleServer,
46 createMultipleServers,
47 cleanupTests,
48 killallServers
49}
diff --git a/shared/server-commands/server/stats-command.ts b/shared/server-commands/server/stats-command.ts
new file mode 100644
index 000000000..64a452306
--- /dev/null
+++ b/shared/server-commands/server/stats-command.ts
@@ -0,0 +1,25 @@
1import { HttpStatusCode, ServerStats } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class StatsCommand extends AbstractCommand {
5
6 get (options: OverrideCommandOptions & {
7 useCache?: boolean // default false
8 } = {}) {
9 const { useCache = false } = options
10 const path = '/api/v1/server/stats'
11
12 const query = {
13 t: useCache ? undefined : new Date().getTime()
14 }
15
16 return this.getRequestBody<ServerStats>({
17 ...options,
18
19 path,
20 query,
21 implicitToken: false,
22 defaultExpectedStatus: HttpStatusCode.OK_200
23 })
24 }
25}
diff --git a/shared/server-commands/shared/abstract-command.ts b/shared/server-commands/shared/abstract-command.ts
new file mode 100644
index 000000000..1b53a5330
--- /dev/null
+++ b/shared/server-commands/shared/abstract-command.ts
@@ -0,0 +1,211 @@
1import { isAbsolute, join } from 'path'
2import { root } from '@shared/core-utils'
3import {
4 makeDeleteRequest,
5 makeGetRequest,
6 makePostBodyRequest,
7 makePutBodyRequest,
8 makeUploadRequest,
9 unwrapBody,
10 unwrapText
11} from '../requests/requests'
12import { PeerTubeServer } from '../server/server'
13
14export interface OverrideCommandOptions {
15 token?: string
16 expectedStatus?: number
17}
18
19interface InternalCommonCommandOptions extends OverrideCommandOptions {
20 // Default to server.url
21 url?: string
22
23 path: string
24 // If we automatically send the server token if the token is not provided
25 implicitToken: boolean
26 defaultExpectedStatus: number
27
28 // Common optional request parameters
29 contentType?: string
30 accept?: string
31 redirects?: number
32 range?: string
33 host?: string
34 headers?: { [ name: string ]: string }
35 requestType?: string
36 xForwardedFor?: string
37}
38
39interface InternalGetCommandOptions extends InternalCommonCommandOptions {
40 query?: { [ id: string ]: any }
41}
42
43interface InternalDeleteCommandOptions extends InternalCommonCommandOptions {
44 query?: { [ id: string ]: any }
45 rawQuery?: string
46}
47
48abstract class AbstractCommand {
49
50 constructor (
51 protected server: PeerTubeServer
52 ) {
53
54 }
55
56 protected getRequestBody <T> (options: InternalGetCommandOptions) {
57 return unwrapBody<T>(this.getRequest(options))
58 }
59
60 protected getRequestText (options: InternalGetCommandOptions) {
61 return unwrapText(this.getRequest(options))
62 }
63
64 protected getRawRequest (options: Omit<InternalGetCommandOptions, 'path'>) {
65 const { url, range } = options
66 const { host, protocol, pathname } = new URL(url)
67
68 return this.getRequest({
69 ...options,
70
71 token: this.buildCommonRequestToken(options),
72 defaultExpectedStatus: this.buildExpectedStatus(options),
73
74 url: `${protocol}//${host}`,
75 path: pathname,
76 range
77 })
78 }
79
80 protected getRequest (options: InternalGetCommandOptions) {
81 const { query } = options
82
83 return makeGetRequest({
84 ...this.buildCommonRequestOptions(options),
85
86 query
87 })
88 }
89
90 protected deleteRequest (options: InternalDeleteCommandOptions) {
91 const { query, rawQuery } = options
92
93 return makeDeleteRequest({
94 ...this.buildCommonRequestOptions(options),
95
96 query,
97 rawQuery
98 })
99 }
100
101 protected putBodyRequest (options: InternalCommonCommandOptions & {
102 fields?: { [ fieldName: string ]: any }
103 }) {
104 const { fields } = options
105
106 return makePutBodyRequest({
107 ...this.buildCommonRequestOptions(options),
108
109 fields
110 })
111 }
112
113 protected postBodyRequest (options: InternalCommonCommandOptions & {
114 fields?: { [ fieldName: string ]: any }
115 }) {
116 const { fields } = options
117
118 return makePostBodyRequest({
119 ...this.buildCommonRequestOptions(options),
120
121 fields
122 })
123 }
124
125 protected postUploadRequest (options: InternalCommonCommandOptions & {
126 fields?: { [ fieldName: string ]: any }
127 attaches?: { [ fieldName: string ]: any }
128 }) {
129 const { fields, attaches } = options
130
131 return makeUploadRequest({
132 ...this.buildCommonRequestOptions(options),
133
134 method: 'POST',
135 fields,
136 attaches
137 })
138 }
139
140 protected putUploadRequest (options: InternalCommonCommandOptions & {
141 fields?: { [ fieldName: string ]: any }
142 attaches?: { [ fieldName: string ]: any }
143 }) {
144 const { fields, attaches } = options
145
146 return makeUploadRequest({
147 ...this.buildCommonRequestOptions(options),
148
149 method: 'PUT',
150 fields,
151 attaches
152 })
153 }
154
155 protected updateImageRequest (options: InternalCommonCommandOptions & {
156 fixture: string
157 fieldname: string
158 }) {
159 const filePath = isAbsolute(options.fixture)
160 ? options.fixture
161 : join(root(), 'server', 'tests', 'fixtures', options.fixture)
162
163 return this.postUploadRequest({
164 ...options,
165
166 fields: {},
167 attaches: { [options.fieldname]: filePath }
168 })
169 }
170
171 protected buildCommonRequestOptions (options: InternalCommonCommandOptions) {
172 const { url, path, redirects, contentType, accept, range, host, headers, requestType, xForwardedFor } = options
173
174 return {
175 url: url ?? this.server.url,
176 path,
177
178 token: this.buildCommonRequestToken(options),
179 expectedStatus: this.buildExpectedStatus(options),
180
181 redirects,
182 contentType,
183 range,
184 host,
185 accept,
186 headers,
187 type: requestType,
188 xForwardedFor
189 }
190 }
191
192 protected buildCommonRequestToken (options: Pick<InternalCommonCommandOptions, 'token' | 'implicitToken'>) {
193 const { token } = options
194
195 const fallbackToken = options.implicitToken
196 ? this.server.accessToken
197 : undefined
198
199 return token !== undefined ? token : fallbackToken
200 }
201
202 protected buildExpectedStatus (options: Pick<InternalCommonCommandOptions, 'expectedStatus' | 'defaultExpectedStatus'>) {
203 const { expectedStatus, defaultExpectedStatus } = options
204
205 return expectedStatus !== undefined ? expectedStatus : defaultExpectedStatus
206 }
207}
208
209export {
210 AbstractCommand
211}
diff --git a/shared/server-commands/shared/index.ts b/shared/server-commands/shared/index.ts
new file mode 100644
index 000000000..e807ab4f7
--- /dev/null
+++ b/shared/server-commands/shared/index.ts
@@ -0,0 +1 @@
export * from './abstract-command'
diff --git a/shared/server-commands/socket/index.ts b/shared/server-commands/socket/index.ts
new file mode 100644
index 000000000..594329b2f
--- /dev/null
+++ b/shared/server-commands/socket/index.ts
@@ -0,0 +1 @@
export * from './socket-io-command'
diff --git a/shared/server-commands/socket/socket-io-command.ts b/shared/server-commands/socket/socket-io-command.ts
new file mode 100644
index 000000000..c277ead28
--- /dev/null
+++ b/shared/server-commands/socket/socket-io-command.ts
@@ -0,0 +1,15 @@
1import { io } from 'socket.io-client'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class SocketIOCommand extends AbstractCommand {
5
6 getUserNotificationSocket (options: OverrideCommandOptions = {}) {
7 return io(this.server.url + '/user-notifications', {
8 query: { accessToken: options.token ?? this.server.accessToken }
9 })
10 }
11
12 getLiveNotificationSocket () {
13 return io(this.server.url + '/live-videos')
14 }
15}
diff --git a/shared/server-commands/users/accounts-command.ts b/shared/server-commands/users/accounts-command.ts
new file mode 100644
index 000000000..5844b330b
--- /dev/null
+++ b/shared/server-commands/users/accounts-command.ts
@@ -0,0 +1,76 @@
1import { Account, AccountVideoRate, ActorFollow, HttpStatusCode, ResultList, VideoRateType } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class AccountsCommand extends AbstractCommand {
5
6 list (options: OverrideCommandOptions & {
7 sort?: string // default -createdAt
8 } = {}) {
9 const { sort = '-createdAt' } = options
10 const path = '/api/v1/accounts'
11
12 return this.getRequestBody<ResultList<Account>>({
13 ...options,
14
15 path,
16 query: { sort },
17 implicitToken: false,
18 defaultExpectedStatus: HttpStatusCode.OK_200
19 })
20 }
21
22 get (options: OverrideCommandOptions & {
23 accountName: string
24 }) {
25 const path = '/api/v1/accounts/' + options.accountName
26
27 return this.getRequestBody<Account>({
28 ...options,
29
30 path,
31 implicitToken: false,
32 defaultExpectedStatus: HttpStatusCode.OK_200
33 })
34 }
35
36 listRatings (options: OverrideCommandOptions & {
37 accountName: string
38 rating?: VideoRateType
39 }) {
40 const { rating, accountName } = options
41 const path = '/api/v1/accounts/' + accountName + '/ratings'
42
43 const query = { rating }
44
45 return this.getRequestBody<ResultList<AccountVideoRate>>({
46 ...options,
47
48 path,
49 query,
50 implicitToken: true,
51 defaultExpectedStatus: HttpStatusCode.OK_200
52 })
53 }
54
55 listFollowers (options: OverrideCommandOptions & {
56 accountName: string
57 start?: number
58 count?: number
59 sort?: string
60 search?: string
61 }) {
62 const { accountName, start, count, sort, search } = options
63 const path = '/api/v1/accounts/' + accountName + '/followers'
64
65 const query = { start, count, sort, search }
66
67 return this.getRequestBody<ResultList<ActorFollow>>({
68 ...options,
69
70 path,
71 query,
72 implicitToken: true,
73 defaultExpectedStatus: HttpStatusCode.OK_200
74 })
75 }
76}
diff --git a/shared/server-commands/users/blocklist-command.ts b/shared/server-commands/users/blocklist-command.ts
new file mode 100644
index 000000000..2e7ed074d
--- /dev/null
+++ b/shared/server-commands/users/blocklist-command.ts
@@ -0,0 +1,162 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { AccountBlock, BlockStatus, HttpStatusCode, ResultList, ServerBlock } from '@shared/models'
4import { AbstractCommand, OverrideCommandOptions } from '../shared'
5
6type ListBlocklistOptions = OverrideCommandOptions & {
7 start: number
8 count: number
9 sort: string // default -createdAt
10}
11
12export class BlocklistCommand extends AbstractCommand {
13
14 listMyAccountBlocklist (options: ListBlocklistOptions) {
15 const path = '/api/v1/users/me/blocklist/accounts'
16
17 return this.listBlocklist<AccountBlock>(options, path)
18 }
19
20 listMyServerBlocklist (options: ListBlocklistOptions) {
21 const path = '/api/v1/users/me/blocklist/servers'
22
23 return this.listBlocklist<ServerBlock>(options, path)
24 }
25
26 listServerAccountBlocklist (options: ListBlocklistOptions) {
27 const path = '/api/v1/server/blocklist/accounts'
28
29 return this.listBlocklist<AccountBlock>(options, path)
30 }
31
32 listServerServerBlocklist (options: ListBlocklistOptions) {
33 const path = '/api/v1/server/blocklist/servers'
34
35 return this.listBlocklist<ServerBlock>(options, path)
36 }
37
38 // ---------------------------------------------------------------------------
39
40 getStatus (options: OverrideCommandOptions & {
41 accounts?: string[]
42 hosts?: string[]
43 }) {
44 const { accounts, hosts } = options
45
46 const path = '/api/v1/blocklist/status'
47
48 return this.getRequestBody<BlockStatus>({
49 ...options,
50
51 path,
52 query: {
53 accounts,
54 hosts
55 },
56 implicitToken: false,
57 defaultExpectedStatus: HttpStatusCode.OK_200
58 })
59 }
60
61 // ---------------------------------------------------------------------------
62
63 addToMyBlocklist (options: OverrideCommandOptions & {
64 account?: string
65 server?: string
66 }) {
67 const { account, server } = options
68
69 const path = account
70 ? '/api/v1/users/me/blocklist/accounts'
71 : '/api/v1/users/me/blocklist/servers'
72
73 return this.postBodyRequest({
74 ...options,
75
76 path,
77 fields: {
78 accountName: account,
79 host: server
80 },
81 implicitToken: true,
82 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
83 })
84 }
85
86 addToServerBlocklist (options: OverrideCommandOptions & {
87 account?: string
88 server?: string
89 }) {
90 const { account, server } = options
91
92 const path = account
93 ? '/api/v1/server/blocklist/accounts'
94 : '/api/v1/server/blocklist/servers'
95
96 return this.postBodyRequest({
97 ...options,
98
99 path,
100 fields: {
101 accountName: account,
102 host: server
103 },
104 implicitToken: true,
105 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
106 })
107 }
108
109 // ---------------------------------------------------------------------------
110
111 removeFromMyBlocklist (options: OverrideCommandOptions & {
112 account?: string
113 server?: string
114 }) {
115 const { account, server } = options
116
117 const path = account
118 ? '/api/v1/users/me/blocklist/accounts/' + account
119 : '/api/v1/users/me/blocklist/servers/' + server
120
121 return this.deleteRequest({
122 ...options,
123
124 path,
125 implicitToken: true,
126 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
127 })
128 }
129
130 removeFromServerBlocklist (options: OverrideCommandOptions & {
131 account?: string
132 server?: string
133 }) {
134 const { account, server } = options
135
136 const path = account
137 ? '/api/v1/server/blocklist/accounts/' + account
138 : '/api/v1/server/blocklist/servers/' + server
139
140 return this.deleteRequest({
141 ...options,
142
143 path,
144 implicitToken: true,
145 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
146 })
147 }
148
149 private listBlocklist <T> (options: ListBlocklistOptions, path: string) {
150 const { start, count, sort = '-createdAt' } = options
151
152 return this.getRequestBody<ResultList<T>>({
153 ...options,
154
155 path,
156 query: { start, count, sort },
157 implicitToken: true,
158 defaultExpectedStatus: HttpStatusCode.OK_200
159 })
160 }
161
162}
diff --git a/shared/server-commands/users/index.ts b/shared/server-commands/users/index.ts
new file mode 100644
index 000000000..c2bc5c44f
--- /dev/null
+++ b/shared/server-commands/users/index.ts
@@ -0,0 +1,7 @@
1export * from './accounts-command'
2export * from './blocklist-command'
3export * from './login'
4export * from './login-command'
5export * from './notifications-command'
6export * from './subscriptions-command'
7export * from './users-command'
diff --git a/shared/server-commands/users/login-command.ts b/shared/server-commands/users/login-command.ts
new file mode 100644
index 000000000..143f72a59
--- /dev/null
+++ b/shared/server-commands/users/login-command.ts
@@ -0,0 +1,132 @@
1import { HttpStatusCode, PeerTubeProblemDocument } from '@shared/models'
2import { unwrapBody } from '../requests'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class LoginCommand extends AbstractCommand {
6
7 login (options: OverrideCommandOptions & {
8 client?: { id?: string, secret?: string }
9 user?: { username: string, password?: string }
10 } = {}) {
11 const { client = this.server.store.client, user = this.server.store.user } = options
12 const path = '/api/v1/users/token'
13
14 const body = {
15 client_id: client.id,
16 client_secret: client.secret,
17 username: user.username,
18 password: user.password ?? 'password',
19 response_type: 'code',
20 grant_type: 'password',
21 scope: 'upload'
22 }
23
24 return unwrapBody<{ access_token: string, refresh_token: string } & PeerTubeProblemDocument>(this.postBodyRequest({
25 ...options,
26
27 path,
28 requestType: 'form',
29 fields: body,
30 implicitToken: false,
31 defaultExpectedStatus: HttpStatusCode.OK_200
32 }))
33 }
34
35 getAccessToken (arg1?: { username: string, password?: string }): Promise<string>
36 getAccessToken (arg1: string, password?: string): Promise<string>
37 async getAccessToken (arg1?: { username: string, password?: string } | string, password?: string) {
38 let user: { username: string, password?: string }
39
40 if (!arg1) user = this.server.store.user
41 else if (typeof arg1 === 'object') user = arg1
42 else user = { username: arg1, password }
43
44 try {
45 const body = await this.login({ user })
46
47 return body.access_token
48 } catch (err) {
49 throw new Error(`Cannot authenticate. Please check your username/password. (${err})`)
50 }
51 }
52
53 loginUsingExternalToken (options: OverrideCommandOptions & {
54 username: string
55 externalAuthToken: string
56 }) {
57 const { username, externalAuthToken } = options
58 const path = '/api/v1/users/token'
59
60 const body = {
61 client_id: this.server.store.client.id,
62 client_secret: this.server.store.client.secret,
63 username: username,
64 response_type: 'code',
65 grant_type: 'password',
66 scope: 'upload',
67 externalAuthToken
68 }
69
70 return this.postBodyRequest({
71 ...options,
72
73 path,
74 requestType: 'form',
75 fields: body,
76 implicitToken: false,
77 defaultExpectedStatus: HttpStatusCode.OK_200
78 })
79 }
80
81 logout (options: OverrideCommandOptions & {
82 token: string
83 }) {
84 const path = '/api/v1/users/revoke-token'
85
86 return unwrapBody<{ redirectUrl: string }>(this.postBodyRequest({
87 ...options,
88
89 path,
90 requestType: 'form',
91 implicitToken: false,
92 defaultExpectedStatus: HttpStatusCode.OK_200
93 }))
94 }
95
96 refreshToken (options: OverrideCommandOptions & {
97 refreshToken: string
98 }) {
99 const path = '/api/v1/users/token'
100
101 const body = {
102 client_id: this.server.store.client.id,
103 client_secret: this.server.store.client.secret,
104 refresh_token: options.refreshToken,
105 response_type: 'code',
106 grant_type: 'refresh_token'
107 }
108
109 return this.postBodyRequest({
110 ...options,
111
112 path,
113 requestType: 'form',
114 fields: body,
115 implicitToken: false,
116 defaultExpectedStatus: HttpStatusCode.OK_200
117 })
118 }
119
120 getClient (options: OverrideCommandOptions = {}) {
121 const path = '/api/v1/oauth-clients/local'
122
123 return this.getRequestBody<{ client_id: string, client_secret: string }>({
124 ...options,
125
126 path,
127 host: this.server.host,
128 implicitToken: false,
129 defaultExpectedStatus: HttpStatusCode.OK_200
130 })
131 }
132}
diff --git a/shared/server-commands/users/login.ts b/shared/server-commands/users/login.ts
new file mode 100644
index 000000000..f1df027d3
--- /dev/null
+++ b/shared/server-commands/users/login.ts
@@ -0,0 +1,19 @@
1import { PeerTubeServer } from '../server/server'
2
3function setAccessTokensToServers (servers: PeerTubeServer[]) {
4 const tasks: Promise<any>[] = []
5
6 for (const server of servers) {
7 const p = server.login.getAccessToken()
8 .then(t => { server.accessToken = t })
9 tasks.push(p)
10 }
11
12 return Promise.all(tasks)
13}
14
15// ---------------------------------------------------------------------------
16
17export {
18 setAccessTokensToServers
19}
diff --git a/shared/server-commands/users/notifications-command.ts b/shared/server-commands/users/notifications-command.ts
new file mode 100644
index 000000000..6bd815daa
--- /dev/null
+++ b/shared/server-commands/users/notifications-command.ts
@@ -0,0 +1,85 @@
1import { HttpStatusCode, ResultList, UserNotification, UserNotificationSetting } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class NotificationsCommand extends AbstractCommand {
5
6 updateMySettings (options: OverrideCommandOptions & {
7 settings: UserNotificationSetting
8 }) {
9 const path = '/api/v1/users/me/notification-settings'
10
11 return this.putBodyRequest({
12 ...options,
13
14 path,
15 fields: options.settings,
16 implicitToken: true,
17 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
18 })
19 }
20
21 list (options: OverrideCommandOptions & {
22 start?: number
23 count?: number
24 unread?: boolean
25 sort?: string
26 }) {
27 const { start, count, unread, sort = '-createdAt' } = options
28 const path = '/api/v1/users/me/notifications'
29
30 return this.getRequestBody<ResultList<UserNotification>>({
31 ...options,
32
33 path,
34 query: {
35 start,
36 count,
37 sort,
38 unread
39 },
40 implicitToken: true,
41 defaultExpectedStatus: HttpStatusCode.OK_200
42 })
43 }
44
45 markAsRead (options: OverrideCommandOptions & {
46 ids: number[]
47 }) {
48 const { ids } = options
49 const path = '/api/v1/users/me/notifications/read'
50
51 return this.postBodyRequest({
52 ...options,
53
54 path,
55 fields: { ids },
56 implicitToken: true,
57 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
58 })
59 }
60
61 markAsReadAll (options: OverrideCommandOptions) {
62 const path = '/api/v1/users/me/notifications/read-all'
63
64 return this.postBodyRequest({
65 ...options,
66
67 path,
68 implicitToken: true,
69 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
70 })
71 }
72
73 async getLatest (options: OverrideCommandOptions = {}) {
74 const { total, data } = await this.list({
75 ...options,
76 start: 0,
77 count: 1,
78 sort: '-createdAt'
79 })
80
81 if (total === 0) return undefined
82
83 return data[0]
84 }
85}
diff --git a/shared/server-commands/users/subscriptions-command.ts b/shared/server-commands/users/subscriptions-command.ts
new file mode 100644
index 000000000..edc60e612
--- /dev/null
+++ b/shared/server-commands/users/subscriptions-command.ts
@@ -0,0 +1,99 @@
1import { HttpStatusCode, ResultList, Video, VideoChannel } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class SubscriptionsCommand extends AbstractCommand {
5
6 add (options: OverrideCommandOptions & {
7 targetUri: string
8 }) {
9 const path = '/api/v1/users/me/subscriptions'
10
11 return this.postBodyRequest({
12 ...options,
13
14 path,
15 fields: { uri: options.targetUri },
16 implicitToken: true,
17 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
18 })
19 }
20
21 list (options: OverrideCommandOptions & {
22 sort?: string // default -createdAt
23 search?: string
24 } = {}) {
25 const { sort = '-createdAt', search } = options
26 const path = '/api/v1/users/me/subscriptions'
27
28 return this.getRequestBody<ResultList<VideoChannel>>({
29 ...options,
30
31 path,
32 query: {
33 sort,
34 search
35 },
36 implicitToken: true,
37 defaultExpectedStatus: HttpStatusCode.OK_200
38 })
39 }
40
41 listVideos (options: OverrideCommandOptions & {
42 sort?: string // default -createdAt
43 } = {}) {
44 const { sort = '-createdAt' } = options
45 const path = '/api/v1/users/me/subscriptions/videos'
46
47 return this.getRequestBody<ResultList<Video>>({
48 ...options,
49
50 path,
51 query: { sort },
52 implicitToken: true,
53 defaultExpectedStatus: HttpStatusCode.OK_200
54 })
55 }
56
57 get (options: OverrideCommandOptions & {
58 uri: string
59 }) {
60 const path = '/api/v1/users/me/subscriptions/' + options.uri
61
62 return this.getRequestBody<VideoChannel>({
63 ...options,
64
65 path,
66 implicitToken: true,
67 defaultExpectedStatus: HttpStatusCode.OK_200
68 })
69 }
70
71 remove (options: OverrideCommandOptions & {
72 uri: string
73 }) {
74 const path = '/api/v1/users/me/subscriptions/' + options.uri
75
76 return this.deleteRequest({
77 ...options,
78
79 path,
80 implicitToken: true,
81 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
82 })
83 }
84
85 exist (options: OverrideCommandOptions & {
86 uris: string[]
87 }) {
88 const path = '/api/v1/users/me/subscriptions/exist'
89
90 return this.getRequestBody<{ [id: string ]: boolean }>({
91 ...options,
92
93 path,
94 query: { 'uris[]': options.uris },
95 implicitToken: true,
96 defaultExpectedStatus: HttpStatusCode.OK_200
97 })
98 }
99}
diff --git a/shared/server-commands/users/users-command.ts b/shared/server-commands/users/users-command.ts
new file mode 100644
index 000000000..b5ae9008e
--- /dev/null
+++ b/shared/server-commands/users/users-command.ts
@@ -0,0 +1,416 @@
1import { omit } from 'lodash'
2import { pick } from '@shared/core-utils'
3import {
4 HttpStatusCode,
5 MyUser,
6 ResultList,
7 ScopedToken,
8 User,
9 UserAdminFlag,
10 UserCreateResult,
11 UserRole,
12 UserUpdate,
13 UserUpdateMe,
14 UserVideoQuota,
15 UserVideoRate
16} from '@shared/models'
17import { unwrapBody } from '../requests'
18import { AbstractCommand, OverrideCommandOptions } from '../shared'
19
20export class UsersCommand extends AbstractCommand {
21
22 askResetPassword (options: OverrideCommandOptions & {
23 email: string
24 }) {
25 const { email } = options
26 const path = '/api/v1/users/ask-reset-password'
27
28 return this.postBodyRequest({
29 ...options,
30
31 path,
32 fields: { email },
33 implicitToken: false,
34 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
35 })
36 }
37
38 resetPassword (options: OverrideCommandOptions & {
39 userId: number
40 verificationString: string
41 password: string
42 }) {
43 const { userId, verificationString, password } = options
44 const path = '/api/v1/users/' + userId + '/reset-password'
45
46 return this.postBodyRequest({
47 ...options,
48
49 path,
50 fields: { password, verificationString },
51 implicitToken: false,
52 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
53 })
54 }
55
56 // ---------------------------------------------------------------------------
57
58 askSendVerifyEmail (options: OverrideCommandOptions & {
59 email: string
60 }) {
61 const { email } = options
62 const path = '/api/v1/users/ask-send-verify-email'
63
64 return this.postBodyRequest({
65 ...options,
66
67 path,
68 fields: { email },
69 implicitToken: false,
70 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
71 })
72 }
73
74 verifyEmail (options: OverrideCommandOptions & {
75 userId: number
76 verificationString: string
77 isPendingEmail?: boolean // default false
78 }) {
79 const { userId, verificationString, isPendingEmail = false } = options
80 const path = '/api/v1/users/' + userId + '/verify-email'
81
82 return this.postBodyRequest({
83 ...options,
84
85 path,
86 fields: {
87 verificationString,
88 isPendingEmail
89 },
90 implicitToken: false,
91 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
92 })
93 }
94
95 // ---------------------------------------------------------------------------
96
97 banUser (options: OverrideCommandOptions & {
98 userId: number
99 reason?: string
100 }) {
101 const { userId, reason } = options
102 const path = '/api/v1/users' + '/' + userId + '/block'
103
104 return this.postBodyRequest({
105 ...options,
106
107 path,
108 fields: { reason },
109 implicitToken: true,
110 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
111 })
112 }
113
114 unbanUser (options: OverrideCommandOptions & {
115 userId: number
116 }) {
117 const { userId } = options
118 const path = '/api/v1/users' + '/' + userId + '/unblock'
119
120 return this.postBodyRequest({
121 ...options,
122
123 path,
124 implicitToken: true,
125 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
126 })
127 }
128
129 // ---------------------------------------------------------------------------
130
131 getMyScopedTokens (options: OverrideCommandOptions = {}) {
132 const path = '/api/v1/users/scoped-tokens'
133
134 return this.getRequestBody<ScopedToken>({
135 ...options,
136
137 path,
138 implicitToken: true,
139 defaultExpectedStatus: HttpStatusCode.OK_200
140 })
141 }
142
143 renewMyScopedTokens (options: OverrideCommandOptions = {}) {
144 const path = '/api/v1/users/scoped-tokens'
145
146 return this.postBodyRequest({
147 ...options,
148
149 path,
150 implicitToken: true,
151 defaultExpectedStatus: HttpStatusCode.OK_200
152 })
153 }
154
155 // ---------------------------------------------------------------------------
156
157 create (options: OverrideCommandOptions & {
158 username: string
159 password?: string
160 videoQuota?: number
161 videoQuotaDaily?: number
162 role?: UserRole
163 adminFlags?: UserAdminFlag
164 }) {
165 const {
166 username,
167 adminFlags,
168 password = 'password',
169 videoQuota = 42000000,
170 videoQuotaDaily = -1,
171 role = UserRole.USER
172 } = options
173
174 const path = '/api/v1/users'
175
176 return unwrapBody<{ user: UserCreateResult }>(this.postBodyRequest({
177 ...options,
178
179 path,
180 fields: {
181 username,
182 password,
183 role,
184 adminFlags,
185 email: username + '@example.com',
186 videoQuota,
187 videoQuotaDaily
188 },
189 implicitToken: true,
190 defaultExpectedStatus: HttpStatusCode.OK_200
191 })).then(res => res.user)
192 }
193
194 async generate (username: string, role?: UserRole) {
195 const password = 'password'
196 const user = await this.create({ username, password, role })
197
198 const token = await this.server.login.getAccessToken({ username, password })
199
200 const me = await this.getMyInfo({ token })
201
202 return {
203 token,
204 userId: user.id,
205 userChannelId: me.videoChannels[0].id,
206 userChannelName: me.videoChannels[0].name
207 }
208 }
209
210 async generateUserAndToken (username: string, role?: UserRole) {
211 const password = 'password'
212 await this.create({ username, password, role })
213
214 return this.server.login.getAccessToken({ username, password })
215 }
216
217 register (options: OverrideCommandOptions & {
218 username: string
219 password?: string
220 displayName?: string
221 channel?: {
222 name: string
223 displayName: string
224 }
225 }) {
226 const { username, password = 'password', displayName, channel } = options
227 const path = '/api/v1/users/register'
228
229 return this.postBodyRequest({
230 ...options,
231
232 path,
233 fields: {
234 username,
235 password,
236 email: username + '@example.com',
237 displayName,
238 channel
239 },
240 implicitToken: false,
241 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
242 })
243 }
244
245 // ---------------------------------------------------------------------------
246
247 getMyInfo (options: OverrideCommandOptions = {}) {
248 const path = '/api/v1/users/me'
249
250 return this.getRequestBody<MyUser>({
251 ...options,
252
253 path,
254 implicitToken: true,
255 defaultExpectedStatus: HttpStatusCode.OK_200
256 })
257 }
258
259 getMyQuotaUsed (options: OverrideCommandOptions = {}) {
260 const path = '/api/v1/users/me/video-quota-used'
261
262 return this.getRequestBody<UserVideoQuota>({
263 ...options,
264
265 path,
266 implicitToken: true,
267 defaultExpectedStatus: HttpStatusCode.OK_200
268 })
269 }
270
271 getMyRating (options: OverrideCommandOptions & {
272 videoId: number | string
273 }) {
274 const { videoId } = options
275 const path = '/api/v1/users/me/videos/' + videoId + '/rating'
276
277 return this.getRequestBody<UserVideoRate>({
278 ...options,
279
280 path,
281 implicitToken: true,
282 defaultExpectedStatus: HttpStatusCode.OK_200
283 })
284 }
285
286 deleteMe (options: OverrideCommandOptions = {}) {
287 const path = '/api/v1/users/me'
288
289 return this.deleteRequest({
290 ...options,
291
292 path,
293 implicitToken: true,
294 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
295 })
296 }
297
298 updateMe (options: OverrideCommandOptions & UserUpdateMe) {
299 const path = '/api/v1/users/me'
300
301 const toSend: UserUpdateMe = omit(options, 'url', 'accessToken')
302
303 return this.putBodyRequest({
304 ...options,
305
306 path,
307 fields: toSend,
308 implicitToken: true,
309 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
310 })
311 }
312
313 updateMyAvatar (options: OverrideCommandOptions & {
314 fixture: string
315 }) {
316 const { fixture } = options
317 const path = '/api/v1/users/me/avatar/pick'
318
319 return this.updateImageRequest({
320 ...options,
321
322 path,
323 fixture,
324 fieldname: 'avatarfile',
325
326 implicitToken: true,
327 defaultExpectedStatus: HttpStatusCode.OK_200
328 })
329 }
330
331 // ---------------------------------------------------------------------------
332
333 get (options: OverrideCommandOptions & {
334 userId: number
335 withStats?: boolean // default false
336 }) {
337 const { userId, withStats } = options
338 const path = '/api/v1/users/' + userId
339
340 return this.getRequestBody<User>({
341 ...options,
342
343 path,
344 query: { withStats },
345 implicitToken: true,
346 defaultExpectedStatus: HttpStatusCode.OK_200
347 })
348 }
349
350 list (options: OverrideCommandOptions & {
351 start?: number
352 count?: number
353 sort?: string
354 search?: string
355 blocked?: boolean
356 } = {}) {
357 const path = '/api/v1/users'
358
359 return this.getRequestBody<ResultList<User>>({
360 ...options,
361
362 path,
363 query: pick(options, [ 'start', 'count', 'sort', 'search', 'blocked' ]),
364 implicitToken: true,
365 defaultExpectedStatus: HttpStatusCode.OK_200
366 })
367 }
368
369 remove (options: OverrideCommandOptions & {
370 userId: number
371 }) {
372 const { userId } = options
373 const path = '/api/v1/users/' + userId
374
375 return this.deleteRequest({
376 ...options,
377
378 path,
379 implicitToken: true,
380 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
381 })
382 }
383
384 update (options: OverrideCommandOptions & {
385 userId: number
386 email?: string
387 emailVerified?: boolean
388 videoQuota?: number
389 videoQuotaDaily?: number
390 password?: string
391 adminFlags?: UserAdminFlag
392 pluginAuth?: string
393 role?: UserRole
394 }) {
395 const path = '/api/v1/users/' + options.userId
396
397 const toSend: UserUpdate = {}
398 if (options.password !== undefined && options.password !== null) toSend.password = options.password
399 if (options.email !== undefined && options.email !== null) toSend.email = options.email
400 if (options.emailVerified !== undefined && options.emailVerified !== null) toSend.emailVerified = options.emailVerified
401 if (options.videoQuota !== undefined && options.videoQuota !== null) toSend.videoQuota = options.videoQuota
402 if (options.videoQuotaDaily !== undefined && options.videoQuotaDaily !== null) toSend.videoQuotaDaily = options.videoQuotaDaily
403 if (options.role !== undefined && options.role !== null) toSend.role = options.role
404 if (options.adminFlags !== undefined && options.adminFlags !== null) toSend.adminFlags = options.adminFlags
405 if (options.pluginAuth !== undefined) toSend.pluginAuth = options.pluginAuth
406
407 return this.putBodyRequest({
408 ...options,
409
410 path,
411 fields: toSend,
412 implicitToken: true,
413 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
414 })
415 }
416}
diff --git a/shared/server-commands/videos/blacklist-command.ts b/shared/server-commands/videos/blacklist-command.ts
new file mode 100644
index 000000000..47e23ebc8
--- /dev/null
+++ b/shared/server-commands/videos/blacklist-command.ts
@@ -0,0 +1,75 @@
1
2import { HttpStatusCode, ResultList, VideoBlacklist, VideoBlacklistType } from '@shared/models'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class BlacklistCommand extends AbstractCommand {
6
7 add (options: OverrideCommandOptions & {
8 videoId: number | string
9 reason?: string
10 unfederate?: boolean
11 }) {
12 const { videoId, reason, unfederate } = options
13 const path = '/api/v1/videos/' + videoId + '/blacklist'
14
15 return this.postBodyRequest({
16 ...options,
17
18 path,
19 fields: { reason, unfederate },
20 implicitToken: true,
21 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
22 })
23 }
24
25 update (options: OverrideCommandOptions & {
26 videoId: number | string
27 reason?: string
28 }) {
29 const { videoId, reason } = options
30 const path = '/api/v1/videos/' + videoId + '/blacklist'
31
32 return this.putBodyRequest({
33 ...options,
34
35 path,
36 fields: { reason },
37 implicitToken: true,
38 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
39 })
40 }
41
42 remove (options: OverrideCommandOptions & {
43 videoId: number | string
44 }) {
45 const { videoId } = options
46 const path = '/api/v1/videos/' + videoId + '/blacklist'
47
48 return this.deleteRequest({
49 ...options,
50
51 path,
52 implicitToken: true,
53 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
54 })
55 }
56
57 list (options: OverrideCommandOptions & {
58 sort?: string
59 type?: VideoBlacklistType
60 } = {}) {
61 const { sort, type } = options
62 const path = '/api/v1/videos/blacklist/'
63
64 const query = { sort, type }
65
66 return this.getRequestBody<ResultList<VideoBlacklist>>({
67 ...options,
68
69 path,
70 query,
71 implicitToken: true,
72 defaultExpectedStatus: HttpStatusCode.OK_200
73 })
74 }
75}
diff --git a/shared/server-commands/videos/captions-command.ts b/shared/server-commands/videos/captions-command.ts
new file mode 100644
index 000000000..62bf9c5e6
--- /dev/null
+++ b/shared/server-commands/videos/captions-command.ts
@@ -0,0 +1,65 @@
1import { buildAbsoluteFixturePath } from '@shared/core-utils'
2import { HttpStatusCode, ResultList, VideoCaption } from '@shared/models'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class CaptionsCommand extends AbstractCommand {
6
7 add (options: OverrideCommandOptions & {
8 videoId: string | number
9 language: string
10 fixture: string
11 mimeType?: string
12 }) {
13 const { videoId, language, fixture, mimeType } = options
14
15 const path = '/api/v1/videos/' + videoId + '/captions/' + language
16
17 const captionfile = buildAbsoluteFixturePath(fixture)
18 const captionfileAttach = mimeType
19 ? [ captionfile, { contentType: mimeType } ]
20 : captionfile
21
22 return this.putUploadRequest({
23 ...options,
24
25 path,
26 fields: {},
27 attaches: {
28 captionfile: captionfileAttach
29 },
30 implicitToken: true,
31 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
32 })
33 }
34
35 list (options: OverrideCommandOptions & {
36 videoId: string | number
37 }) {
38 const { videoId } = options
39 const path = '/api/v1/videos/' + videoId + '/captions'
40
41 return this.getRequestBody<ResultList<VideoCaption>>({
42 ...options,
43
44 path,
45 implicitToken: false,
46 defaultExpectedStatus: HttpStatusCode.OK_200
47 })
48 }
49
50 delete (options: OverrideCommandOptions & {
51 videoId: string | number
52 language: string
53 }) {
54 const { videoId, language } = options
55 const path = '/api/v1/videos/' + videoId + '/captions/' + language
56
57 return this.deleteRequest({
58 ...options,
59
60 path,
61 implicitToken: true,
62 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
63 })
64 }
65}
diff --git a/shared/server-commands/videos/change-ownership-command.ts b/shared/server-commands/videos/change-ownership-command.ts
new file mode 100644
index 000000000..ad4c726ef
--- /dev/null
+++ b/shared/server-commands/videos/change-ownership-command.ts
@@ -0,0 +1,68 @@
1
2import { HttpStatusCode, ResultList, VideoChangeOwnership } from '@shared/models'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class ChangeOwnershipCommand extends AbstractCommand {
6
7 create (options: OverrideCommandOptions & {
8 videoId: number | string
9 username: string
10 }) {
11 const { videoId, username } = options
12 const path = '/api/v1/videos/' + videoId + '/give-ownership'
13
14 return this.postBodyRequest({
15 ...options,
16
17 path,
18 fields: { username },
19 implicitToken: true,
20 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
21 })
22 }
23
24 list (options: OverrideCommandOptions = {}) {
25 const path = '/api/v1/videos/ownership'
26
27 return this.getRequestBody<ResultList<VideoChangeOwnership>>({
28 ...options,
29
30 path,
31 query: { sort: '-createdAt' },
32 implicitToken: true,
33 defaultExpectedStatus: HttpStatusCode.OK_200
34 })
35 }
36
37 accept (options: OverrideCommandOptions & {
38 ownershipId: number
39 channelId: number
40 }) {
41 const { ownershipId, channelId } = options
42 const path = '/api/v1/videos/ownership/' + ownershipId + '/accept'
43
44 return this.postBodyRequest({
45 ...options,
46
47 path,
48 fields: { channelId },
49 implicitToken: true,
50 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
51 })
52 }
53
54 refuse (options: OverrideCommandOptions & {
55 ownershipId: number
56 }) {
57 const { ownershipId } = options
58 const path = '/api/v1/videos/ownership/' + ownershipId + '/refuse'
59
60 return this.postBodyRequest({
61 ...options,
62
63 path,
64 implicitToken: true,
65 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
66 })
67 }
68}
diff --git a/shared/server-commands/videos/channels-command.ts b/shared/server-commands/videos/channels-command.ts
new file mode 100644
index 000000000..8ab124658
--- /dev/null
+++ b/shared/server-commands/videos/channels-command.ts
@@ -0,0 +1,184 @@
1import { pick } from '@shared/core-utils'
2import {
3 ActorFollow,
4 HttpStatusCode,
5 ResultList,
6 VideoChannel,
7 VideoChannelCreate,
8 VideoChannelCreateResult,
9 VideoChannelUpdate
10} from '@shared/models'
11import { unwrapBody } from '../requests'
12import { AbstractCommand, OverrideCommandOptions } from '../shared'
13
14export class ChannelsCommand extends AbstractCommand {
15
16 list (options: OverrideCommandOptions & {
17 start?: number
18 count?: number
19 sort?: string
20 withStats?: boolean
21 } = {}) {
22 const path = '/api/v1/video-channels'
23
24 return this.getRequestBody<ResultList<VideoChannel>>({
25 ...options,
26
27 path,
28 query: pick(options, [ 'start', 'count', 'sort', 'withStats' ]),
29 implicitToken: false,
30 defaultExpectedStatus: HttpStatusCode.OK_200
31 })
32 }
33
34 listByAccount (options: OverrideCommandOptions & {
35 accountName: string
36 start?: number
37 count?: number
38 sort?: string
39 withStats?: boolean
40 search?: string
41 }) {
42 const { accountName, sort = 'createdAt' } = options
43 const path = '/api/v1/accounts/' + accountName + '/video-channels'
44
45 return this.getRequestBody<ResultList<VideoChannel>>({
46 ...options,
47
48 path,
49 query: { sort, ...pick(options, [ 'start', 'count', 'withStats', 'search' ]) },
50 implicitToken: false,
51 defaultExpectedStatus: HttpStatusCode.OK_200
52 })
53 }
54
55 async create (options: OverrideCommandOptions & {
56 attributes: Partial<VideoChannelCreate>
57 }) {
58 const path = '/api/v1/video-channels/'
59
60 // Default attributes
61 const defaultAttributes = {
62 displayName: 'my super video channel',
63 description: 'my super channel description',
64 support: 'my super channel support'
65 }
66 const attributes = { ...defaultAttributes, ...options.attributes }
67
68 const body = await unwrapBody<{ videoChannel: VideoChannelCreateResult }>(this.postBodyRequest({
69 ...options,
70
71 path,
72 fields: attributes,
73 implicitToken: true,
74 defaultExpectedStatus: HttpStatusCode.OK_200
75 }))
76
77 return body.videoChannel
78 }
79
80 update (options: OverrideCommandOptions & {
81 channelName: string
82 attributes: VideoChannelUpdate
83 }) {
84 const { channelName, attributes } = options
85 const path = '/api/v1/video-channels/' + channelName
86
87 return this.putBodyRequest({
88 ...options,
89
90 path,
91 fields: attributes,
92 implicitToken: true,
93 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
94 })
95 }
96
97 delete (options: OverrideCommandOptions & {
98 channelName: string
99 }) {
100 const path = '/api/v1/video-channels/' + options.channelName
101
102 return this.deleteRequest({
103 ...options,
104
105 path,
106 implicitToken: true,
107 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
108 })
109 }
110
111 get (options: OverrideCommandOptions & {
112 channelName: string
113 }) {
114 const path = '/api/v1/video-channels/' + options.channelName
115
116 return this.getRequestBody<VideoChannel>({
117 ...options,
118
119 path,
120 implicitToken: false,
121 defaultExpectedStatus: HttpStatusCode.OK_200
122 })
123 }
124
125 updateImage (options: OverrideCommandOptions & {
126 fixture: string
127 channelName: string | number
128 type: 'avatar' | 'banner'
129 }) {
130 const { channelName, fixture, type } = options
131
132 const path = `/api/v1/video-channels/${channelName}/${type}/pick`
133
134 return this.updateImageRequest({
135 ...options,
136
137 path,
138 fixture,
139 fieldname: type + 'file',
140
141 implicitToken: true,
142 defaultExpectedStatus: HttpStatusCode.OK_200
143 })
144 }
145
146 deleteImage (options: OverrideCommandOptions & {
147 channelName: string | number
148 type: 'avatar' | 'banner'
149 }) {
150 const { channelName, type } = options
151
152 const path = `/api/v1/video-channels/${channelName}/${type}`
153
154 return this.deleteRequest({
155 ...options,
156
157 path,
158 implicitToken: true,
159 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
160 })
161 }
162
163 listFollowers (options: OverrideCommandOptions & {
164 channelName: string
165 start?: number
166 count?: number
167 sort?: string
168 search?: string
169 }) {
170 const { channelName, start, count, sort, search } = options
171 const path = '/api/v1/video-channels/' + channelName + '/followers'
172
173 const query = { start, count, sort, search }
174
175 return this.getRequestBody<ResultList<ActorFollow>>({
176 ...options,
177
178 path,
179 query,
180 implicitToken: true,
181 defaultExpectedStatus: HttpStatusCode.OK_200
182 })
183 }
184}
diff --git a/shared/server-commands/videos/channels.ts b/shared/server-commands/videos/channels.ts
new file mode 100644
index 000000000..756c47453
--- /dev/null
+++ b/shared/server-commands/videos/channels.ts
@@ -0,0 +1,18 @@
1import { PeerTubeServer } from '../server/server'
2
3function setDefaultVideoChannel (servers: PeerTubeServer[]) {
4 const tasks: Promise<any>[] = []
5
6 for (const server of servers) {
7 const p = server.users.getMyInfo()
8 .then(user => { server.store.channel = user.videoChannels[0] })
9
10 tasks.push(p)
11 }
12
13 return Promise.all(tasks)
14}
15
16export {
17 setDefaultVideoChannel
18}
diff --git a/shared/server-commands/videos/comments-command.ts b/shared/server-commands/videos/comments-command.ts
new file mode 100644
index 000000000..f0d163a07
--- /dev/null
+++ b/shared/server-commands/videos/comments-command.ts
@@ -0,0 +1,152 @@
1import { pick } from 'lodash'
2import { HttpStatusCode, ResultList, VideoComment, VideoCommentThreads, VideoCommentThreadTree } from '@shared/models'
3import { unwrapBody } from '../requests'
4import { AbstractCommand, OverrideCommandOptions } from '../shared'
5
6export class CommentsCommand extends AbstractCommand {
7
8 private lastVideoId: number | string
9 private lastThreadId: number
10 private lastReplyId: number
11
12 listForAdmin (options: OverrideCommandOptions & {
13 start?: number
14 count?: number
15 sort?: string
16 isLocal?: boolean
17 search?: string
18 searchAccount?: string
19 searchVideo?: string
20 } = {}) {
21 const { sort = '-createdAt' } = options
22 const path = '/api/v1/videos/comments'
23
24 const query = { sort, ...pick(options, [ 'start', 'count', 'isLocal', 'search', 'searchAccount', 'searchVideo' ]) }
25
26 return this.getRequestBody<ResultList<VideoComment>>({
27 ...options,
28
29 path,
30 query,
31 implicitToken: true,
32 defaultExpectedStatus: HttpStatusCode.OK_200
33 })
34 }
35
36 listThreads (options: OverrideCommandOptions & {
37 videoId: number | string
38 start?: number
39 count?: number
40 sort?: string
41 }) {
42 const { start, count, sort, videoId } = options
43 const path = '/api/v1/videos/' + videoId + '/comment-threads'
44
45 return this.getRequestBody<VideoCommentThreads>({
46 ...options,
47
48 path,
49 query: { start, count, sort },
50 implicitToken: false,
51 defaultExpectedStatus: HttpStatusCode.OK_200
52 })
53 }
54
55 getThread (options: OverrideCommandOptions & {
56 videoId: number | string
57 threadId: number
58 }) {
59 const { videoId, threadId } = options
60 const path = '/api/v1/videos/' + videoId + '/comment-threads/' + threadId
61
62 return this.getRequestBody<VideoCommentThreadTree>({
63 ...options,
64
65 path,
66 implicitToken: false,
67 defaultExpectedStatus: HttpStatusCode.OK_200
68 })
69 }
70
71 async createThread (options: OverrideCommandOptions & {
72 videoId: number | string
73 text: string
74 }) {
75 const { videoId, text } = options
76 const path = '/api/v1/videos/' + videoId + '/comment-threads'
77
78 const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({
79 ...options,
80
81 path,
82 fields: { text },
83 implicitToken: true,
84 defaultExpectedStatus: HttpStatusCode.OK_200
85 }))
86
87 this.lastThreadId = body.comment?.id
88 this.lastVideoId = videoId
89
90 return body.comment
91 }
92
93 async addReply (options: OverrideCommandOptions & {
94 videoId: number | string
95 toCommentId: number
96 text: string
97 }) {
98 const { videoId, toCommentId, text } = options
99 const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId
100
101 const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({
102 ...options,
103
104 path,
105 fields: { text },
106 implicitToken: true,
107 defaultExpectedStatus: HttpStatusCode.OK_200
108 }))
109
110 this.lastReplyId = body.comment?.id
111
112 return body.comment
113 }
114
115 async addReplyToLastReply (options: OverrideCommandOptions & {
116 text: string
117 }) {
118 return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastReplyId })
119 }
120
121 async addReplyToLastThread (options: OverrideCommandOptions & {
122 text: string
123 }) {
124 return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastThreadId })
125 }
126
127 async findCommentId (options: OverrideCommandOptions & {
128 videoId: number | string
129 text: string
130 }) {
131 const { videoId, text } = options
132 const { data } = await this.listThreads({ videoId, count: 25, sort: '-createdAt' })
133
134 return data.find(c => c.text === text).id
135 }
136
137 delete (options: OverrideCommandOptions & {
138 videoId: number | string
139 commentId: number
140 }) {
141 const { videoId, commentId } = options
142 const path = '/api/v1/videos/' + videoId + '/comments/' + commentId
143
144 return this.deleteRequest({
145 ...options,
146
147 path,
148 implicitToken: true,
149 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
150 })
151 }
152}
diff --git a/shared/server-commands/videos/history-command.ts b/shared/server-commands/videos/history-command.ts
new file mode 100644
index 000000000..13b7150c1
--- /dev/null
+++ b/shared/server-commands/videos/history-command.ts
@@ -0,0 +1,58 @@
1import { HttpStatusCode, ResultList, Video } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class HistoryCommand extends AbstractCommand {
5
6 wathVideo (options: OverrideCommandOptions & {
7 videoId: number | string
8 currentTime: number
9 }) {
10 const { videoId, currentTime } = options
11
12 const path = '/api/v1/videos/' + videoId + '/watching'
13 const fields = { currentTime }
14
15 return this.putBodyRequest({
16 ...options,
17
18 path,
19 fields,
20 implicitToken: true,
21 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
22 })
23 }
24
25 list (options: OverrideCommandOptions & {
26 search?: string
27 } = {}) {
28 const { search } = options
29 const path = '/api/v1/users/me/history/videos'
30
31 return this.getRequestBody<ResultList<Video>>({
32 ...options,
33
34 path,
35 query: {
36 search
37 },
38 implicitToken: true,
39 defaultExpectedStatus: HttpStatusCode.OK_200
40 })
41 }
42
43 remove (options: OverrideCommandOptions & {
44 beforeDate?: string
45 } = {}) {
46 const { beforeDate } = options
47 const path = '/api/v1/users/me/history/videos/remove'
48
49 return this.postBodyRequest({
50 ...options,
51
52 path,
53 fields: { beforeDate },
54 implicitToken: true,
55 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
56 })
57 }
58}
diff --git a/shared/server-commands/videos/imports-command.ts b/shared/server-commands/videos/imports-command.ts
new file mode 100644
index 000000000..e4944694d
--- /dev/null
+++ b/shared/server-commands/videos/imports-command.ts
@@ -0,0 +1,47 @@
1
2import { HttpStatusCode, ResultList } from '@shared/models'
3import { VideoImport, VideoImportCreate } from '../../models/videos'
4import { unwrapBody } from '../requests'
5import { AbstractCommand, OverrideCommandOptions } from '../shared'
6
7export class ImportsCommand extends AbstractCommand {
8
9 importVideo (options: OverrideCommandOptions & {
10 attributes: VideoImportCreate & { torrentfile?: string }
11 }) {
12 const { attributes } = options
13 const path = '/api/v1/videos/imports'
14
15 let attaches: any = {}
16 if (attributes.torrentfile) attaches = { torrentfile: attributes.torrentfile }
17
18 return unwrapBody<VideoImport>(this.postUploadRequest({
19 ...options,
20
21 path,
22 attaches,
23 fields: options.attributes,
24 implicitToken: true,
25 defaultExpectedStatus: HttpStatusCode.OK_200
26 }))
27 }
28
29 getMyVideoImports (options: OverrideCommandOptions & {
30 sort?: string
31 } = {}) {
32 const { sort } = options
33 const path = '/api/v1/users/me/videos/imports'
34
35 const query = {}
36 if (sort) query['sort'] = sort
37
38 return this.getRequestBody<ResultList<VideoImport>>({
39 ...options,
40
41 path,
42 query: { sort },
43 implicitToken: true,
44 defaultExpectedStatus: HttpStatusCode.OK_200
45 })
46 }
47}
diff --git a/shared/server-commands/videos/index.ts b/shared/server-commands/videos/index.ts
new file mode 100644
index 000000000..68a188b21
--- /dev/null
+++ b/shared/server-commands/videos/index.ts
@@ -0,0 +1,15 @@
1export * from './blacklist-command'
2export * from './captions-command'
3export * from './change-ownership-command'
4export * from './channels'
5export * from './channels-command'
6export * from './comments-command'
7export * from './history-command'
8export * from './imports-command'
9export * from './live-command'
10export * from './live'
11export * from './playlists-command'
12export * from './services-command'
13export * from './streaming-playlists-command'
14export * from './comments-command'
15export * from './videos-command'
diff --git a/shared/server-commands/videos/live-command.ts b/shared/server-commands/videos/live-command.ts
new file mode 100644
index 000000000..f7816eca0
--- /dev/null
+++ b/shared/server-commands/videos/live-command.ts
@@ -0,0 +1,155 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { readdir } from 'fs-extra'
4import { omit } from 'lodash'
5import { join } from 'path'
6import { wait } from '@shared/core-utils'
7import { HttpStatusCode, LiveVideo, LiveVideoCreate, LiveVideoUpdate, VideoCreateResult, VideoDetails, VideoState } from '@shared/models'
8import { unwrapBody } from '../requests'
9import { AbstractCommand, OverrideCommandOptions } from '../shared'
10import { sendRTMPStream, testFfmpegStreamError } from './live'
11
12export class LiveCommand extends AbstractCommand {
13
14 get (options: OverrideCommandOptions & {
15 videoId: number | string
16 }) {
17 const path = '/api/v1/videos/live'
18
19 return this.getRequestBody<LiveVideo>({
20 ...options,
21
22 path: path + '/' + options.videoId,
23 implicitToken: true,
24 defaultExpectedStatus: HttpStatusCode.OK_200
25 })
26 }
27
28 update (options: OverrideCommandOptions & {
29 videoId: number | string
30 fields: LiveVideoUpdate
31 }) {
32 const { videoId, fields } = options
33 const path = '/api/v1/videos/live'
34
35 return this.putBodyRequest({
36 ...options,
37
38 path: path + '/' + videoId,
39 fields,
40 implicitToken: true,
41 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
42 })
43 }
44
45 async create (options: OverrideCommandOptions & {
46 fields: LiveVideoCreate
47 }) {
48 const { fields } = options
49 const path = '/api/v1/videos/live'
50
51 const attaches: any = {}
52 if (fields.thumbnailfile) attaches.thumbnailfile = fields.thumbnailfile
53 if (fields.previewfile) attaches.previewfile = fields.previewfile
54
55 const body = await unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({
56 ...options,
57
58 path,
59 attaches,
60 fields: omit(fields, 'thumbnailfile', 'previewfile'),
61 implicitToken: true,
62 defaultExpectedStatus: HttpStatusCode.OK_200
63 }))
64
65 return body.video
66 }
67
68 async sendRTMPStreamInVideo (options: OverrideCommandOptions & {
69 videoId: number | string
70 fixtureName?: string
71 copyCodecs?: boolean
72 }) {
73 const { videoId, fixtureName, copyCodecs } = options
74 const videoLive = await this.get({ videoId })
75
76 return sendRTMPStream({ rtmpBaseUrl: videoLive.rtmpUrl, streamKey: videoLive.streamKey, fixtureName, copyCodecs })
77 }
78
79 async runAndTestStreamError (options: OverrideCommandOptions & {
80 videoId: number | string
81 shouldHaveError: boolean
82 }) {
83 const command = await this.sendRTMPStreamInVideo(options)
84
85 return testFfmpegStreamError(command, options.shouldHaveError)
86 }
87
88 waitUntilPublished (options: OverrideCommandOptions & {
89 videoId: number | string
90 }) {
91 const { videoId } = options
92 return this.waitUntilState({ videoId, state: VideoState.PUBLISHED })
93 }
94
95 waitUntilWaiting (options: OverrideCommandOptions & {
96 videoId: number | string
97 }) {
98 const { videoId } = options
99 return this.waitUntilState({ videoId, state: VideoState.WAITING_FOR_LIVE })
100 }
101
102 waitUntilEnded (options: OverrideCommandOptions & {
103 videoId: number | string
104 }) {
105 const { videoId } = options
106 return this.waitUntilState({ videoId, state: VideoState.LIVE_ENDED })
107 }
108
109 waitUntilSegmentGeneration (options: OverrideCommandOptions & {
110 videoUUID: string
111 resolution: number
112 segment: number
113 }) {
114 const { resolution, segment, videoUUID } = options
115 const segmentName = `${resolution}-00000${segment}.ts`
116
117 return this.server.servers.waitUntilLog(`${videoUUID}/${segmentName}`, 2, false)
118 }
119
120 async waitUntilSaved (options: OverrideCommandOptions & {
121 videoId: number | string
122 }) {
123 let video: VideoDetails
124
125 do {
126 video = await this.server.videos.getWithToken({ token: options.token, id: options.videoId })
127
128 await wait(500)
129 } while (video.isLive === true || video.state.id !== VideoState.PUBLISHED)
130 }
131
132 async countPlaylists (options: OverrideCommandOptions & {
133 videoUUID: string
134 }) {
135 const basePath = this.server.servers.buildDirectory('streaming-playlists')
136 const hlsPath = join(basePath, 'hls', options.videoUUID)
137
138 const files = await readdir(hlsPath)
139
140 return files.filter(f => f.endsWith('.m3u8')).length
141 }
142
143 private async waitUntilState (options: OverrideCommandOptions & {
144 videoId: number | string
145 state: VideoState
146 }) {
147 let video: VideoDetails
148
149 do {
150 video = await this.server.videos.getWithToken({ token: options.token, id: options.videoId })
151
152 await wait(500)
153 } while (video.state.id !== options.state)
154 }
155}
diff --git a/shared/server-commands/videos/live.ts b/shared/server-commands/videos/live.ts
new file mode 100644
index 000000000..7a7faa911
--- /dev/null
+++ b/shared/server-commands/videos/live.ts
@@ -0,0 +1,100 @@
1import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
2import { buildAbsoluteFixturePath, wait } from '@shared/core-utils'
3import { PeerTubeServer } from '../server/server'
4
5function sendRTMPStream (options: {
6 rtmpBaseUrl: string
7 streamKey: string
8 fixtureName?: string // default video_short.mp4
9 copyCodecs?: boolean // default false
10}) {
11 const { rtmpBaseUrl, streamKey, fixtureName = 'video_short.mp4', copyCodecs = false } = options
12
13 const fixture = buildAbsoluteFixturePath(fixtureName)
14
15 const command = ffmpeg(fixture)
16 command.inputOption('-stream_loop -1')
17 command.inputOption('-re')
18
19 if (copyCodecs) {
20 command.outputOption('-c copy')
21 } else {
22 command.outputOption('-c:v libx264')
23 command.outputOption('-g 50')
24 command.outputOption('-keyint_min 2')
25 command.outputOption('-r 60')
26 }
27
28 command.outputOption('-f flv')
29
30 const rtmpUrl = rtmpBaseUrl + '/' + streamKey
31 command.output(rtmpUrl)
32
33 command.on('error', err => {
34 if (err?.message?.includes('Exiting normally')) return
35
36 if (process.env.DEBUG) console.error(err)
37 })
38
39 if (process.env.DEBUG) {
40 command.on('stderr', data => console.log(data))
41 }
42
43 command.run()
44
45 return command
46}
47
48function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) {
49 return new Promise<void>((res, rej) => {
50 command.on('error', err => {
51 return rej(err)
52 })
53
54 setTimeout(() => {
55 res()
56 }, successAfterMS)
57 })
58}
59
60async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: boolean) {
61 let error: Error
62
63 try {
64 await waitFfmpegUntilError(command, 35000)
65 } catch (err) {
66 error = err
67 }
68
69 await stopFfmpeg(command)
70
71 if (shouldHaveError && !error) throw new Error('Ffmpeg did not have an error')
72 if (!shouldHaveError && error) throw error
73}
74
75async function stopFfmpeg (command: FfmpegCommand) {
76 command.kill('SIGINT')
77
78 await wait(500)
79}
80
81async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) {
82 for (const server of servers) {
83 await server.live.waitUntilPublished({ videoId })
84 }
85}
86
87async function waitUntilLiveSavedOnAllServers (servers: PeerTubeServer[], videoId: string) {
88 for (const server of servers) {
89 await server.live.waitUntilSaved({ videoId })
90 }
91}
92
93export {
94 sendRTMPStream,
95 waitFfmpegUntilError,
96 testFfmpegStreamError,
97 stopFfmpeg,
98 waitUntilLivePublishedOnAllServers,
99 waitUntilLiveSavedOnAllServers
100}
diff --git a/shared/server-commands/videos/playlists-command.ts b/shared/server-commands/videos/playlists-command.ts
new file mode 100644
index 000000000..ce23900d3
--- /dev/null
+++ b/shared/server-commands/videos/playlists-command.ts
@@ -0,0 +1,280 @@
1import { omit } from 'lodash'
2import { pick } from '@shared/core-utils'
3import {
4 BooleanBothQuery,
5 HttpStatusCode,
6 ResultList,
7 VideoExistInPlaylist,
8 VideoPlaylist,
9 VideoPlaylistCreate,
10 VideoPlaylistCreateResult,
11 VideoPlaylistElement,
12 VideoPlaylistElementCreate,
13 VideoPlaylistElementCreateResult,
14 VideoPlaylistElementUpdate,
15 VideoPlaylistReorder,
16 VideoPlaylistType,
17 VideoPlaylistUpdate
18} from '@shared/models'
19import { unwrapBody } from '../requests'
20import { AbstractCommand, OverrideCommandOptions } from '../shared'
21
22export class PlaylistsCommand extends AbstractCommand {
23
24 list (options: OverrideCommandOptions & {
25 start?: number
26 count?: number
27 sort?: string
28 }) {
29 const path = '/api/v1/video-playlists'
30 const query = pick(options, [ 'start', 'count', 'sort' ])
31
32 return this.getRequestBody<ResultList<VideoPlaylist>>({
33 ...options,
34
35 path,
36 query,
37 implicitToken: false,
38 defaultExpectedStatus: HttpStatusCode.OK_200
39 })
40 }
41
42 listByChannel (options: OverrideCommandOptions & {
43 handle: string
44 start?: number
45 count?: number
46 sort?: string
47 }) {
48 const path = '/api/v1/video-channels/' + options.handle + '/video-playlists'
49 const query = pick(options, [ 'start', 'count', 'sort' ])
50
51 return this.getRequestBody<ResultList<VideoPlaylist>>({
52 ...options,
53
54 path,
55 query,
56 implicitToken: false,
57 defaultExpectedStatus: HttpStatusCode.OK_200
58 })
59 }
60
61 listByAccount (options: OverrideCommandOptions & {
62 handle: string
63 start?: number
64 count?: number
65 sort?: string
66 search?: string
67 playlistType?: VideoPlaylistType
68 }) {
69 const path = '/api/v1/accounts/' + options.handle + '/video-playlists'
70 const query = pick(options, [ 'start', 'count', 'sort', 'search', 'playlistType' ])
71
72 return this.getRequestBody<ResultList<VideoPlaylist>>({
73 ...options,
74
75 path,
76 query,
77 implicitToken: false,
78 defaultExpectedStatus: HttpStatusCode.OK_200
79 })
80 }
81
82 get (options: OverrideCommandOptions & {
83 playlistId: number | string
84 }) {
85 const { playlistId } = options
86 const path = '/api/v1/video-playlists/' + playlistId
87
88 return this.getRequestBody<VideoPlaylist>({
89 ...options,
90
91 path,
92 implicitToken: false,
93 defaultExpectedStatus: HttpStatusCode.OK_200
94 })
95 }
96
97 listVideos (options: OverrideCommandOptions & {
98 playlistId: number | string
99 start?: number
100 count?: number
101 query?: { nsfw?: BooleanBothQuery }
102 }) {
103 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos'
104 const query = options.query ?? {}
105
106 return this.getRequestBody<ResultList<VideoPlaylistElement>>({
107 ...options,
108
109 path,
110 query: {
111 ...query,
112 start: options.start,
113 count: options.count
114 },
115 implicitToken: true,
116 defaultExpectedStatus: HttpStatusCode.OK_200
117 })
118 }
119
120 delete (options: OverrideCommandOptions & {
121 playlistId: number | string
122 }) {
123 const path = '/api/v1/video-playlists/' + options.playlistId
124
125 return this.deleteRequest({
126 ...options,
127
128 path,
129 implicitToken: true,
130 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
131 })
132 }
133
134 async create (options: OverrideCommandOptions & {
135 attributes: VideoPlaylistCreate
136 }) {
137 const path = '/api/v1/video-playlists'
138
139 const fields = omit(options.attributes, 'thumbnailfile')
140
141 const attaches = options.attributes.thumbnailfile
142 ? { thumbnailfile: options.attributes.thumbnailfile }
143 : {}
144
145 const body = await unwrapBody<{ videoPlaylist: VideoPlaylistCreateResult }>(this.postUploadRequest({
146 ...options,
147
148 path,
149 fields,
150 attaches,
151 implicitToken: true,
152 defaultExpectedStatus: HttpStatusCode.OK_200
153 }))
154
155 return body.videoPlaylist
156 }
157
158 update (options: OverrideCommandOptions & {
159 attributes: VideoPlaylistUpdate
160 playlistId: number | string
161 }) {
162 const path = '/api/v1/video-playlists/' + options.playlistId
163
164 const fields = omit(options.attributes, 'thumbnailfile')
165
166 const attaches = options.attributes.thumbnailfile
167 ? { thumbnailfile: options.attributes.thumbnailfile }
168 : {}
169
170 return this.putUploadRequest({
171 ...options,
172
173 path,
174 fields,
175 attaches,
176 implicitToken: true,
177 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
178 })
179 }
180
181 async addElement (options: OverrideCommandOptions & {
182 playlistId: number | string
183 attributes: VideoPlaylistElementCreate | { videoId: string }
184 }) {
185 const attributes = {
186 ...options.attributes,
187
188 videoId: await this.server.videos.getId({ ...options, uuid: options.attributes.videoId })
189 }
190
191 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos'
192
193 const body = await unwrapBody<{ videoPlaylistElement: VideoPlaylistElementCreateResult }>(this.postBodyRequest({
194 ...options,
195
196 path,
197 fields: attributes,
198 implicitToken: true,
199 defaultExpectedStatus: HttpStatusCode.OK_200
200 }))
201
202 return body.videoPlaylistElement
203 }
204
205 updateElement (options: OverrideCommandOptions & {
206 playlistId: number | string
207 elementId: number | string
208 attributes: VideoPlaylistElementUpdate
209 }) {
210 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId
211
212 return this.putBodyRequest({
213 ...options,
214
215 path,
216 fields: options.attributes,
217 implicitToken: true,
218 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
219 })
220 }
221
222 removeElement (options: OverrideCommandOptions & {
223 playlistId: number | string
224 elementId: number
225 }) {
226 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId
227
228 return this.deleteRequest({
229 ...options,
230
231 path,
232 implicitToken: true,
233 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
234 })
235 }
236
237 reorderElements (options: OverrideCommandOptions & {
238 playlistId: number | string
239 attributes: VideoPlaylistReorder
240 }) {
241 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/reorder'
242
243 return this.postBodyRequest({
244 ...options,
245
246 path,
247 fields: options.attributes,
248 implicitToken: true,
249 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
250 })
251 }
252
253 getPrivacies (options: OverrideCommandOptions = {}) {
254 const path = '/api/v1/video-playlists/privacies'
255
256 return this.getRequestBody<{ [ id: number ]: string }>({
257 ...options,
258
259 path,
260 implicitToken: false,
261 defaultExpectedStatus: HttpStatusCode.OK_200
262 })
263 }
264
265 videosExist (options: OverrideCommandOptions & {
266 videoIds: number[]
267 }) {
268 const { videoIds } = options
269 const path = '/api/v1/users/me/video-playlists/videos-exist'
270
271 return this.getRequestBody<VideoExistInPlaylist>({
272 ...options,
273
274 path,
275 query: { videoIds },
276 implicitToken: true,
277 defaultExpectedStatus: HttpStatusCode.OK_200
278 })
279 }
280}
diff --git a/shared/server-commands/videos/services-command.ts b/shared/server-commands/videos/services-command.ts
new file mode 100644
index 000000000..06760df42
--- /dev/null
+++ b/shared/server-commands/videos/services-command.ts
@@ -0,0 +1,29 @@
1import { HttpStatusCode } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class ServicesCommand extends AbstractCommand {
5
6 getOEmbed (options: OverrideCommandOptions & {
7 oembedUrl: string
8 format?: string
9 maxHeight?: number
10 maxWidth?: number
11 }) {
12 const path = '/services/oembed'
13 const query = {
14 url: options.oembedUrl,
15 format: options.format,
16 maxheight: options.maxHeight,
17 maxwidth: options.maxWidth
18 }
19
20 return this.getRequest({
21 ...options,
22
23 path,
24 query,
25 implicitToken: false,
26 defaultExpectedStatus: HttpStatusCode.OK_200
27 })
28 }
29}
diff --git a/shared/server-commands/videos/streaming-playlists-command.ts b/shared/server-commands/videos/streaming-playlists-command.ts
new file mode 100644
index 000000000..5d40d35cb
--- /dev/null
+++ b/shared/server-commands/videos/streaming-playlists-command.ts
@@ -0,0 +1,44 @@
1import { HttpStatusCode } from '@shared/models'
2import { unwrapBody, unwrapTextOrDecode, unwrapBodyOrDecodeToJSON } from '../requests'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class StreamingPlaylistsCommand extends AbstractCommand {
6
7 get (options: OverrideCommandOptions & {
8 url: string
9 }) {
10 return unwrapTextOrDecode(this.getRawRequest({
11 ...options,
12
13 url: options.url,
14 implicitToken: false,
15 defaultExpectedStatus: HttpStatusCode.OK_200
16 }))
17 }
18
19 getSegment (options: OverrideCommandOptions & {
20 url: string
21 range?: string
22 }) {
23 return unwrapBody<Buffer>(this.getRawRequest({
24 ...options,
25
26 url: options.url,
27 range: options.range,
28 implicitToken: false,
29 defaultExpectedStatus: HttpStatusCode.OK_200
30 }))
31 }
32
33 getSegmentSha256 (options: OverrideCommandOptions & {
34 url: string
35 }) {
36 return unwrapBodyOrDecodeToJSON<{ [ id: string ]: string }>(this.getRawRequest({
37 ...options,
38
39 url: options.url,
40 implicitToken: false,
41 defaultExpectedStatus: HttpStatusCode.OK_200
42 }))
43 }
44}
diff --git a/shared/server-commands/videos/videos-command.ts b/shared/server-commands/videos/videos-command.ts
new file mode 100644
index 000000000..21753ddc4
--- /dev/null
+++ b/shared/server-commands/videos/videos-command.ts
@@ -0,0 +1,678 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
2
3import { expect } from 'chai'
4import { createReadStream, stat } from 'fs-extra'
5import got, { Response as GotResponse } from 'got'
6import { omit } from 'lodash'
7import validator from 'validator'
8import { buildAbsoluteFixturePath, pick, wait } from '@shared/core-utils'
9import { buildUUID } from '@shared/extra-utils'
10import {
11 HttpStatusCode,
12 ResultList,
13 UserVideoRateType,
14 Video,
15 VideoCreate,
16 VideoCreateResult,
17 VideoDetails,
18 VideoFileMetadata,
19 VideoPrivacy,
20 VideosCommonQuery,
21 VideoTranscodingCreate
22} from '@shared/models'
23import { unwrapBody } from '../requests'
24import { waitJobs } from '../server'
25import { AbstractCommand, OverrideCommandOptions } from '../shared'
26
27export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile'>> & {
28 fixture?: string
29 thumbnailfile?: string
30 previewfile?: string
31}
32
33export class VideosCommand extends AbstractCommand {
34 getCategories (options: OverrideCommandOptions = {}) {
35 const path = '/api/v1/videos/categories'
36
37 return this.getRequestBody<{ [id: number]: string }>({
38 ...options,
39 path,
40
41 implicitToken: false,
42 defaultExpectedStatus: HttpStatusCode.OK_200
43 })
44 }
45
46 getLicences (options: OverrideCommandOptions = {}) {
47 const path = '/api/v1/videos/licences'
48
49 return this.getRequestBody<{ [id: number]: string }>({
50 ...options,
51 path,
52
53 implicitToken: false,
54 defaultExpectedStatus: HttpStatusCode.OK_200
55 })
56 }
57
58 getLanguages (options: OverrideCommandOptions = {}) {
59 const path = '/api/v1/videos/languages'
60
61 return this.getRequestBody<{ [id: string]: string }>({
62 ...options,
63 path,
64
65 implicitToken: false,
66 defaultExpectedStatus: HttpStatusCode.OK_200
67 })
68 }
69
70 getPrivacies (options: OverrideCommandOptions = {}) {
71 const path = '/api/v1/videos/privacies'
72
73 return this.getRequestBody<{ [id in VideoPrivacy]: string }>({
74 ...options,
75 path,
76
77 implicitToken: false,
78 defaultExpectedStatus: HttpStatusCode.OK_200
79 })
80 }
81
82 // ---------------------------------------------------------------------------
83
84 getDescription (options: OverrideCommandOptions & {
85 descriptionPath: string
86 }) {
87 return this.getRequestBody<{ description: string }>({
88 ...options,
89 path: options.descriptionPath,
90
91 implicitToken: false,
92 defaultExpectedStatus: HttpStatusCode.OK_200
93 })
94 }
95
96 getFileMetadata (options: OverrideCommandOptions & {
97 url: string
98 }) {
99 return unwrapBody<VideoFileMetadata>(this.getRawRequest({
100 ...options,
101
102 url: options.url,
103 implicitToken: false,
104 defaultExpectedStatus: HttpStatusCode.OK_200
105 }))
106 }
107
108 // ---------------------------------------------------------------------------
109
110 view (options: OverrideCommandOptions & {
111 id: number | string
112 xForwardedFor?: string
113 }) {
114 const { id, xForwardedFor } = options
115 const path = '/api/v1/videos/' + id + '/views'
116
117 return this.postBodyRequest({
118 ...options,
119
120 path,
121 xForwardedFor,
122 implicitToken: false,
123 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
124 })
125 }
126
127 rate (options: OverrideCommandOptions & {
128 id: number | string
129 rating: UserVideoRateType
130 }) {
131 const { id, rating } = options
132 const path = '/api/v1/videos/' + id + '/rate'
133
134 return this.putBodyRequest({
135 ...options,
136
137 path,
138 fields: { rating },
139 implicitToken: true,
140 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
141 })
142 }
143
144 // ---------------------------------------------------------------------------
145
146 get (options: OverrideCommandOptions & {
147 id: number | string
148 }) {
149 const path = '/api/v1/videos/' + options.id
150
151 return this.getRequestBody<VideoDetails>({
152 ...options,
153
154 path,
155 implicitToken: false,
156 defaultExpectedStatus: HttpStatusCode.OK_200
157 })
158 }
159
160 getWithToken (options: OverrideCommandOptions & {
161 id: number | string
162 }) {
163 return this.get({
164 ...options,
165
166 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
167 })
168 }
169
170 async getId (options: OverrideCommandOptions & {
171 uuid: number | string
172 }) {
173 const { uuid } = options
174
175 if (validator.isUUID('' + uuid) === false) return uuid as number
176
177 const { id } = await this.get({ ...options, id: uuid })
178
179 return id
180 }
181
182 async listFiles (options: OverrideCommandOptions & {
183 id: number | string
184 }) {
185 const video = await this.get(options)
186
187 const files = video.files || []
188 const hlsFiles = video.streamingPlaylists[0]?.files || []
189
190 return files.concat(hlsFiles)
191 }
192
193 // ---------------------------------------------------------------------------
194
195 listMyVideos (options: OverrideCommandOptions & {
196 start?: number
197 count?: number
198 sort?: string
199 search?: string
200 isLive?: boolean
201 channelId?: number
202 } = {}) {
203 const path = '/api/v1/users/me/videos'
204
205 return this.getRequestBody<ResultList<Video>>({
206 ...options,
207
208 path,
209 query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]),
210 implicitToken: true,
211 defaultExpectedStatus: HttpStatusCode.OK_200
212 })
213 }
214
215 // ---------------------------------------------------------------------------
216
217 list (options: OverrideCommandOptions & VideosCommonQuery = {}) {
218 const path = '/api/v1/videos'
219
220 const query = this.buildListQuery(options)
221
222 return this.getRequestBody<ResultList<Video>>({
223 ...options,
224
225 path,
226 query: { sort: 'name', ...query },
227 implicitToken: false,
228 defaultExpectedStatus: HttpStatusCode.OK_200
229 })
230 }
231
232 listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) {
233 return this.list({
234 ...options,
235
236 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
237 })
238 }
239
240 listByAccount (options: OverrideCommandOptions & VideosCommonQuery & {
241 handle: string
242 }) {
243 const { handle, search } = options
244 const path = '/api/v1/accounts/' + handle + '/videos'
245
246 return this.getRequestBody<ResultList<Video>>({
247 ...options,
248
249 path,
250 query: { search, ...this.buildListQuery(options) },
251 implicitToken: true,
252 defaultExpectedStatus: HttpStatusCode.OK_200
253 })
254 }
255
256 listByChannel (options: OverrideCommandOptions & VideosCommonQuery & {
257 handle: string
258 }) {
259 const { handle } = options
260 const path = '/api/v1/video-channels/' + handle + '/videos'
261
262 return this.getRequestBody<ResultList<Video>>({
263 ...options,
264
265 path,
266 query: this.buildListQuery(options),
267 implicitToken: true,
268 defaultExpectedStatus: HttpStatusCode.OK_200
269 })
270 }
271
272 // ---------------------------------------------------------------------------
273
274 async find (options: OverrideCommandOptions & {
275 name: string
276 }) {
277 const { data } = await this.list(options)
278
279 return data.find(v => v.name === options.name)
280 }
281
282 // ---------------------------------------------------------------------------
283
284 update (options: OverrideCommandOptions & {
285 id: number | string
286 attributes?: VideoEdit
287 }) {
288 const { id, attributes = {} } = options
289 const path = '/api/v1/videos/' + id
290
291 // Upload request
292 if (attributes.thumbnailfile || attributes.previewfile) {
293 const attaches: any = {}
294 if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile
295 if (attributes.previewfile) attaches.previewfile = attributes.previewfile
296
297 return this.putUploadRequest({
298 ...options,
299
300 path,
301 fields: options.attributes,
302 attaches: {
303 thumbnailfile: attributes.thumbnailfile,
304 previewfile: attributes.previewfile
305 },
306 implicitToken: true,
307 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
308 })
309 }
310
311 return this.putBodyRequest({
312 ...options,
313
314 path,
315 fields: options.attributes,
316 implicitToken: true,
317 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
318 })
319 }
320
321 remove (options: OverrideCommandOptions & {
322 id: number | string
323 }) {
324 const path = '/api/v1/videos/' + options.id
325
326 return unwrapBody(this.deleteRequest({
327 ...options,
328
329 path,
330 implicitToken: true,
331 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
332 }))
333 }
334
335 async removeAll () {
336 const { data } = await this.list()
337
338 for (const v of data) {
339 await this.remove({ id: v.id })
340 }
341 }
342
343 // ---------------------------------------------------------------------------
344
345 async upload (options: OverrideCommandOptions & {
346 attributes?: VideoEdit
347 mode?: 'legacy' | 'resumable' // default legacy
348 } = {}) {
349 const { mode = 'legacy' } = options
350 let defaultChannelId = 1
351
352 try {
353 const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
354 defaultChannelId = videoChannels[0].id
355 } catch (e) { /* empty */ }
356
357 // Override default attributes
358 const attributes = {
359 name: 'my super video',
360 category: 5,
361 licence: 4,
362 language: 'zh',
363 channelId: defaultChannelId,
364 nsfw: true,
365 waitTranscoding: false,
366 description: 'my super description',
367 support: 'my super support text',
368 tags: [ 'tag' ],
369 privacy: VideoPrivacy.PUBLIC,
370 commentsEnabled: true,
371 downloadEnabled: true,
372 fixture: 'video_short.webm',
373
374 ...options.attributes
375 }
376
377 const created = mode === 'legacy'
378 ? await this.buildLegacyUpload({ ...options, attributes })
379 : await this.buildResumeUpload({ ...options, attributes })
380
381 // Wait torrent generation
382 const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
383 if (expectedStatus === HttpStatusCode.OK_200) {
384 let video: VideoDetails
385
386 do {
387 video = await this.getWithToken({ ...options, id: created.uuid })
388
389 await wait(50)
390 } while (!video.files[0].torrentUrl)
391 }
392
393 return created
394 }
395
396 async buildLegacyUpload (options: OverrideCommandOptions & {
397 attributes: VideoEdit
398 }): Promise<VideoCreateResult> {
399 const path = '/api/v1/videos/upload'
400
401 return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({
402 ...options,
403
404 path,
405 fields: this.buildUploadFields(options.attributes),
406 attaches: this.buildUploadAttaches(options.attributes),
407 implicitToken: true,
408 defaultExpectedStatus: HttpStatusCode.OK_200
409 })).then(body => body.video || body as any)
410 }
411
412 async buildResumeUpload (options: OverrideCommandOptions & {
413 attributes: VideoEdit
414 }): Promise<VideoCreateResult> {
415 const { attributes, expectedStatus } = options
416
417 let size = 0
418 let videoFilePath: string
419 let mimetype = 'video/mp4'
420
421 if (attributes.fixture) {
422 videoFilePath = buildAbsoluteFixturePath(attributes.fixture)
423 size = (await stat(videoFilePath)).size
424
425 if (videoFilePath.endsWith('.mkv')) {
426 mimetype = 'video/x-matroska'
427 } else if (videoFilePath.endsWith('.webm')) {
428 mimetype = 'video/webm'
429 }
430 }
431
432 // Do not check status automatically, we'll check it manually
433 const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype })
434 const initStatus = initializeSessionRes.status
435
436 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
437 const locationHeader = initializeSessionRes.header['location']
438 expect(locationHeader).to.not.be.undefined
439
440 const pathUploadId = locationHeader.split('?')[1]
441
442 const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size })
443
444 if (result.statusCode === HttpStatusCode.OK_200) {
445 await this.endResumableUpload({ ...options, expectedStatus: HttpStatusCode.NO_CONTENT_204, pathUploadId })
446 }
447
448 return result.body?.video || result.body as any
449 }
450
451 const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200
452 ? HttpStatusCode.CREATED_201
453 : expectedStatus
454
455 expect(initStatus).to.equal(expectedInitStatus)
456
457 return initializeSessionRes.body.video || initializeSessionRes.body
458 }
459
460 async prepareResumableUpload (options: OverrideCommandOptions & {
461 attributes: VideoEdit
462 size: number
463 mimetype: string
464
465 originalName?: string
466 lastModified?: number
467 }) {
468 const { attributes, originalName, lastModified, size, mimetype } = options
469
470 const path = '/api/v1/videos/upload-resumable'
471
472 return this.postUploadRequest({
473 ...options,
474
475 path,
476 headers: {
477 'X-Upload-Content-Type': mimetype,
478 'X-Upload-Content-Length': size.toString()
479 },
480 fields: {
481 filename: attributes.fixture,
482 originalName,
483 lastModified,
484
485 ...this.buildUploadFields(options.attributes)
486 },
487
488 // Fixture will be sent later
489 attaches: this.buildUploadAttaches(omit(options.attributes, 'fixture')),
490 implicitToken: true,
491
492 defaultExpectedStatus: null
493 })
494 }
495
496 sendResumableChunks (options: OverrideCommandOptions & {
497 pathUploadId: string
498 videoFilePath: string
499 size: number
500 contentLength?: number
501 contentRangeBuilder?: (start: number, chunk: any) => string
502 }) {
503 const { pathUploadId, videoFilePath, size, contentLength, contentRangeBuilder, expectedStatus = HttpStatusCode.OK_200 } = options
504
505 const path = '/api/v1/videos/upload-resumable'
506 let start = 0
507
508 const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
509 const url = this.server.url
510
511 const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
512 return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => {
513 readable.on('data', async function onData (chunk) {
514 readable.pause()
515
516 const headers = {
517 'Authorization': 'Bearer ' + token,
518 'Content-Type': 'application/octet-stream',
519 'Content-Range': contentRangeBuilder
520 ? contentRangeBuilder(start, chunk)
521 : `bytes ${start}-${start + chunk.length - 1}/${size}`,
522 'Content-Length': contentLength ? contentLength + '' : chunk.length + ''
523 }
524
525 const res = await got<{ video: VideoCreateResult }>({
526 url,
527 method: 'put',
528 headers,
529 path: path + '?' + pathUploadId,
530 body: chunk,
531 responseType: 'json',
532 throwHttpErrors: false
533 })
534
535 start += chunk.length
536
537 if (res.statusCode === expectedStatus) {
538 return resolve(res)
539 }
540
541 if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
542 readable.off('data', onData)
543 return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
544 }
545
546 readable.resume()
547 })
548 })
549 }
550
551 endResumableUpload (options: OverrideCommandOptions & {
552 pathUploadId: string
553 }) {
554 return this.deleteRequest({
555 ...options,
556
557 path: '/api/v1/videos/upload-resumable',
558 rawQuery: options.pathUploadId,
559 implicitToken: true,
560 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
561 })
562 }
563
564 quickUpload (options: OverrideCommandOptions & {
565 name: string
566 nsfw?: boolean
567 privacy?: VideoPrivacy
568 fixture?: string
569 }) {
570 const attributes: VideoEdit = { name: options.name }
571 if (options.nsfw) attributes.nsfw = options.nsfw
572 if (options.privacy) attributes.privacy = options.privacy
573 if (options.fixture) attributes.fixture = options.fixture
574
575 return this.upload({ ...options, attributes })
576 }
577
578 async randomUpload (options: OverrideCommandOptions & {
579 wait?: boolean // default true
580 additionalParams?: VideoEdit & { prefixName?: string }
581 } = {}) {
582 const { wait = true, additionalParams } = options
583 const prefixName = additionalParams?.prefixName || ''
584 const name = prefixName + buildUUID()
585
586 const attributes = { name, ...additionalParams }
587
588 const result = await this.upload({ ...options, attributes })
589
590 if (wait) await waitJobs([ this.server ])
591
592 return { ...result, name }
593 }
594
595 // ---------------------------------------------------------------------------
596
597 removeHLSFiles (options: OverrideCommandOptions & {
598 videoId: number | string
599 }) {
600 const path = '/api/v1/videos/' + options.videoId + '/hls'
601
602 return this.deleteRequest({
603 ...options,
604
605 path,
606 implicitToken: true,
607 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
608 })
609 }
610
611 removeWebTorrentFiles (options: OverrideCommandOptions & {
612 videoId: number | string
613 }) {
614 const path = '/api/v1/videos/' + options.videoId + '/webtorrent'
615
616 return this.deleteRequest({
617 ...options,
618
619 path,
620 implicitToken: true,
621 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
622 })
623 }
624
625 runTranscoding (options: OverrideCommandOptions & {
626 videoId: number | string
627 transcodingType: 'hls' | 'webtorrent'
628 }) {
629 const path = '/api/v1/videos/' + options.videoId + '/transcoding'
630
631 const fields: VideoTranscodingCreate = pick(options, [ 'transcodingType' ])
632
633 return this.postBodyRequest({
634 ...options,
635
636 path,
637 fields,
638 implicitToken: true,
639 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
640 })
641 }
642
643 // ---------------------------------------------------------------------------
644
645 private buildListQuery (options: VideosCommonQuery) {
646 return pick(options, [
647 'start',
648 'count',
649 'sort',
650 'nsfw',
651 'isLive',
652 'categoryOneOf',
653 'licenceOneOf',
654 'languageOneOf',
655 'tagsOneOf',
656 'tagsAllOf',
657 'isLocal',
658 'include',
659 'skipCount'
660 ])
661 }
662
663 private buildUploadFields (attributes: VideoEdit) {
664 return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ])
665 }
666
667 private buildUploadAttaches (attributes: VideoEdit) {
668 const attaches: { [ name: string ]: string } = {}
669
670 for (const key of [ 'thumbnailfile', 'previewfile' ]) {
671 if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key])
672 }
673
674 if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture)
675
676 return attaches
677 }
678}