]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - shared/server-commands/videos/videos-command.ts
Fix instance config on mobile
[github/Chocobozzz/PeerTube.git] / shared / server-commands / videos / videos-command.ts
CommitLineData
d23dd9fb
C
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'
b033851f 6import { omit } from 'lodash'
d23dd9fb 7import validator from 'validator'
0628157f
C
8import { buildAbsoluteFixturePath, pick, wait } from '@shared/core-utils'
9import { buildUUID } from '@shared/extra-utils'
d23dd9fb 10import {
4c7e60bc 11 HttpStatusCode,
d23dd9fb
C
12 ResultList,
13 UserVideoRateType,
14 Video,
15 VideoCreate,
16 VideoCreateResult,
17 VideoDetails,
18 VideoFileMetadata,
19 VideoPrivacy,
ad5db104
C
20 VideosCommonQuery,
21 VideoTranscodingCreate
d23dd9fb 22} from '@shared/models'
d23dd9fb 23import { unwrapBody } from '../requests'
06aad801 24import { waitJobs } from '../server'
d23dd9fb 25import { AbstractCommand, OverrideCommandOptions } from '../shared'
2e401e85 26import { VideoSource } from '@shared/models/videos/video-source'
d23dd9fb
C
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 {
d23dd9fb
C
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
d23dd9fb
C
111 rate (options: OverrideCommandOptions & {
112 id: number | string
113 rating: UserVideoRateType
114 }) {
115 const { id, rating } = options
116 const path = '/api/v1/videos/' + id + '/rate'
117
118 return this.putBodyRequest({
119 ...options,
120
121 path,
122 fields: { rating },
123 implicitToken: true,
124 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
125 })
126 }
127
128 // ---------------------------------------------------------------------------
129
130 get (options: OverrideCommandOptions & {
131 id: number | string
132 }) {
133 const path = '/api/v1/videos/' + options.id
134
135 return this.getRequestBody<VideoDetails>({
136 ...options,
137
138 path,
139 implicitToken: false,
140 defaultExpectedStatus: HttpStatusCode.OK_200
141 })
142 }
143
144 getWithToken (options: OverrideCommandOptions & {
145 id: number | string
146 }) {
147 return this.get({
148 ...options,
149
150 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
151 })
152 }
153
2e401e85 154 getSource (options: OverrideCommandOptions & {
155 id: number | string
156 }) {
157 const path = '/api/v1/videos/' + options.id + '/source'
158
159 return this.getRequestBody<VideoSource>({
160 ...options,
161
162 path,
163 implicitToken: true,
164 defaultExpectedStatus: HttpStatusCode.OK_200
165 })
166 }
167
d23dd9fb
C
168 async getId (options: OverrideCommandOptions & {
169 uuid: number | string
170 }) {
171 const { uuid } = options
172
173 if (validator.isUUID('' + uuid) === false) return uuid as number
174
175 const { id } = await this.get({ ...options, id: uuid })
176
177 return id
178 }
179
0305db28
JB
180 async listFiles (options: OverrideCommandOptions & {
181 id: number | string
182 }) {
183 const video = await this.get(options)
184
185 const files = video.files || []
186 const hlsFiles = video.streamingPlaylists[0]?.files || []
187
188 return files.concat(hlsFiles)
189 }
190
d23dd9fb
C
191 // ---------------------------------------------------------------------------
192
193 listMyVideos (options: OverrideCommandOptions & {
194 start?: number
195 count?: number
196 sort?: string
197 search?: string
198 isLive?: boolean
978c87e7 199 channelId?: number
d23dd9fb
C
200 } = {}) {
201 const path = '/api/v1/users/me/videos'
202
203 return this.getRequestBody<ResultList<Video>>({
204 ...options,
205
206 path,
978c87e7 207 query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]),
d23dd9fb
C
208 implicitToken: true,
209 defaultExpectedStatus: HttpStatusCode.OK_200
210 })
211 }
212
213 // ---------------------------------------------------------------------------
214
215 list (options: OverrideCommandOptions & VideosCommonQuery = {}) {
216 const path = '/api/v1/videos'
217
218 const query = this.buildListQuery(options)
219
220 return this.getRequestBody<ResultList<Video>>({
221 ...options,
222
223 path,
224 query: { sort: 'name', ...query },
225 implicitToken: false,
226 defaultExpectedStatus: HttpStatusCode.OK_200
227 })
228 }
229
230 listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) {
231 return this.list({
232 ...options,
233
234 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
235 })
236 }
237
2760b454 238 listByAccount (options: OverrideCommandOptions & VideosCommonQuery & {
c0e8b12e 239 handle: string
d23dd9fb 240 }) {
c0e8b12e
C
241 const { handle, search } = options
242 const path = '/api/v1/accounts/' + handle + '/videos'
d23dd9fb
C
243
244 return this.getRequestBody<ResultList<Video>>({
245 ...options,
246
247 path,
248 query: { search, ...this.buildListQuery(options) },
249 implicitToken: true,
250 defaultExpectedStatus: HttpStatusCode.OK_200
251 })
252 }
253
2760b454 254 listByChannel (options: OverrideCommandOptions & VideosCommonQuery & {
c0e8b12e 255 handle: string
d23dd9fb 256 }) {
c0e8b12e
C
257 const { handle } = options
258 const path = '/api/v1/video-channels/' + handle + '/videos'
d23dd9fb
C
259
260 return this.getRequestBody<ResultList<Video>>({
261 ...options,
262
263 path,
264 query: this.buildListQuery(options),
265 implicitToken: true,
266 defaultExpectedStatus: HttpStatusCode.OK_200
267 })
268 }
269
270 // ---------------------------------------------------------------------------
271
4d029ef8
C
272 async find (options: OverrideCommandOptions & {
273 name: string
274 }) {
275 const { data } = await this.list(options)
276
277 return data.find(v => v.name === options.name)
278 }
279
280 // ---------------------------------------------------------------------------
281
d23dd9fb
C
282 update (options: OverrideCommandOptions & {
283 id: number | string
284 attributes?: VideoEdit
285 }) {
286 const { id, attributes = {} } = options
287 const path = '/api/v1/videos/' + id
288
289 // Upload request
290 if (attributes.thumbnailfile || attributes.previewfile) {
291 const attaches: any = {}
292 if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile
293 if (attributes.previewfile) attaches.previewfile = attributes.previewfile
294
295 return this.putUploadRequest({
296 ...options,
297
298 path,
299 fields: options.attributes,
300 attaches: {
301 thumbnailfile: attributes.thumbnailfile,
302 previewfile: attributes.previewfile
303 },
304 implicitToken: true,
305 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
306 })
307 }
308
309 return this.putBodyRequest({
310 ...options,
311
312 path,
313 fields: options.attributes,
314 implicitToken: true,
315 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
316 })
317 }
318
319 remove (options: OverrideCommandOptions & {
320 id: number | string
321 }) {
322 const path = '/api/v1/videos/' + options.id
323
c0e8b12e 324 return unwrapBody(this.deleteRequest({
d23dd9fb
C
325 ...options,
326
327 path,
328 implicitToken: true,
329 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
c0e8b12e 330 }))
d23dd9fb
C
331 }
332
333 async removeAll () {
334 const { data } = await this.list()
335
336 for (const v of data) {
337 await this.remove({ id: v.id })
338 }
339 }
340
341 // ---------------------------------------------------------------------------
342
343 async upload (options: OverrideCommandOptions & {
344 attributes?: VideoEdit
345 mode?: 'legacy' | 'resumable' // default legacy
346 } = {}) {
4c7e60bc 347 const { mode = 'legacy' } = options
d23dd9fb
C
348 let defaultChannelId = 1
349
350 try {
89d241a7 351 const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
d23dd9fb
C
352 defaultChannelId = videoChannels[0].id
353 } catch (e) { /* empty */ }
354
355 // Override default attributes
356 const attributes = {
357 name: 'my super video',
358 category: 5,
359 licence: 4,
360 language: 'zh',
361 channelId: defaultChannelId,
362 nsfw: true,
363 waitTranscoding: false,
364 description: 'my super description',
365 support: 'my super support text',
366 tags: [ 'tag' ],
367 privacy: VideoPrivacy.PUBLIC,
368 commentsEnabled: true,
369 downloadEnabled: true,
370 fixture: 'video_short.webm',
371
372 ...options.attributes
373 }
374
4c7e60bc 375 const created = mode === 'legacy'
d23dd9fb
C
376 ? await this.buildLegacyUpload({ ...options, attributes })
377 : await this.buildResumeUpload({ ...options, attributes })
378
379 // Wait torrent generation
4c7e60bc 380 const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
d23dd9fb
C
381 if (expectedStatus === HttpStatusCode.OK_200) {
382 let video: VideoDetails
383
384 do {
4c7e60bc 385 video = await this.getWithToken({ ...options, id: created.uuid })
d23dd9fb
C
386
387 await wait(50)
388 } while (!video.files[0].torrentUrl)
389 }
390
4c7e60bc 391 return created
d23dd9fb
C
392 }
393
394 async buildLegacyUpload (options: OverrideCommandOptions & {
395 attributes: VideoEdit
396 }): Promise<VideoCreateResult> {
397 const path = '/api/v1/videos/upload'
398
399 return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({
400 ...options,
401
402 path,
403 fields: this.buildUploadFields(options.attributes),
404 attaches: this.buildUploadAttaches(options.attributes),
405 implicitToken: true,
406 defaultExpectedStatus: HttpStatusCode.OK_200
407 })).then(body => body.video || body as any)
408 }
409
410 async buildResumeUpload (options: OverrideCommandOptions & {
411 attributes: VideoEdit
c0e8b12e 412 }): Promise<VideoCreateResult> {
d23dd9fb
C
413 const { attributes, expectedStatus } = options
414
415 let size = 0
416 let videoFilePath: string
417 let mimetype = 'video/mp4'
418
419 if (attributes.fixture) {
420 videoFilePath = buildAbsoluteFixturePath(attributes.fixture)
421 size = (await stat(videoFilePath)).size
422
423 if (videoFilePath.endsWith('.mkv')) {
424 mimetype = 'video/x-matroska'
425 } else if (videoFilePath.endsWith('.webm')) {
426 mimetype = 'video/webm'
427 }
428 }
429
c0e8b12e
C
430 // Do not check status automatically, we'll check it manually
431 const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype })
d23dd9fb
C
432 const initStatus = initializeSessionRes.status
433
434 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
435 const locationHeader = initializeSessionRes.header['location']
436 expect(locationHeader).to.not.be.undefined
437
438 const pathUploadId = locationHeader.split('?')[1]
439
440 const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size })
441
8de589b9
C
442 if (result.statusCode === HttpStatusCode.OK_200) {
443 await this.endResumableUpload({ ...options, expectedStatus: HttpStatusCode.NO_CONTENT_204, pathUploadId })
444 }
790c2837 445
c0e8b12e 446 return result.body?.video || result.body as any
d23dd9fb
C
447 }
448
449 const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200
450 ? HttpStatusCode.CREATED_201
451 : expectedStatus
452
453 expect(initStatus).to.equal(expectedInitStatus)
454
c0e8b12e 455 return initializeSessionRes.body.video || initializeSessionRes.body
d23dd9fb
C
456 }
457
458 async prepareResumableUpload (options: OverrideCommandOptions & {
459 attributes: VideoEdit
460 size: number
461 mimetype: string
020d3d3d
C
462
463 originalName?: string
464 lastModified?: number
d23dd9fb 465 }) {
020d3d3d 466 const { attributes, originalName, lastModified, size, mimetype } = options
d23dd9fb
C
467
468 const path = '/api/v1/videos/upload-resumable'
469
470 return this.postUploadRequest({
471 ...options,
472
473 path,
474 headers: {
475 'X-Upload-Content-Type': mimetype,
476 'X-Upload-Content-Length': size.toString()
477 },
020d3d3d
C
478 fields: {
479 filename: attributes.fixture,
480 originalName,
481 lastModified,
482
483 ...this.buildUploadFields(options.attributes)
484 },
485
c0e8b12e
C
486 // Fixture will be sent later
487 attaches: this.buildUploadAttaches(omit(options.attributes, 'fixture')),
d23dd9fb 488 implicitToken: true,
c0e8b12e 489
d23dd9fb
C
490 defaultExpectedStatus: null
491 })
492 }
493
494 sendResumableChunks (options: OverrideCommandOptions & {
495 pathUploadId: string
496 videoFilePath: string
497 size: number
498 contentLength?: number
499 contentRangeBuilder?: (start: number, chunk: any) => string
33ac85bf 500 digestBuilder?: (chunk: any) => string
d23dd9fb 501 }) {
33ac85bf
C
502 const {
503 pathUploadId,
504 videoFilePath,
505 size,
506 contentLength,
507 contentRangeBuilder,
508 digestBuilder,
509 expectedStatus = HttpStatusCode.OK_200
510 } = options
d23dd9fb
C
511
512 const path = '/api/v1/videos/upload-resumable'
513 let start = 0
514
515 const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
516 const url = this.server.url
517
518 const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
519 return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => {
520 readable.on('data', async function onData (chunk) {
521 readable.pause()
522
523 const headers = {
524 'Authorization': 'Bearer ' + token,
525 'Content-Type': 'application/octet-stream',
526 'Content-Range': contentRangeBuilder
527 ? contentRangeBuilder(start, chunk)
528 : `bytes ${start}-${start + chunk.length - 1}/${size}`,
529 'Content-Length': contentLength ? contentLength + '' : chunk.length + ''
530 }
531
33ac85bf
C
532 if (digestBuilder) {
533 Object.assign(headers, { digest: digestBuilder(chunk) })
534 }
535
d23dd9fb
C
536 const res = await got<{ video: VideoCreateResult }>({
537 url,
538 method: 'put',
539 headers,
540 path: path + '?' + pathUploadId,
541 body: chunk,
542 responseType: 'json',
543 throwHttpErrors: false
544 })
545
546 start += chunk.length
547
548 if (res.statusCode === expectedStatus) {
549 return resolve(res)
550 }
551
552 if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
553 readable.off('data', onData)
554 return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
555 }
556
557 readable.resume()
558 })
559 })
560 }
561
790c2837
C
562 endResumableUpload (options: OverrideCommandOptions & {
563 pathUploadId: string
564 }) {
565 return this.deleteRequest({
566 ...options,
567
568 path: '/api/v1/videos/upload-resumable',
569 rawQuery: options.pathUploadId,
570 implicitToken: true,
571 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
572 })
573 }
574
d23dd9fb
C
575 quickUpload (options: OverrideCommandOptions & {
576 name: string
577 nsfw?: boolean
578 privacy?: VideoPrivacy
579 fixture?: string
580 }) {
581 const attributes: VideoEdit = { name: options.name }
582 if (options.nsfw) attributes.nsfw = options.nsfw
583 if (options.privacy) attributes.privacy = options.privacy
584 if (options.fixture) attributes.fixture = options.fixture
585
586 return this.upload({ ...options, attributes })
587 }
588
589 async randomUpload (options: OverrideCommandOptions & {
590 wait?: boolean // default true
4c7e60bc 591 additionalParams?: VideoEdit & { prefixName?: string }
d23dd9fb
C
592 } = {}) {
593 const { wait = true, additionalParams } = options
594 const prefixName = additionalParams?.prefixName || ''
595 const name = prefixName + buildUUID()
596
4c7e60bc 597 const attributes = { name, ...additionalParams }
d23dd9fb 598
d23dd9fb
C
599 const result = await this.upload({ ...options, attributes })
600
c0e8b12e
C
601 if (wait) await waitJobs([ this.server ])
602
d23dd9fb
C
603 return { ...result, name }
604 }
605
606 // ---------------------------------------------------------------------------
607
b46cf4b9
C
608 removeHLSFiles (options: OverrideCommandOptions & {
609 videoId: number | string
610 }) {
611 const path = '/api/v1/videos/' + options.videoId + '/hls'
612
613 return this.deleteRequest({
614 ...options,
615
616 path,
617 implicitToken: true,
618 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
619 })
620 }
621
622 removeWebTorrentFiles (options: OverrideCommandOptions & {
623 videoId: number | string
624 }) {
625 const path = '/api/v1/videos/' + options.videoId + '/webtorrent'
626
627 return this.deleteRequest({
628 ...options,
629
630 path,
631 implicitToken: true,
ad5db104
C
632 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
633 })
634 }
635
636 runTranscoding (options: OverrideCommandOptions & {
637 videoId: number | string
638 transcodingType: 'hls' | 'webtorrent'
639 }) {
640 const path = '/api/v1/videos/' + options.videoId + '/transcoding'
641
642 const fields: VideoTranscodingCreate = pick(options, [ 'transcodingType' ])
643
644 return this.postBodyRequest({
645 ...options,
646
647 path,
648 fields,
649 implicitToken: true,
b46cf4b9
C
650 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
651 })
652 }
653
654 // ---------------------------------------------------------------------------
655
d23dd9fb
C
656 private buildListQuery (options: VideosCommonQuery) {
657 return pick(options, [
658 'start',
659 'count',
660 'sort',
661 'nsfw',
662 'isLive',
663 'categoryOneOf',
664 'licenceOneOf',
665 'languageOneOf',
666 'tagsOneOf',
667 'tagsAllOf',
2760b454
C
668 'isLocal',
669 'include',
d23dd9fb
C
670 'skipCount'
671 ])
672 }
673
674 private buildUploadFields (attributes: VideoEdit) {
c0e8b12e 675 return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ])
d23dd9fb
C
676 }
677
678 private buildUploadAttaches (attributes: VideoEdit) {
679 const attaches: { [ name: string ]: string } = {}
680
681 for (const key of [ 'thumbnailfile', 'previewfile' ]) {
682 if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key])
683 }
684
685 if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture)
686
687 return attaches
688 }
689}