]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - shared/extra-utils/videos/videos-command.ts
Warning when using capitalized letter in login
[github/Chocobozzz/PeerTube.git] / shared / extra-utils / 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'
6import { omit, pick } from 'lodash'
7import validator from 'validator'
8import { buildUUID } from '@server/helpers/uuid'
9import { loadLanguages } from '@server/initializers/constants'
d23dd9fb 10import {
4c7e60bc 11 HttpStatusCode,
d23dd9fb
C
12 ResultList,
13 UserVideoRateType,
14 Video,
15 VideoCreate,
16 VideoCreateResult,
17 VideoDetails,
18 VideoFileMetadata,
19 VideoPrivacy,
20 VideosCommonQuery,
21 VideosWithSearchCommonQuery
22} from '@shared/models'
23import { buildAbsoluteFixturePath, wait } from '../miscs'
24import { unwrapBody } from '../requests'
254d3579 25import { PeerTubeServer, waitJobs } from '../server'
d23dd9fb
C
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
254d3579 36 constructor (server: PeerTubeServer) {
d23dd9fb
C
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 // ---------------------------------------------------------------------------
191
192 listMyVideos (options: OverrideCommandOptions & {
193 start?: number
194 count?: number
195 sort?: string
196 search?: string
197 isLive?: boolean
198 } = {}) {
199 const path = '/api/v1/users/me/videos'
200
201 return this.getRequestBody<ResultList<Video>>({
202 ...options,
203
204 path,
205 query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive' ]),
206 implicitToken: true,
207 defaultExpectedStatus: HttpStatusCode.OK_200
208 })
209 }
210
211 // ---------------------------------------------------------------------------
212
213 list (options: OverrideCommandOptions & VideosCommonQuery = {}) {
214 const path = '/api/v1/videos'
215
216 const query = this.buildListQuery(options)
217
218 return this.getRequestBody<ResultList<Video>>({
219 ...options,
220
221 path,
222 query: { sort: 'name', ...query },
223 implicitToken: false,
224 defaultExpectedStatus: HttpStatusCode.OK_200
225 })
226 }
227
228 listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) {
229 return this.list({
230 ...options,
231
232 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
233 })
234 }
235
236 listByAccount (options: OverrideCommandOptions & VideosWithSearchCommonQuery & {
c0e8b12e 237 handle: string
d23dd9fb 238 }) {
c0e8b12e
C
239 const { handle, search } = options
240 const path = '/api/v1/accounts/' + handle + '/videos'
d23dd9fb
C
241
242 return this.getRequestBody<ResultList<Video>>({
243 ...options,
244
245 path,
246 query: { search, ...this.buildListQuery(options) },
247 implicitToken: true,
248 defaultExpectedStatus: HttpStatusCode.OK_200
249 })
250 }
251
252 listByChannel (options: OverrideCommandOptions & VideosWithSearchCommonQuery & {
c0e8b12e 253 handle: string
d23dd9fb 254 }) {
c0e8b12e
C
255 const { handle } = options
256 const path = '/api/v1/video-channels/' + handle + '/videos'
d23dd9fb
C
257
258 return this.getRequestBody<ResultList<Video>>({
259 ...options,
260
261 path,
262 query: this.buildListQuery(options),
263 implicitToken: true,
264 defaultExpectedStatus: HttpStatusCode.OK_200
265 })
266 }
267
268 // ---------------------------------------------------------------------------
269
270 update (options: OverrideCommandOptions & {
271 id: number | string
272 attributes?: VideoEdit
273 }) {
274 const { id, attributes = {} } = options
275 const path = '/api/v1/videos/' + id
276
277 // Upload request
278 if (attributes.thumbnailfile || attributes.previewfile) {
279 const attaches: any = {}
280 if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile
281 if (attributes.previewfile) attaches.previewfile = attributes.previewfile
282
283 return this.putUploadRequest({
284 ...options,
285
286 path,
287 fields: options.attributes,
288 attaches: {
289 thumbnailfile: attributes.thumbnailfile,
290 previewfile: attributes.previewfile
291 },
292 implicitToken: true,
293 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
294 })
295 }
296
297 return this.putBodyRequest({
298 ...options,
299
300 path,
301 fields: options.attributes,
302 implicitToken: true,
303 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
304 })
305 }
306
307 remove (options: OverrideCommandOptions & {
308 id: number | string
309 }) {
310 const path = '/api/v1/videos/' + options.id
311
c0e8b12e 312 return unwrapBody(this.deleteRequest({
d23dd9fb
C
313 ...options,
314
315 path,
316 implicitToken: true,
317 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
c0e8b12e 318 }))
d23dd9fb
C
319 }
320
321 async removeAll () {
322 const { data } = await this.list()
323
324 for (const v of data) {
325 await this.remove({ id: v.id })
326 }
327 }
328
329 // ---------------------------------------------------------------------------
330
331 async upload (options: OverrideCommandOptions & {
332 attributes?: VideoEdit
333 mode?: 'legacy' | 'resumable' // default legacy
334 } = {}) {
4c7e60bc 335 const { mode = 'legacy' } = options
d23dd9fb
C
336 let defaultChannelId = 1
337
338 try {
89d241a7 339 const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
d23dd9fb
C
340 defaultChannelId = videoChannels[0].id
341 } catch (e) { /* empty */ }
342
343 // Override default attributes
344 const attributes = {
345 name: 'my super video',
346 category: 5,
347 licence: 4,
348 language: 'zh',
349 channelId: defaultChannelId,
350 nsfw: true,
351 waitTranscoding: false,
352 description: 'my super description',
353 support: 'my super support text',
354 tags: [ 'tag' ],
355 privacy: VideoPrivacy.PUBLIC,
356 commentsEnabled: true,
357 downloadEnabled: true,
358 fixture: 'video_short.webm',
359
360 ...options.attributes
361 }
362
4c7e60bc 363 const created = mode === 'legacy'
d23dd9fb
C
364 ? await this.buildLegacyUpload({ ...options, attributes })
365 : await this.buildResumeUpload({ ...options, attributes })
366
367 // Wait torrent generation
4c7e60bc 368 const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
d23dd9fb
C
369 if (expectedStatus === HttpStatusCode.OK_200) {
370 let video: VideoDetails
371
372 do {
4c7e60bc 373 video = await this.getWithToken({ ...options, id: created.uuid })
d23dd9fb
C
374
375 await wait(50)
376 } while (!video.files[0].torrentUrl)
377 }
378
4c7e60bc 379 return created
d23dd9fb
C
380 }
381
382 async buildLegacyUpload (options: OverrideCommandOptions & {
383 attributes: VideoEdit
384 }): Promise<VideoCreateResult> {
385 const path = '/api/v1/videos/upload'
386
387 return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({
388 ...options,
389
390 path,
391 fields: this.buildUploadFields(options.attributes),
392 attaches: this.buildUploadAttaches(options.attributes),
393 implicitToken: true,
394 defaultExpectedStatus: HttpStatusCode.OK_200
395 })).then(body => body.video || body as any)
396 }
397
398 async buildResumeUpload (options: OverrideCommandOptions & {
399 attributes: VideoEdit
c0e8b12e 400 }): Promise<VideoCreateResult> {
d23dd9fb
C
401 const { attributes, expectedStatus } = options
402
403 let size = 0
404 let videoFilePath: string
405 let mimetype = 'video/mp4'
406
407 if (attributes.fixture) {
408 videoFilePath = buildAbsoluteFixturePath(attributes.fixture)
409 size = (await stat(videoFilePath)).size
410
411 if (videoFilePath.endsWith('.mkv')) {
412 mimetype = 'video/x-matroska'
413 } else if (videoFilePath.endsWith('.webm')) {
414 mimetype = 'video/webm'
415 }
416 }
417
c0e8b12e
C
418 // Do not check status automatically, we'll check it manually
419 const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype })
d23dd9fb
C
420 const initStatus = initializeSessionRes.status
421
422 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
423 const locationHeader = initializeSessionRes.header['location']
424 expect(locationHeader).to.not.be.undefined
425
426 const pathUploadId = locationHeader.split('?')[1]
427
428 const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size })
429
c0e8b12e 430 return result.body?.video || result.body as any
d23dd9fb
C
431 }
432
433 const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200
434 ? HttpStatusCode.CREATED_201
435 : expectedStatus
436
437 expect(initStatus).to.equal(expectedInitStatus)
438
c0e8b12e 439 return initializeSessionRes.body.video || initializeSessionRes.body
d23dd9fb
C
440 }
441
442 async prepareResumableUpload (options: OverrideCommandOptions & {
443 attributes: VideoEdit
444 size: number
445 mimetype: string
446 }) {
447 const { attributes, size, mimetype } = options
448
449 const path = '/api/v1/videos/upload-resumable'
450
451 return this.postUploadRequest({
452 ...options,
453
454 path,
455 headers: {
456 'X-Upload-Content-Type': mimetype,
457 'X-Upload-Content-Length': size.toString()
458 },
459 fields: { filename: attributes.fixture, ...this.buildUploadFields(options.attributes) },
c0e8b12e
C
460 // Fixture will be sent later
461 attaches: this.buildUploadAttaches(omit(options.attributes, 'fixture')),
d23dd9fb 462 implicitToken: true,
c0e8b12e 463
d23dd9fb
C
464 defaultExpectedStatus: null
465 })
466 }
467
468 sendResumableChunks (options: OverrideCommandOptions & {
469 pathUploadId: string
470 videoFilePath: string
471 size: number
472 contentLength?: number
473 contentRangeBuilder?: (start: number, chunk: any) => string
474 }) {
475 const { pathUploadId, videoFilePath, size, contentLength, contentRangeBuilder, expectedStatus = HttpStatusCode.OK_200 } = options
476
477 const path = '/api/v1/videos/upload-resumable'
478 let start = 0
479
480 const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
481 const url = this.server.url
482
483 const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
484 return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => {
485 readable.on('data', async function onData (chunk) {
486 readable.pause()
487
488 const headers = {
489 'Authorization': 'Bearer ' + token,
490 'Content-Type': 'application/octet-stream',
491 'Content-Range': contentRangeBuilder
492 ? contentRangeBuilder(start, chunk)
493 : `bytes ${start}-${start + chunk.length - 1}/${size}`,
494 'Content-Length': contentLength ? contentLength + '' : chunk.length + ''
495 }
496
497 const res = await got<{ video: VideoCreateResult }>({
498 url,
499 method: 'put',
500 headers,
501 path: path + '?' + pathUploadId,
502 body: chunk,
503 responseType: 'json',
504 throwHttpErrors: false
505 })
506
507 start += chunk.length
508
509 if (res.statusCode === expectedStatus) {
510 return resolve(res)
511 }
512
513 if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
514 readable.off('data', onData)
515 return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
516 }
517
518 readable.resume()
519 })
520 })
521 }
522
523 quickUpload (options: OverrideCommandOptions & {
524 name: string
525 nsfw?: boolean
526 privacy?: VideoPrivacy
527 fixture?: string
528 }) {
529 const attributes: VideoEdit = { name: options.name }
530 if (options.nsfw) attributes.nsfw = options.nsfw
531 if (options.privacy) attributes.privacy = options.privacy
532 if (options.fixture) attributes.fixture = options.fixture
533
534 return this.upload({ ...options, attributes })
535 }
536
537 async randomUpload (options: OverrideCommandOptions & {
538 wait?: boolean // default true
4c7e60bc 539 additionalParams?: VideoEdit & { prefixName?: string }
d23dd9fb
C
540 } = {}) {
541 const { wait = true, additionalParams } = options
542 const prefixName = additionalParams?.prefixName || ''
543 const name = prefixName + buildUUID()
544
4c7e60bc 545 const attributes = { name, ...additionalParams }
d23dd9fb 546
d23dd9fb
C
547 const result = await this.upload({ ...options, attributes })
548
c0e8b12e
C
549 if (wait) await waitJobs([ this.server ])
550
d23dd9fb
C
551 return { ...result, name }
552 }
553
554 // ---------------------------------------------------------------------------
555
556 private buildListQuery (options: VideosCommonQuery) {
557 return pick(options, [
558 'start',
559 'count',
560 'sort',
561 'nsfw',
562 'isLive',
563 'categoryOneOf',
564 'licenceOneOf',
565 'languageOneOf',
566 'tagsOneOf',
567 'tagsAllOf',
568 'filter',
569 'skipCount'
570 ])
571 }
572
573 private buildUploadFields (attributes: VideoEdit) {
c0e8b12e 574 return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ])
d23dd9fb
C
575 }
576
577 private buildUploadAttaches (attributes: VideoEdit) {
578 const attaches: { [ name: string ]: string } = {}
579
580 for (const key of [ 'thumbnailfile', 'previewfile' ]) {
581 if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key])
582 }
583
584 if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture)
585
586 return attaches
587 }
588}