aboutsummaryrefslogtreecommitdiffhomepage
path: root/shared/server-commands/videos
diff options
context:
space:
mode:
Diffstat (limited to 'shared/server-commands/videos')
-rw-r--r--shared/server-commands/videos/blacklist-command.ts76
-rw-r--r--shared/server-commands/videos/captions-command.ts65
-rw-r--r--shared/server-commands/videos/captions.ts21
-rw-r--r--shared/server-commands/videos/change-ownership-command.ts68
-rw-r--r--shared/server-commands/videos/channels-command.ts178
-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.ts19
-rw-r--r--shared/server-commands/videos/live-command.ts155
-rw-r--r--shared/server-commands/videos/live.ts137
-rw-r--r--shared/server-commands/videos/playlists-command.ts280
-rw-r--r--shared/server-commands/videos/playlists.ts25
-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/streaming-playlists.ts77
-rw-r--r--shared/server-commands/videos/videos-command.ts679
-rw-r--r--shared/server-commands/videos/videos.ts104
19 files changed, 2232 insertions, 0 deletions
diff --git a/shared/server-commands/videos/blacklist-command.ts b/shared/server-commands/videos/blacklist-command.ts
new file mode 100644
index 000000000..3a2ef89ba
--- /dev/null
+++ b/shared/server-commands/videos/blacklist-command.ts
@@ -0,0 +1,76 @@
1
2import { HttpStatusCode, ResultList } from '@shared/models'
3import { VideoBlacklist, VideoBlacklistType } from '../../models/videos'
4import { AbstractCommand, OverrideCommandOptions } from '../shared'
5
6export class BlacklistCommand extends AbstractCommand {
7
8 add (options: OverrideCommandOptions & {
9 videoId: number | string
10 reason?: string
11 unfederate?: boolean
12 }) {
13 const { videoId, reason, unfederate } = options
14 const path = '/api/v1/videos/' + videoId + '/blacklist'
15
16 return this.postBodyRequest({
17 ...options,
18
19 path,
20 fields: { reason, unfederate },
21 implicitToken: true,
22 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
23 })
24 }
25
26 update (options: OverrideCommandOptions & {
27 videoId: number | string
28 reason?: string
29 }) {
30 const { videoId, reason } = options
31 const path = '/api/v1/videos/' + videoId + '/blacklist'
32
33 return this.putBodyRequest({
34 ...options,
35
36 path,
37 fields: { reason },
38 implicitToken: true,
39 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
40 })
41 }
42
43 remove (options: OverrideCommandOptions & {
44 videoId: number | string
45 }) {
46 const { videoId } = options
47 const path = '/api/v1/videos/' + videoId + '/blacklist'
48
49 return this.deleteRequest({
50 ...options,
51
52 path,
53 implicitToken: true,
54 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
55 })
56 }
57
58 list (options: OverrideCommandOptions & {
59 sort?: string
60 type?: VideoBlacklistType
61 } = {}) {
62 const { sort, type } = options
63 const path = '/api/v1/videos/blacklist/'
64
65 const query = { sort, type }
66
67 return this.getRequestBody<ResultList<VideoBlacklist>>({
68 ...options,
69
70 path,
71 query,
72 implicitToken: true,
73 defaultExpectedStatus: HttpStatusCode.OK_200
74 })
75 }
76}
diff --git a/shared/server-commands/videos/captions-command.ts b/shared/server-commands/videos/captions-command.ts
new file mode 100644
index 000000000..a65ea99e3
--- /dev/null
+++ b/shared/server-commands/videos/captions-command.ts
@@ -0,0 +1,65 @@
1import { HttpStatusCode, ResultList, VideoCaption } from '@shared/models'
2import { buildAbsoluteFixturePath } from '../miscs'
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/captions.ts b/shared/server-commands/videos/captions.ts
new file mode 100644
index 000000000..35e722408
--- /dev/null
+++ b/shared/server-commands/videos/captions.ts
@@ -0,0 +1,21 @@
1import { expect } from 'chai'
2import request from 'supertest'
3import { HttpStatusCode } from '@shared/models'
4
5async function testCaptionFile (url: string, captionPath: string, toTest: RegExp | string) {
6 const res = await request(url)
7 .get(captionPath)
8 .expect(HttpStatusCode.OK_200)
9
10 if (toTest instanceof RegExp) {
11 expect(res.text).to.match(toTest)
12 } else {
13 expect(res.text).to.contain(toTest)
14 }
15}
16
17// ---------------------------------------------------------------------------
18
19export {
20 testCaptionFile
21}
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..e406e570b
--- /dev/null
+++ b/shared/server-commands/videos/channels-command.ts
@@ -0,0 +1,178 @@
1import { pick } from '@shared/core-utils'
2import { ActorFollow, HttpStatusCode, ResultList, VideoChannel, VideoChannelCreateResult } from '@shared/models'
3import { VideoChannelCreate } from '../../models/videos/channel/video-channel-create.model'
4import { VideoChannelUpdate } from '../../models/videos/channel/video-channel-update.model'
5import { unwrapBody } from '../requests'
6import { AbstractCommand, OverrideCommandOptions } from '../shared'
7
8export class ChannelsCommand extends AbstractCommand {
9
10 list (options: OverrideCommandOptions & {
11 start?: number
12 count?: number
13 sort?: string
14 withStats?: boolean
15 } = {}) {
16 const path = '/api/v1/video-channels'
17
18 return this.getRequestBody<ResultList<VideoChannel>>({
19 ...options,
20
21 path,
22 query: pick(options, [ 'start', 'count', 'sort', 'withStats' ]),
23 implicitToken: false,
24 defaultExpectedStatus: HttpStatusCode.OK_200
25 })
26 }
27
28 listByAccount (options: OverrideCommandOptions & {
29 accountName: string
30 start?: number
31 count?: number
32 sort?: string
33 withStats?: boolean
34 search?: string
35 }) {
36 const { accountName, sort = 'createdAt' } = options
37 const path = '/api/v1/accounts/' + accountName + '/video-channels'
38
39 return this.getRequestBody<ResultList<VideoChannel>>({
40 ...options,
41
42 path,
43 query: { sort, ...pick(options, [ 'start', 'count', 'withStats', 'search' ]) },
44 implicitToken: false,
45 defaultExpectedStatus: HttpStatusCode.OK_200
46 })
47 }
48
49 async create (options: OverrideCommandOptions & {
50 attributes: Partial<VideoChannelCreate>
51 }) {
52 const path = '/api/v1/video-channels/'
53
54 // Default attributes
55 const defaultAttributes = {
56 displayName: 'my super video channel',
57 description: 'my super channel description',
58 support: 'my super channel support'
59 }
60 const attributes = { ...defaultAttributes, ...options.attributes }
61
62 const body = await unwrapBody<{ videoChannel: VideoChannelCreateResult }>(this.postBodyRequest({
63 ...options,
64
65 path,
66 fields: attributes,
67 implicitToken: true,
68 defaultExpectedStatus: HttpStatusCode.OK_200
69 }))
70
71 return body.videoChannel
72 }
73
74 update (options: OverrideCommandOptions & {
75 channelName: string
76 attributes: VideoChannelUpdate
77 }) {
78 const { channelName, attributes } = options
79 const path = '/api/v1/video-channels/' + channelName
80
81 return this.putBodyRequest({
82 ...options,
83
84 path,
85 fields: attributes,
86 implicitToken: true,
87 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
88 })
89 }
90
91 delete (options: OverrideCommandOptions & {
92 channelName: string
93 }) {
94 const path = '/api/v1/video-channels/' + options.channelName
95
96 return this.deleteRequest({
97 ...options,
98
99 path,
100 implicitToken: true,
101 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
102 })
103 }
104
105 get (options: OverrideCommandOptions & {
106 channelName: string
107 }) {
108 const path = '/api/v1/video-channels/' + options.channelName
109
110 return this.getRequestBody<VideoChannel>({
111 ...options,
112
113 path,
114 implicitToken: false,
115 defaultExpectedStatus: HttpStatusCode.OK_200
116 })
117 }
118
119 updateImage (options: OverrideCommandOptions & {
120 fixture: string
121 channelName: string | number
122 type: 'avatar' | 'banner'
123 }) {
124 const { channelName, fixture, type } = options
125
126 const path = `/api/v1/video-channels/${channelName}/${type}/pick`
127
128 return this.updateImageRequest({
129 ...options,
130
131 path,
132 fixture,
133 fieldname: type + 'file',
134
135 implicitToken: true,
136 defaultExpectedStatus: HttpStatusCode.OK_200
137 })
138 }
139
140 deleteImage (options: OverrideCommandOptions & {
141 channelName: string | number
142 type: 'avatar' | 'banner'
143 }) {
144 const { channelName, type } = options
145
146 const path = `/api/v1/video-channels/${channelName}/${type}`
147
148 return this.deleteRequest({
149 ...options,
150
151 path,
152 implicitToken: true,
153 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
154 })
155 }
156
157 listFollowers (options: OverrideCommandOptions & {
158 channelName: string
159 start?: number
160 count?: number
161 sort?: string
162 search?: string
163 }) {
164 const { channelName, start, count, sort, search } = options
165 const path = '/api/v1/video-channels/' + channelName + '/followers'
166
167 const query = { start, count, sort, search }
168
169 return this.getRequestBody<ResultList<ActorFollow>>({
170 ...options,
171
172 path,
173 query,
174 implicitToken: true,
175 defaultExpectedStatus: HttpStatusCode.OK_200
176 })
177 }
178}
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..26e663f46
--- /dev/null
+++ b/shared/server-commands/videos/index.ts
@@ -0,0 +1,19 @@
1export * from './blacklist-command'
2export * from './captions-command'
3export * from './captions'
4export * from './change-ownership-command'
5export * from './channels'
6export * from './channels-command'
7export * from './comments-command'
8export * from './history-command'
9export * from './imports-command'
10export * from './live-command'
11export * from './live'
12export * from './playlists-command'
13export * from './playlists'
14export * from './services-command'
15export * from './streaming-playlists-command'
16export * from './streaming-playlists'
17export * from './comments-command'
18export * from './videos-command'
19export * from './videos'
diff --git a/shared/server-commands/videos/live-command.ts b/shared/server-commands/videos/live-command.ts
new file mode 100644
index 000000000..74f5d3089
--- /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 { HttpStatusCode, LiveVideo, LiveVideoCreate, LiveVideoUpdate, VideoCreateResult, VideoDetails, VideoState } from '@shared/models'
7import { wait } from '../miscs'
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..d3665bc90
--- /dev/null
+++ b/shared/server-commands/videos/live.ts
@@ -0,0 +1,137 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
5import { pathExists, readdir } from 'fs-extra'
6import { join } from 'path'
7import { buildAbsoluteFixturePath, wait } from '../miscs'
8import { PeerTubeServer } from '../server/server'
9
10function sendRTMPStream (options: {
11 rtmpBaseUrl: string
12 streamKey: string
13 fixtureName?: string // default video_short.mp4
14 copyCodecs?: boolean // default false
15}) {
16 const { rtmpBaseUrl, streamKey, fixtureName = 'video_short.mp4', copyCodecs = false } = options
17
18 const fixture = buildAbsoluteFixturePath(fixtureName)
19
20 const command = ffmpeg(fixture)
21 command.inputOption('-stream_loop -1')
22 command.inputOption('-re')
23
24 if (copyCodecs) {
25 command.outputOption('-c copy')
26 } else {
27 command.outputOption('-c:v libx264')
28 command.outputOption('-g 50')
29 command.outputOption('-keyint_min 2')
30 command.outputOption('-r 60')
31 }
32
33 command.outputOption('-f flv')
34
35 const rtmpUrl = rtmpBaseUrl + '/' + streamKey
36 command.output(rtmpUrl)
37
38 command.on('error', err => {
39 if (err?.message?.includes('Exiting normally')) return
40
41 if (process.env.DEBUG) console.error(err)
42 })
43
44 if (process.env.DEBUG) {
45 command.on('stderr', data => console.log(data))
46 }
47
48 command.run()
49
50 return command
51}
52
53function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) {
54 return new Promise<void>((res, rej) => {
55 command.on('error', err => {
56 return rej(err)
57 })
58
59 setTimeout(() => {
60 res()
61 }, successAfterMS)
62 })
63}
64
65async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: boolean) {
66 let error: Error
67
68 try {
69 await waitFfmpegUntilError(command, 35000)
70 } catch (err) {
71 error = err
72 }
73
74 await stopFfmpeg(command)
75
76 if (shouldHaveError && !error) throw new Error('Ffmpeg did not have an error')
77 if (!shouldHaveError && error) throw error
78}
79
80async function stopFfmpeg (command: FfmpegCommand) {
81 command.kill('SIGINT')
82
83 await wait(500)
84}
85
86async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) {
87 for (const server of servers) {
88 await server.live.waitUntilPublished({ videoId })
89 }
90}
91
92async function waitUntilLiveSavedOnAllServers (servers: PeerTubeServer[], videoId: string) {
93 for (const server of servers) {
94 await server.live.waitUntilSaved({ videoId })
95 }
96}
97
98async function checkLiveCleanupAfterSave (server: PeerTubeServer, videoUUID: string, resolutions: number[] = []) {
99 const basePath = server.servers.buildDirectory('streaming-playlists')
100 const hlsPath = join(basePath, 'hls', videoUUID)
101
102 if (resolutions.length === 0) {
103 const result = await pathExists(hlsPath)
104 expect(result).to.be.false
105
106 return
107 }
108
109 const files = await readdir(hlsPath)
110
111 // fragmented file and playlist per resolution + master playlist + segments sha256 json file
112 expect(files).to.have.lengthOf(resolutions.length * 2 + 2)
113
114 for (const resolution of resolutions) {
115 const fragmentedFile = files.find(f => f.endsWith(`-${resolution}-fragmented.mp4`))
116 expect(fragmentedFile).to.exist
117
118 const playlistFile = files.find(f => f.endsWith(`${resolution}.m3u8`))
119 expect(playlistFile).to.exist
120 }
121
122 const masterPlaylistFile = files.find(f => f.endsWith('-master.m3u8'))
123 expect(masterPlaylistFile).to.exist
124
125 const shaFile = files.find(f => f.endsWith('-segments-sha256.json'))
126 expect(shaFile).to.exist
127}
128
129export {
130 sendRTMPStream,
131 waitFfmpegUntilError,
132 testFfmpegStreamError,
133 stopFfmpeg,
134 waitUntilLivePublishedOnAllServers,
135 waitUntilLiveSavedOnAllServers,
136 checkLiveCleanupAfterSave
137}
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/playlists.ts b/shared/server-commands/videos/playlists.ts
new file mode 100644
index 000000000..3dde52bb9
--- /dev/null
+++ b/shared/server-commands/videos/playlists.ts
@@ -0,0 +1,25 @@
1import { expect } from 'chai'
2import { readdir } from 'fs-extra'
3import { join } from 'path'
4import { root } from '../miscs'
5
6async function checkPlaylistFilesWereRemoved (
7 playlistUUID: string,
8 internalServerNumber: number,
9 directories = [ 'thumbnails' ]
10) {
11 const testDirectory = 'test' + internalServerNumber
12
13 for (const directory of directories) {
14 const directoryPath = join(root(), testDirectory, directory)
15
16 const files = await readdir(directoryPath)
17 for (const file of files) {
18 expect(file).to.not.contain(playlistUUID)
19 }
20 }
21}
22
23export {
24 checkPlaylistFilesWereRemoved
25}
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/streaming-playlists.ts b/shared/server-commands/videos/streaming-playlists.ts
new file mode 100644
index 000000000..0451c0efe
--- /dev/null
+++ b/shared/server-commands/videos/streaming-playlists.ts
@@ -0,0 +1,77 @@
1import { expect } from 'chai'
2import { basename } from 'path'
3import { sha256 } from '@shared/core-utils/crypto'
4import { removeFragmentedMP4Ext } from '@shared/core-utils'
5import { HttpStatusCode, VideoStreamingPlaylist } from '@shared/models'
6import { PeerTubeServer } from '../server'
7
8async function checkSegmentHash (options: {
9 server: PeerTubeServer
10 baseUrlPlaylist: string
11 baseUrlSegment: string
12 resolution: number
13 hlsPlaylist: VideoStreamingPlaylist
14}) {
15 const { server, baseUrlPlaylist, baseUrlSegment, resolution, hlsPlaylist } = options
16 const command = server.streamingPlaylists
17
18 const file = hlsPlaylist.files.find(f => f.resolution.id === resolution)
19 const videoName = basename(file.fileUrl)
20
21 const playlist = await command.get({ url: `${baseUrlPlaylist}/${removeFragmentedMP4Ext(videoName)}.m3u8` })
22
23 const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist)
24
25 const length = parseInt(matches[1], 10)
26 const offset = parseInt(matches[2], 10)
27 const range = `${offset}-${offset + length - 1}`
28
29 const segmentBody = await command.getSegment({
30 url: `${baseUrlSegment}/${videoName}`,
31 expectedStatus: HttpStatusCode.PARTIAL_CONTENT_206,
32 range: `bytes=${range}`
33 })
34
35 const shaBody = await command.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url })
36 expect(sha256(segmentBody)).to.equal(shaBody[videoName][range])
37}
38
39async function checkLiveSegmentHash (options: {
40 server: PeerTubeServer
41 baseUrlSegment: string
42 videoUUID: string
43 segmentName: string
44 hlsPlaylist: VideoStreamingPlaylist
45}) {
46 const { server, baseUrlSegment, videoUUID, segmentName, hlsPlaylist } = options
47 const command = server.streamingPlaylists
48
49 const segmentBody = await command.getSegment({ url: `${baseUrlSegment}/${videoUUID}/${segmentName}` })
50 const shaBody = await command.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url })
51
52 expect(sha256(segmentBody)).to.equal(shaBody[segmentName])
53}
54
55async function checkResolutionsInMasterPlaylist (options: {
56 server: PeerTubeServer
57 playlistUrl: string
58 resolutions: number[]
59}) {
60 const { server, playlistUrl, resolutions } = options
61
62 const masterPlaylist = await server.streamingPlaylists.get({ url: playlistUrl })
63
64 for (const resolution of resolutions) {
65 const reg = new RegExp(
66 '#EXT-X-STREAM-INF:BANDWIDTH=\\d+,RESOLUTION=\\d+x' + resolution + ',(FRAME-RATE=\\d+,)?CODECS="avc1.64001f,mp4a.40.2"'
67 )
68
69 expect(masterPlaylist).to.match(reg)
70 }
71}
72
73export {
74 checkSegmentHash,
75 checkLiveSegmentHash,
76 checkResolutionsInMasterPlaylist
77}
diff --git a/shared/server-commands/videos/videos-command.ts b/shared/server-commands/videos/videos-command.ts
new file mode 100644
index 000000000..8ea828b40
--- /dev/null
+++ b/shared/server-commands/videos/videos-command.ts
@@ -0,0 +1,679 @@
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 { buildUUID } from '@shared/core-utils/uuid'
9import { pick } from '@shared/core-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 { buildAbsoluteFixturePath, wait } from '../miscs'
24import { unwrapBody } from '../requests'
25import { waitJobs } from '../server'
26import { AbstractCommand, OverrideCommandOptions } from '../shared'
27
28export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile'>> & {
29 fixture?: string
30 thumbnailfile?: string
31 previewfile?: string
32}
33
34export class VideosCommand extends AbstractCommand {
35 getCategories (options: OverrideCommandOptions = {}) {
36 const path = '/api/v1/videos/categories'
37
38 return this.getRequestBody<{ [id: number]: string }>({
39 ...options,
40 path,
41
42 implicitToken: false,
43 defaultExpectedStatus: HttpStatusCode.OK_200
44 })
45 }
46
47 getLicences (options: OverrideCommandOptions = {}) {
48 const path = '/api/v1/videos/licences'
49
50 return this.getRequestBody<{ [id: number]: string }>({
51 ...options,
52 path,
53
54 implicitToken: false,
55 defaultExpectedStatus: HttpStatusCode.OK_200
56 })
57 }
58
59 getLanguages (options: OverrideCommandOptions = {}) {
60 const path = '/api/v1/videos/languages'
61
62 return this.getRequestBody<{ [id: string]: string }>({
63 ...options,
64 path,
65
66 implicitToken: false,
67 defaultExpectedStatus: HttpStatusCode.OK_200
68 })
69 }
70
71 getPrivacies (options: OverrideCommandOptions = {}) {
72 const path = '/api/v1/videos/privacies'
73
74 return this.getRequestBody<{ [id in VideoPrivacy]: string }>({
75 ...options,
76 path,
77
78 implicitToken: false,
79 defaultExpectedStatus: HttpStatusCode.OK_200
80 })
81 }
82
83 // ---------------------------------------------------------------------------
84
85 getDescription (options: OverrideCommandOptions & {
86 descriptionPath: string
87 }) {
88 return this.getRequestBody<{ description: string }>({
89 ...options,
90 path: options.descriptionPath,
91
92 implicitToken: false,
93 defaultExpectedStatus: HttpStatusCode.OK_200
94 })
95 }
96
97 getFileMetadata (options: OverrideCommandOptions & {
98 url: string
99 }) {
100 return unwrapBody<VideoFileMetadata>(this.getRawRequest({
101 ...options,
102
103 url: options.url,
104 implicitToken: false,
105 defaultExpectedStatus: HttpStatusCode.OK_200
106 }))
107 }
108
109 // ---------------------------------------------------------------------------
110
111 view (options: OverrideCommandOptions & {
112 id: number | string
113 xForwardedFor?: string
114 }) {
115 const { id, xForwardedFor } = options
116 const path = '/api/v1/videos/' + id + '/views'
117
118 return this.postBodyRequest({
119 ...options,
120
121 path,
122 xForwardedFor,
123 implicitToken: false,
124 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
125 })
126 }
127
128 rate (options: OverrideCommandOptions & {
129 id: number | string
130 rating: UserVideoRateType
131 }) {
132 const { id, rating } = options
133 const path = '/api/v1/videos/' + id + '/rate'
134
135 return this.putBodyRequest({
136 ...options,
137
138 path,
139 fields: { rating },
140 implicitToken: true,
141 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
142 })
143 }
144
145 // ---------------------------------------------------------------------------
146
147 get (options: OverrideCommandOptions & {
148 id: number | string
149 }) {
150 const path = '/api/v1/videos/' + options.id
151
152 return this.getRequestBody<VideoDetails>({
153 ...options,
154
155 path,
156 implicitToken: false,
157 defaultExpectedStatus: HttpStatusCode.OK_200
158 })
159 }
160
161 getWithToken (options: OverrideCommandOptions & {
162 id: number | string
163 }) {
164 return this.get({
165 ...options,
166
167 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
168 })
169 }
170
171 async getId (options: OverrideCommandOptions & {
172 uuid: number | string
173 }) {
174 const { uuid } = options
175
176 if (validator.isUUID('' + uuid) === false) return uuid as number
177
178 const { id } = await this.get({ ...options, id: uuid })
179
180 return id
181 }
182
183 async listFiles (options: OverrideCommandOptions & {
184 id: number | string
185 }) {
186 const video = await this.get(options)
187
188 const files = video.files || []
189 const hlsFiles = video.streamingPlaylists[0]?.files || []
190
191 return files.concat(hlsFiles)
192 }
193
194 // ---------------------------------------------------------------------------
195
196 listMyVideos (options: OverrideCommandOptions & {
197 start?: number
198 count?: number
199 sort?: string
200 search?: string
201 isLive?: boolean
202 channelId?: number
203 } = {}) {
204 const path = '/api/v1/users/me/videos'
205
206 return this.getRequestBody<ResultList<Video>>({
207 ...options,
208
209 path,
210 query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]),
211 implicitToken: true,
212 defaultExpectedStatus: HttpStatusCode.OK_200
213 })
214 }
215
216 // ---------------------------------------------------------------------------
217
218 list (options: OverrideCommandOptions & VideosCommonQuery = {}) {
219 const path = '/api/v1/videos'
220
221 const query = this.buildListQuery(options)
222
223 return this.getRequestBody<ResultList<Video>>({
224 ...options,
225
226 path,
227 query: { sort: 'name', ...query },
228 implicitToken: false,
229 defaultExpectedStatus: HttpStatusCode.OK_200
230 })
231 }
232
233 listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) {
234 return this.list({
235 ...options,
236
237 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
238 })
239 }
240
241 listByAccount (options: OverrideCommandOptions & VideosCommonQuery & {
242 handle: string
243 }) {
244 const { handle, search } = options
245 const path = '/api/v1/accounts/' + handle + '/videos'
246
247 return this.getRequestBody<ResultList<Video>>({
248 ...options,
249
250 path,
251 query: { search, ...this.buildListQuery(options) },
252 implicitToken: true,
253 defaultExpectedStatus: HttpStatusCode.OK_200
254 })
255 }
256
257 listByChannel (options: OverrideCommandOptions & VideosCommonQuery & {
258 handle: string
259 }) {
260 const { handle } = options
261 const path = '/api/v1/video-channels/' + handle + '/videos'
262
263 return this.getRequestBody<ResultList<Video>>({
264 ...options,
265
266 path,
267 query: this.buildListQuery(options),
268 implicitToken: true,
269 defaultExpectedStatus: HttpStatusCode.OK_200
270 })
271 }
272
273 // ---------------------------------------------------------------------------
274
275 async find (options: OverrideCommandOptions & {
276 name: string
277 }) {
278 const { data } = await this.list(options)
279
280 return data.find(v => v.name === options.name)
281 }
282
283 // ---------------------------------------------------------------------------
284
285 update (options: OverrideCommandOptions & {
286 id: number | string
287 attributes?: VideoEdit
288 }) {
289 const { id, attributes = {} } = options
290 const path = '/api/v1/videos/' + id
291
292 // Upload request
293 if (attributes.thumbnailfile || attributes.previewfile) {
294 const attaches: any = {}
295 if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile
296 if (attributes.previewfile) attaches.previewfile = attributes.previewfile
297
298 return this.putUploadRequest({
299 ...options,
300
301 path,
302 fields: options.attributes,
303 attaches: {
304 thumbnailfile: attributes.thumbnailfile,
305 previewfile: attributes.previewfile
306 },
307 implicitToken: true,
308 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
309 })
310 }
311
312 return this.putBodyRequest({
313 ...options,
314
315 path,
316 fields: options.attributes,
317 implicitToken: true,
318 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
319 })
320 }
321
322 remove (options: OverrideCommandOptions & {
323 id: number | string
324 }) {
325 const path = '/api/v1/videos/' + options.id
326
327 return unwrapBody(this.deleteRequest({
328 ...options,
329
330 path,
331 implicitToken: true,
332 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
333 }))
334 }
335
336 async removeAll () {
337 const { data } = await this.list()
338
339 for (const v of data) {
340 await this.remove({ id: v.id })
341 }
342 }
343
344 // ---------------------------------------------------------------------------
345
346 async upload (options: OverrideCommandOptions & {
347 attributes?: VideoEdit
348 mode?: 'legacy' | 'resumable' // default legacy
349 } = {}) {
350 const { mode = 'legacy' } = options
351 let defaultChannelId = 1
352
353 try {
354 const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
355 defaultChannelId = videoChannels[0].id
356 } catch (e) { /* empty */ }
357
358 // Override default attributes
359 const attributes = {
360 name: 'my super video',
361 category: 5,
362 licence: 4,
363 language: 'zh',
364 channelId: defaultChannelId,
365 nsfw: true,
366 waitTranscoding: false,
367 description: 'my super description',
368 support: 'my super support text',
369 tags: [ 'tag' ],
370 privacy: VideoPrivacy.PUBLIC,
371 commentsEnabled: true,
372 downloadEnabled: true,
373 fixture: 'video_short.webm',
374
375 ...options.attributes
376 }
377
378 const created = mode === 'legacy'
379 ? await this.buildLegacyUpload({ ...options, attributes })
380 : await this.buildResumeUpload({ ...options, attributes })
381
382 // Wait torrent generation
383 const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
384 if (expectedStatus === HttpStatusCode.OK_200) {
385 let video: VideoDetails
386
387 do {
388 video = await this.getWithToken({ ...options, id: created.uuid })
389
390 await wait(50)
391 } while (!video.files[0].torrentUrl)
392 }
393
394 return created
395 }
396
397 async buildLegacyUpload (options: OverrideCommandOptions & {
398 attributes: VideoEdit
399 }): Promise<VideoCreateResult> {
400 const path = '/api/v1/videos/upload'
401
402 return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({
403 ...options,
404
405 path,
406 fields: this.buildUploadFields(options.attributes),
407 attaches: this.buildUploadAttaches(options.attributes),
408 implicitToken: true,
409 defaultExpectedStatus: HttpStatusCode.OK_200
410 })).then(body => body.video || body as any)
411 }
412
413 async buildResumeUpload (options: OverrideCommandOptions & {
414 attributes: VideoEdit
415 }): Promise<VideoCreateResult> {
416 const { attributes, expectedStatus } = options
417
418 let size = 0
419 let videoFilePath: string
420 let mimetype = 'video/mp4'
421
422 if (attributes.fixture) {
423 videoFilePath = buildAbsoluteFixturePath(attributes.fixture)
424 size = (await stat(videoFilePath)).size
425
426 if (videoFilePath.endsWith('.mkv')) {
427 mimetype = 'video/x-matroska'
428 } else if (videoFilePath.endsWith('.webm')) {
429 mimetype = 'video/webm'
430 }
431 }
432
433 // Do not check status automatically, we'll check it manually
434 const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype })
435 const initStatus = initializeSessionRes.status
436
437 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
438 const locationHeader = initializeSessionRes.header['location']
439 expect(locationHeader).to.not.be.undefined
440
441 const pathUploadId = locationHeader.split('?')[1]
442
443 const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size })
444
445 if (result.statusCode === HttpStatusCode.OK_200) {
446 await this.endResumableUpload({ ...options, expectedStatus: HttpStatusCode.NO_CONTENT_204, pathUploadId })
447 }
448
449 return result.body?.video || result.body as any
450 }
451
452 const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200
453 ? HttpStatusCode.CREATED_201
454 : expectedStatus
455
456 expect(initStatus).to.equal(expectedInitStatus)
457
458 return initializeSessionRes.body.video || initializeSessionRes.body
459 }
460
461 async prepareResumableUpload (options: OverrideCommandOptions & {
462 attributes: VideoEdit
463 size: number
464 mimetype: string
465
466 originalName?: string
467 lastModified?: number
468 }) {
469 const { attributes, originalName, lastModified, size, mimetype } = options
470
471 const path = '/api/v1/videos/upload-resumable'
472
473 return this.postUploadRequest({
474 ...options,
475
476 path,
477 headers: {
478 'X-Upload-Content-Type': mimetype,
479 'X-Upload-Content-Length': size.toString()
480 },
481 fields: {
482 filename: attributes.fixture,
483 originalName,
484 lastModified,
485
486 ...this.buildUploadFields(options.attributes)
487 },
488
489 // Fixture will be sent later
490 attaches: this.buildUploadAttaches(omit(options.attributes, 'fixture')),
491 implicitToken: true,
492
493 defaultExpectedStatus: null
494 })
495 }
496
497 sendResumableChunks (options: OverrideCommandOptions & {
498 pathUploadId: string
499 videoFilePath: string
500 size: number
501 contentLength?: number
502 contentRangeBuilder?: (start: number, chunk: any) => string
503 }) {
504 const { pathUploadId, videoFilePath, size, contentLength, contentRangeBuilder, expectedStatus = HttpStatusCode.OK_200 } = options
505
506 const path = '/api/v1/videos/upload-resumable'
507 let start = 0
508
509 const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
510 const url = this.server.url
511
512 const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
513 return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => {
514 readable.on('data', async function onData (chunk) {
515 readable.pause()
516
517 const headers = {
518 'Authorization': 'Bearer ' + token,
519 'Content-Type': 'application/octet-stream',
520 'Content-Range': contentRangeBuilder
521 ? contentRangeBuilder(start, chunk)
522 : `bytes ${start}-${start + chunk.length - 1}/${size}`,
523 'Content-Length': contentLength ? contentLength + '' : chunk.length + ''
524 }
525
526 const res = await got<{ video: VideoCreateResult }>({
527 url,
528 method: 'put',
529 headers,
530 path: path + '?' + pathUploadId,
531 body: chunk,
532 responseType: 'json',
533 throwHttpErrors: false
534 })
535
536 start += chunk.length
537
538 if (res.statusCode === expectedStatus) {
539 return resolve(res)
540 }
541
542 if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
543 readable.off('data', onData)
544 return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
545 }
546
547 readable.resume()
548 })
549 })
550 }
551
552 endResumableUpload (options: OverrideCommandOptions & {
553 pathUploadId: string
554 }) {
555 return this.deleteRequest({
556 ...options,
557
558 path: '/api/v1/videos/upload-resumable',
559 rawQuery: options.pathUploadId,
560 implicitToken: true,
561 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
562 })
563 }
564
565 quickUpload (options: OverrideCommandOptions & {
566 name: string
567 nsfw?: boolean
568 privacy?: VideoPrivacy
569 fixture?: string
570 }) {
571 const attributes: VideoEdit = { name: options.name }
572 if (options.nsfw) attributes.nsfw = options.nsfw
573 if (options.privacy) attributes.privacy = options.privacy
574 if (options.fixture) attributes.fixture = options.fixture
575
576 return this.upload({ ...options, attributes })
577 }
578
579 async randomUpload (options: OverrideCommandOptions & {
580 wait?: boolean // default true
581 additionalParams?: VideoEdit & { prefixName?: string }
582 } = {}) {
583 const { wait = true, additionalParams } = options
584 const prefixName = additionalParams?.prefixName || ''
585 const name = prefixName + buildUUID()
586
587 const attributes = { name, ...additionalParams }
588
589 const result = await this.upload({ ...options, attributes })
590
591 if (wait) await waitJobs([ this.server ])
592
593 return { ...result, name }
594 }
595
596 // ---------------------------------------------------------------------------
597
598 removeHLSFiles (options: OverrideCommandOptions & {
599 videoId: number | string
600 }) {
601 const path = '/api/v1/videos/' + options.videoId + '/hls'
602
603 return this.deleteRequest({
604 ...options,
605
606 path,
607 implicitToken: true,
608 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
609 })
610 }
611
612 removeWebTorrentFiles (options: OverrideCommandOptions & {
613 videoId: number | string
614 }) {
615 const path = '/api/v1/videos/' + options.videoId + '/webtorrent'
616
617 return this.deleteRequest({
618 ...options,
619
620 path,
621 implicitToken: true,
622 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
623 })
624 }
625
626 runTranscoding (options: OverrideCommandOptions & {
627 videoId: number | string
628 transcodingType: 'hls' | 'webtorrent'
629 }) {
630 const path = '/api/v1/videos/' + options.videoId + '/transcoding'
631
632 const fields: VideoTranscodingCreate = pick(options, [ 'transcodingType' ])
633
634 return this.postBodyRequest({
635 ...options,
636
637 path,
638 fields,
639 implicitToken: true,
640 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
641 })
642 }
643
644 // ---------------------------------------------------------------------------
645
646 private buildListQuery (options: VideosCommonQuery) {
647 return pick(options, [
648 'start',
649 'count',
650 'sort',
651 'nsfw',
652 'isLive',
653 'categoryOneOf',
654 'licenceOneOf',
655 'languageOneOf',
656 'tagsOneOf',
657 'tagsAllOf',
658 'isLocal',
659 'include',
660 'skipCount'
661 ])
662 }
663
664 private buildUploadFields (attributes: VideoEdit) {
665 return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ])
666 }
667
668 private buildUploadAttaches (attributes: VideoEdit) {
669 const attaches: { [ name: string ]: string } = {}
670
671 for (const key of [ 'thumbnailfile', 'previewfile' ]) {
672 if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key])
673 }
674
675 if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture)
676
677 return attaches
678 }
679}
diff --git a/shared/server-commands/videos/videos.ts b/shared/server-commands/videos/videos.ts
new file mode 100644
index 000000000..2c3464aa8
--- /dev/null
+++ b/shared/server-commands/videos/videos.ts
@@ -0,0 +1,104 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
2
3import { expect } from 'chai'
4import { pathExists, readdir } from 'fs-extra'
5import { basename, join } from 'path'
6import { HttpStatusCode, VideoCaption, VideoDetails } from '@shared/models'
7import { waitJobs } from '../server'
8import { PeerTubeServer } from '../server/server'
9import { VideoEdit } from './videos-command'
10
11async function checkVideoFilesWereRemoved (options: {
12 server: PeerTubeServer
13 video: VideoDetails
14 captions?: VideoCaption[]
15 onlyVideoFiles?: boolean // default false
16}) {
17 const { video, server, captions = [], onlyVideoFiles = false } = options
18
19 const webtorrentFiles = video.files || []
20 const hlsFiles = video.streamingPlaylists[0]?.files || []
21
22 const thumbnailName = basename(video.thumbnailPath)
23 const previewName = basename(video.previewPath)
24
25 const torrentNames = webtorrentFiles.concat(hlsFiles).map(f => basename(f.torrentUrl))
26
27 const captionNames = captions.map(c => basename(c.captionPath))
28
29 const webtorrentFilenames = webtorrentFiles.map(f => basename(f.fileUrl))
30 const hlsFilenames = hlsFiles.map(f => basename(f.fileUrl))
31
32 let directories: { [ directory: string ]: string[] } = {
33 videos: webtorrentFilenames,
34 redundancy: webtorrentFilenames,
35 [join('playlists', 'hls')]: hlsFilenames,
36 [join('redundancy', 'hls')]: hlsFilenames
37 }
38
39 if (onlyVideoFiles !== true) {
40 directories = {
41 ...directories,
42
43 thumbnails: [ thumbnailName ],
44 previews: [ previewName ],
45 torrents: torrentNames,
46 captions: captionNames
47 }
48 }
49
50 for (const directory of Object.keys(directories)) {
51 const directoryPath = server.servers.buildDirectory(directory)
52
53 const directoryExists = await pathExists(directoryPath)
54 if (directoryExists === false) continue
55
56 const existingFiles = await readdir(directoryPath)
57 for (const existingFile of existingFiles) {
58 for (const shouldNotExist of directories[directory]) {
59 expect(existingFile, `File ${existingFile} should not exist in ${directoryPath}`).to.not.contain(shouldNotExist)
60 }
61 }
62 }
63}
64
65async function saveVideoInServers (servers: PeerTubeServer[], uuid: string) {
66 for (const server of servers) {
67 server.store.videoDetails = await server.videos.get({ id: uuid })
68 }
69}
70
71function checkUploadVideoParam (
72 server: PeerTubeServer,
73 token: string,
74 attributes: Partial<VideoEdit>,
75 expectedStatus = HttpStatusCode.OK_200,
76 mode: 'legacy' | 'resumable' = 'legacy'
77) {
78 return mode === 'legacy'
79 ? server.videos.buildLegacyUpload({ token, attributes, expectedStatus })
80 : server.videos.buildResumeUpload({ token, attributes, expectedStatus })
81}
82
83// serverNumber starts from 1
84async function uploadRandomVideoOnServers (
85 servers: PeerTubeServer[],
86 serverNumber: number,
87 additionalParams?: VideoEdit & { prefixName?: string }
88) {
89 const server = servers.find(s => s.serverNumber === serverNumber)
90 const res = await server.videos.randomUpload({ wait: false, additionalParams })
91
92 await waitJobs(servers)
93
94 return res
95}
96
97// ---------------------------------------------------------------------------
98
99export {
100 checkUploadVideoParam,
101 uploadRandomVideoOnServers,
102 checkVideoFilesWereRemoved,
103 saveVideoInServers
104}