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