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