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.ts679
1 files changed, 679 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..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}