aboutsummaryrefslogtreecommitdiffhomepage
path: root/shared/server-commands/videos/videos-command.ts
diff options
context:
space:
mode:
Diffstat (limited to 'shared/server-commands/videos/videos-command.ts')
-rw-r--r--shared/server-commands/videos/videos-command.ts678
1 files changed, 678 insertions, 0 deletions
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}