]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - shared/extra-utils/videos/videos.ts
hls-plugin: destroy hls upon third err
[github/Chocobozzz/PeerTube.git] / shared / extra-utils / videos / videos.ts
1 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
2
3 import { expect } from 'chai'
4 import { pathExists, readdir, readFile } from 'fs-extra'
5 import * as parseTorrent from 'parse-torrent'
6 import { extname, join } from 'path'
7 import * as request from 'supertest'
8 import { v4 as uuidv4 } from 'uuid'
9 import validator from 'validator'
10 import { loadLanguages, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../server/initializers/constants'
11 import { VideoDetails, VideoPrivacy } from '../../models/videos'
12 import { buildAbsoluteFixturePath, buildServerDirectory, dateIsValid, immutableAssign, testImage, webtorrentAdd } from '../miscs/miscs'
13 import { makeGetRequest, makePutBodyRequest, makeUploadRequest } from '../requests/requests'
14 import { waitJobs } from '../server/jobs'
15 import { ServerInfo } from '../server/servers'
16 import { getMyUserInformation } from '../users/users'
17
18 loadLanguages()
19
20 type VideoAttributes = {
21 name?: string
22 category?: number
23 licence?: number
24 language?: string
25 nsfw?: boolean
26 commentsEnabled?: boolean
27 downloadEnabled?: boolean
28 waitTranscoding?: boolean
29 description?: string
30 originallyPublishedAt?: string
31 tags?: string[]
32 channelId?: number
33 privacy?: VideoPrivacy
34 fixture?: string
35 thumbnailfile?: string
36 previewfile?: string
37 scheduleUpdate?: {
38 updateAt: string
39 privacy?: VideoPrivacy
40 }
41 }
42
43 function getVideoCategories (url: string) {
44 const path = '/api/v1/videos/categories'
45
46 return makeGetRequest({
47 url,
48 path,
49 statusCodeExpected: 200
50 })
51 }
52
53 function getVideoLicences (url: string) {
54 const path = '/api/v1/videos/licences'
55
56 return makeGetRequest({
57 url,
58 path,
59 statusCodeExpected: 200
60 })
61 }
62
63 function getVideoLanguages (url: string) {
64 const path = '/api/v1/videos/languages'
65
66 return makeGetRequest({
67 url,
68 path,
69 statusCodeExpected: 200
70 })
71 }
72
73 function getVideoPrivacies (url: string) {
74 const path = '/api/v1/videos/privacies'
75
76 return makeGetRequest({
77 url,
78 path,
79 statusCodeExpected: 200
80 })
81 }
82
83 function getVideo (url: string, id: number | string, expectedStatus = 200) {
84 const path = '/api/v1/videos/' + id
85
86 return request(url)
87 .get(path)
88 .set('Accept', 'application/json')
89 .expect(expectedStatus)
90 }
91
92 async function getVideoIdFromUUID (url: string, uuid: string) {
93 const res = await getVideo(url, uuid)
94
95 return res.body.id
96 }
97
98 function getVideoFileMetadataUrl (url: string) {
99 return request(url)
100 .get('/')
101 .set('Accept', 'application/json')
102 .expect(200)
103 .expect('Content-Type', /json/)
104 }
105
106 function viewVideo (url: string, id: number | string, expectedStatus = 204, xForwardedFor?: string) {
107 const path = '/api/v1/videos/' + id + '/views'
108
109 const req = request(url)
110 .post(path)
111 .set('Accept', 'application/json')
112
113 if (xForwardedFor) {
114 req.set('X-Forwarded-For', xForwardedFor)
115 }
116
117 return req.expect(expectedStatus)
118 }
119
120 function getVideoWithToken (url: string, token: string, id: number | string, expectedStatus = 200) {
121 const path = '/api/v1/videos/' + id
122
123 return request(url)
124 .get(path)
125 .set('Authorization', 'Bearer ' + token)
126 .set('Accept', 'application/json')
127 .expect(expectedStatus)
128 }
129
130 function getVideoDescription (url: string, descriptionPath: string) {
131 return request(url)
132 .get(descriptionPath)
133 .set('Accept', 'application/json')
134 .expect(200)
135 .expect('Content-Type', /json/)
136 }
137
138 function getVideosList (url: string) {
139 const path = '/api/v1/videos'
140
141 return request(url)
142 .get(path)
143 .query({ sort: 'name' })
144 .set('Accept', 'application/json')
145 .expect(200)
146 .expect('Content-Type', /json/)
147 }
148
149 function getVideosListWithToken (url: string, token: string, query: { nsfw?: boolean } = {}) {
150 const path = '/api/v1/videos'
151
152 return request(url)
153 .get(path)
154 .set('Authorization', 'Bearer ' + token)
155 .query(immutableAssign(query, { sort: 'name' }))
156 .set('Accept', 'application/json')
157 .expect(200)
158 .expect('Content-Type', /json/)
159 }
160
161 function getLocalVideos (url: string) {
162 const path = '/api/v1/videos'
163
164 return request(url)
165 .get(path)
166 .query({ sort: 'name', filter: 'local' })
167 .set('Accept', 'application/json')
168 .expect(200)
169 .expect('Content-Type', /json/)
170 }
171
172 function getMyVideos (url: string, accessToken: string, start: number, count: number, sort?: string, search?: string) {
173 const path = '/api/v1/users/me/videos'
174
175 const req = request(url)
176 .get(path)
177 .query({ start: start })
178 .query({ count: count })
179 .query({ search: search })
180
181 if (sort) req.query({ sort })
182
183 return req.set('Accept', 'application/json')
184 .set('Authorization', 'Bearer ' + accessToken)
185 .expect(200)
186 .expect('Content-Type', /json/)
187 }
188
189 function getAccountVideos (
190 url: string,
191 accessToken: string,
192 accountName: string,
193 start: number,
194 count: number,
195 sort?: string,
196 query: { nsfw?: boolean } = {}
197 ) {
198 const path = '/api/v1/accounts/' + accountName + '/videos'
199
200 return makeGetRequest({
201 url,
202 path,
203 query: immutableAssign(query, {
204 start,
205 count,
206 sort
207 }),
208 token: accessToken,
209 statusCodeExpected: 200
210 })
211 }
212
213 function getVideoChannelVideos (
214 url: string,
215 accessToken: string,
216 videoChannelName: string,
217 start: number,
218 count: number,
219 sort?: string,
220 query: { nsfw?: boolean } = {}
221 ) {
222 const path = '/api/v1/video-channels/' + videoChannelName + '/videos'
223
224 return makeGetRequest({
225 url,
226 path,
227 query: immutableAssign(query, {
228 start,
229 count,
230 sort
231 }),
232 token: accessToken,
233 statusCodeExpected: 200
234 })
235 }
236
237 function getPlaylistVideos (
238 url: string,
239 accessToken: string,
240 playlistId: number | string,
241 start: number,
242 count: number,
243 query: { nsfw?: boolean } = {}
244 ) {
245 const path = '/api/v1/video-playlists/' + playlistId + '/videos'
246
247 return makeGetRequest({
248 url,
249 path,
250 query: immutableAssign(query, {
251 start,
252 count
253 }),
254 token: accessToken,
255 statusCodeExpected: 200
256 })
257 }
258
259 function getVideosListPagination (url: string, start: number, count: number, sort?: string, skipCount?: boolean) {
260 const path = '/api/v1/videos'
261
262 const req = request(url)
263 .get(path)
264 .query({ start: start })
265 .query({ count: count })
266
267 if (sort) req.query({ sort })
268 if (skipCount) req.query({ skipCount })
269
270 return req.set('Accept', 'application/json')
271 .expect(200)
272 .expect('Content-Type', /json/)
273 }
274
275 function getVideosListSort (url: string, sort: string) {
276 const path = '/api/v1/videos'
277
278 return request(url)
279 .get(path)
280 .query({ sort: sort })
281 .set('Accept', 'application/json')
282 .expect(200)
283 .expect('Content-Type', /json/)
284 }
285
286 function getVideosWithFilters (url: string, query: { tagsAllOf: string[], categoryOneOf: number[] | number }) {
287 const path = '/api/v1/videos'
288
289 return request(url)
290 .get(path)
291 .query(query)
292 .set('Accept', 'application/json')
293 .expect(200)
294 .expect('Content-Type', /json/)
295 }
296
297 function removeVideo (url: string, token: string, id: number | string, expectedStatus = 204) {
298 const path = '/api/v1/videos'
299
300 return request(url)
301 .delete(path + '/' + id)
302 .set('Accept', 'application/json')
303 .set('Authorization', 'Bearer ' + token)
304 .expect(expectedStatus)
305 }
306
307 async function removeAllVideos (server: ServerInfo) {
308 const resVideos = await getVideosList(server.url)
309
310 for (const v of resVideos.body.data) {
311 await removeVideo(server.url, server.accessToken, v.id)
312 }
313 }
314
315 async function checkVideoFilesWereRemoved (
316 videoUUID: string,
317 serverNumber: number,
318 directories = [
319 'redundancy',
320 'videos',
321 'thumbnails',
322 'torrents',
323 'previews',
324 'captions',
325 join('playlists', 'hls'),
326 join('redundancy', 'hls')
327 ]
328 ) {
329 for (const directory of directories) {
330 const directoryPath = buildServerDirectory({ internalServerNumber: serverNumber }, directory)
331
332 const directoryExists = await pathExists(directoryPath)
333 if (directoryExists === false) continue
334
335 const files = await readdir(directoryPath)
336 for (const file of files) {
337 expect(file).to.not.contain(videoUUID)
338 }
339 }
340 }
341
342 async function uploadVideo (url: string, accessToken: string, videoAttributesArg: VideoAttributes, specialStatus = 200) {
343 const path = '/api/v1/videos/upload'
344 let defaultChannelId = '1'
345
346 try {
347 const res = await getMyUserInformation(url, accessToken)
348 defaultChannelId = res.body.videoChannels[0].id
349 } catch (e) { /* empty */ }
350
351 // Override default attributes
352 const attributes = Object.assign({
353 name: 'my super video',
354 category: 5,
355 licence: 4,
356 language: 'zh',
357 channelId: defaultChannelId,
358 nsfw: true,
359 waitTranscoding: false,
360 description: 'my super description',
361 support: 'my super support text',
362 tags: [ 'tag' ],
363 privacy: VideoPrivacy.PUBLIC,
364 commentsEnabled: true,
365 downloadEnabled: true,
366 fixture: 'video_short.webm'
367 }, videoAttributesArg)
368
369 const req = request(url)
370 .post(path)
371 .set('Accept', 'application/json')
372 .set('Authorization', 'Bearer ' + accessToken)
373 .field('name', attributes.name)
374 .field('nsfw', JSON.stringify(attributes.nsfw))
375 .field('commentsEnabled', JSON.stringify(attributes.commentsEnabled))
376 .field('downloadEnabled', JSON.stringify(attributes.downloadEnabled))
377 .field('waitTranscoding', JSON.stringify(attributes.waitTranscoding))
378 .field('privacy', attributes.privacy.toString())
379 .field('channelId', attributes.channelId)
380
381 if (attributes.support !== undefined) {
382 req.field('support', attributes.support)
383 }
384
385 if (attributes.description !== undefined) {
386 req.field('description', attributes.description)
387 }
388 if (attributes.language !== undefined) {
389 req.field('language', attributes.language.toString())
390 }
391 if (attributes.category !== undefined) {
392 req.field('category', attributes.category.toString())
393 }
394 if (attributes.licence !== undefined) {
395 req.field('licence', attributes.licence.toString())
396 }
397
398 const tags = attributes.tags || []
399 for (let i = 0; i < tags.length; i++) {
400 req.field('tags[' + i + ']', attributes.tags[i])
401 }
402
403 if (attributes.thumbnailfile !== undefined) {
404 req.attach('thumbnailfile', buildAbsoluteFixturePath(attributes.thumbnailfile))
405 }
406 if (attributes.previewfile !== undefined) {
407 req.attach('previewfile', buildAbsoluteFixturePath(attributes.previewfile))
408 }
409
410 if (attributes.scheduleUpdate) {
411 req.field('scheduleUpdate[updateAt]', attributes.scheduleUpdate.updateAt)
412
413 if (attributes.scheduleUpdate.privacy) {
414 req.field('scheduleUpdate[privacy]', attributes.scheduleUpdate.privacy)
415 }
416 }
417
418 if (attributes.originallyPublishedAt !== undefined) {
419 req.field('originallyPublishedAt', attributes.originallyPublishedAt)
420 }
421
422 return req.attach('videofile', buildAbsoluteFixturePath(attributes.fixture))
423 .expect(specialStatus)
424 }
425
426 function updateVideo (url: string, accessToken: string, id: number | string, attributes: VideoAttributes, statusCodeExpected = 204) {
427 const path = '/api/v1/videos/' + id
428 const body = {}
429
430 if (attributes.name) body['name'] = attributes.name
431 if (attributes.category) body['category'] = attributes.category
432 if (attributes.licence) body['licence'] = attributes.licence
433 if (attributes.language) body['language'] = attributes.language
434 if (attributes.nsfw !== undefined) body['nsfw'] = JSON.stringify(attributes.nsfw)
435 if (attributes.commentsEnabled !== undefined) body['commentsEnabled'] = JSON.stringify(attributes.commentsEnabled)
436 if (attributes.downloadEnabled !== undefined) body['downloadEnabled'] = JSON.stringify(attributes.downloadEnabled)
437 if (attributes.originallyPublishedAt !== undefined) body['originallyPublishedAt'] = attributes.originallyPublishedAt
438 if (attributes.description) body['description'] = attributes.description
439 if (attributes.tags) body['tags'] = attributes.tags
440 if (attributes.privacy) body['privacy'] = attributes.privacy
441 if (attributes.channelId) body['channelId'] = attributes.channelId
442 if (attributes.scheduleUpdate) body['scheduleUpdate'] = attributes.scheduleUpdate
443
444 // Upload request
445 if (attributes.thumbnailfile || attributes.previewfile) {
446 const attaches: any = {}
447 if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile
448 if (attributes.previewfile) attaches.previewfile = attributes.previewfile
449
450 return makeUploadRequest({
451 url,
452 method: 'PUT',
453 path,
454 token: accessToken,
455 fields: body,
456 attaches,
457 statusCodeExpected
458 })
459 }
460
461 return makePutBodyRequest({
462 url,
463 path,
464 fields: body,
465 token: accessToken,
466 statusCodeExpected
467 })
468 }
469
470 function rateVideo (url: string, accessToken: string, id: number, rating: string, specialStatus = 204) {
471 const path = '/api/v1/videos/' + id + '/rate'
472
473 return request(url)
474 .put(path)
475 .set('Accept', 'application/json')
476 .set('Authorization', 'Bearer ' + accessToken)
477 .send({ rating })
478 .expect(specialStatus)
479 }
480
481 function parseTorrentVideo (server: ServerInfo, videoUUID: string, resolution: number) {
482 return new Promise<any>((res, rej) => {
483 const torrentName = videoUUID + '-' + resolution + '.torrent'
484 const torrentPath = buildServerDirectory(server, join('torrents', torrentName))
485
486 readFile(torrentPath, (err, data) => {
487 if (err) return rej(err)
488
489 return res(parseTorrent(data))
490 })
491 })
492 }
493
494 async function completeVideoCheck (
495 url: string,
496 video: any,
497 attributes: {
498 name: string
499 category: number
500 licence: number
501 language: string
502 nsfw: boolean
503 commentsEnabled: boolean
504 downloadEnabled: boolean
505 description: string
506 publishedAt?: string
507 support: string
508 originallyPublishedAt?: string
509 account: {
510 name: string
511 host: string
512 }
513 isLocal: boolean
514 tags: string[]
515 privacy: number
516 likes?: number
517 dislikes?: number
518 duration: number
519 channel: {
520 displayName: string
521 name: string
522 description
523 isLocal: boolean
524 }
525 fixture: string
526 files: {
527 resolution: number
528 size: number
529 }[]
530 thumbnailfile?: string
531 previewfile?: string
532 }
533 ) {
534 if (!attributes.likes) attributes.likes = 0
535 if (!attributes.dislikes) attributes.dislikes = 0
536
537 expect(video.name).to.equal(attributes.name)
538 expect(video.category.id).to.equal(attributes.category)
539 expect(video.category.label).to.equal(attributes.category !== null ? VIDEO_CATEGORIES[attributes.category] : 'Misc')
540 expect(video.licence.id).to.equal(attributes.licence)
541 expect(video.licence.label).to.equal(attributes.licence !== null ? VIDEO_LICENCES[attributes.licence] : 'Unknown')
542 expect(video.language.id).to.equal(attributes.language)
543 expect(video.language.label).to.equal(attributes.language !== null ? VIDEO_LANGUAGES[attributes.language] : 'Unknown')
544 expect(video.privacy.id).to.deep.equal(attributes.privacy)
545 expect(video.privacy.label).to.deep.equal(VIDEO_PRIVACIES[attributes.privacy])
546 expect(video.nsfw).to.equal(attributes.nsfw)
547 expect(video.description).to.equal(attributes.description)
548 expect(video.account.id).to.be.a('number')
549 expect(video.account.host).to.equal(attributes.account.host)
550 expect(video.account.name).to.equal(attributes.account.name)
551 expect(video.channel.displayName).to.equal(attributes.channel.displayName)
552 expect(video.channel.name).to.equal(attributes.channel.name)
553 expect(video.likes).to.equal(attributes.likes)
554 expect(video.dislikes).to.equal(attributes.dislikes)
555 expect(video.isLocal).to.equal(attributes.isLocal)
556 expect(video.duration).to.equal(attributes.duration)
557 expect(dateIsValid(video.createdAt)).to.be.true
558 expect(dateIsValid(video.publishedAt)).to.be.true
559 expect(dateIsValid(video.updatedAt)).to.be.true
560
561 if (attributes.publishedAt) {
562 expect(video.publishedAt).to.equal(attributes.publishedAt)
563 }
564
565 if (attributes.originallyPublishedAt) {
566 expect(video.originallyPublishedAt).to.equal(attributes.originallyPublishedAt)
567 } else {
568 expect(video.originallyPublishedAt).to.be.null
569 }
570
571 const res = await getVideo(url, video.uuid)
572 const videoDetails: VideoDetails = res.body
573
574 expect(videoDetails.files).to.have.lengthOf(attributes.files.length)
575 expect(videoDetails.tags).to.deep.equal(attributes.tags)
576 expect(videoDetails.account.name).to.equal(attributes.account.name)
577 expect(videoDetails.account.host).to.equal(attributes.account.host)
578 expect(video.channel.displayName).to.equal(attributes.channel.displayName)
579 expect(video.channel.name).to.equal(attributes.channel.name)
580 expect(videoDetails.channel.host).to.equal(attributes.account.host)
581 expect(videoDetails.channel.isLocal).to.equal(attributes.channel.isLocal)
582 expect(dateIsValid(videoDetails.channel.createdAt.toString())).to.be.true
583 expect(dateIsValid(videoDetails.channel.updatedAt.toString())).to.be.true
584 expect(videoDetails.commentsEnabled).to.equal(attributes.commentsEnabled)
585 expect(videoDetails.downloadEnabled).to.equal(attributes.downloadEnabled)
586
587 for (const attributeFile of attributes.files) {
588 const file = videoDetails.files.find(f => f.resolution.id === attributeFile.resolution)
589 expect(file).not.to.be.undefined
590
591 let extension = extname(attributes.fixture)
592 // Transcoding enabled: extension will always be .mp4
593 if (attributes.files.length > 1) extension = '.mp4'
594
595 expect(file.magnetUri).to.have.lengthOf.above(2)
596 expect(file.torrentUrl).to.equal(`http://${attributes.account.host}/static/torrents/${videoDetails.uuid}-${file.resolution.id}.torrent`)
597 expect(file.fileUrl).to.equal(`http://${attributes.account.host}/static/webseed/${videoDetails.uuid}-${file.resolution.id}${extension}`)
598 expect(file.resolution.id).to.equal(attributeFile.resolution)
599 expect(file.resolution.label).to.equal(attributeFile.resolution + 'p')
600
601 const minSize = attributeFile.size - ((10 * attributeFile.size) / 100)
602 const maxSize = attributeFile.size + ((10 * attributeFile.size) / 100)
603 expect(
604 file.size,
605 'File size for resolution ' + file.resolution.label + ' outside confidence interval (' + minSize + '> size <' + maxSize + ')'
606 ).to.be.above(minSize).and.below(maxSize)
607
608 const torrent = await webtorrentAdd(file.magnetUri, true)
609 expect(torrent.files).to.be.an('array')
610 expect(torrent.files.length).to.equal(1)
611 expect(torrent.files[0].path).to.exist.and.to.not.equal('')
612 }
613
614 await testImage(url, attributes.thumbnailfile || attributes.fixture, videoDetails.thumbnailPath)
615
616 if (attributes.previewfile) {
617 await testImage(url, attributes.previewfile, videoDetails.previewPath)
618 }
619 }
620
621 async function videoUUIDToId (url: string, id: number | string) {
622 if (validator.isUUID('' + id) === false) return id
623
624 const res = await getVideo(url, id)
625 return res.body.id
626 }
627
628 async function uploadVideoAndGetId (options: {
629 server: ServerInfo
630 videoName: string
631 nsfw?: boolean
632 privacy?: VideoPrivacy
633 token?: string
634 }) {
635 const videoAttrs: any = { name: options.videoName }
636 if (options.nsfw) videoAttrs.nsfw = options.nsfw
637 if (options.privacy) videoAttrs.privacy = options.privacy
638
639 const res = await uploadVideo(options.server.url, options.token || options.server.accessToken, videoAttrs)
640
641 return { id: res.body.video.id, uuid: res.body.video.uuid }
642 }
643
644 async function getLocalIdByUUID (url: string, uuid: string) {
645 const res = await getVideo(url, uuid)
646
647 return res.body.id
648 }
649
650 // serverNumber starts from 1
651 async function uploadRandomVideoOnServers (servers: ServerInfo[], serverNumber: number, additionalParams: any = {}) {
652 const server = servers.find(s => s.serverNumber === serverNumber)
653 const res = await uploadRandomVideo(server, false, additionalParams)
654
655 await waitJobs(servers)
656
657 return res
658 }
659
660 async function uploadRandomVideo (server: ServerInfo, wait = true, additionalParams: any = {}) {
661 const prefixName = additionalParams.prefixName || ''
662 const name = prefixName + uuidv4()
663
664 const data = Object.assign({ name }, additionalParams)
665 const res = await uploadVideo(server.url, server.accessToken, data)
666
667 if (wait) await waitJobs([ server ])
668
669 return { uuid: res.body.video.uuid, name }
670 }
671
672 // ---------------------------------------------------------------------------
673
674 export {
675 getVideoDescription,
676 getVideoCategories,
677 uploadRandomVideo,
678 getVideoLicences,
679 videoUUIDToId,
680 getVideoPrivacies,
681 getVideoLanguages,
682 getMyVideos,
683 getAccountVideos,
684 getVideoChannelVideos,
685 getVideo,
686 getVideoFileMetadataUrl,
687 getVideoWithToken,
688 getVideosList,
689 removeAllVideos,
690 getVideosListPagination,
691 getVideosListSort,
692 removeVideo,
693 getVideosListWithToken,
694 uploadVideo,
695 getVideosWithFilters,
696 uploadRandomVideoOnServers,
697 updateVideo,
698 rateVideo,
699 viewVideo,
700 parseTorrentVideo,
701 getLocalVideos,
702 completeVideoCheck,
703 checkVideoFilesWereRemoved,
704 getPlaylistVideos,
705 uploadVideoAndGetId,
706 getLocalIdByUUID,
707 getVideoIdFromUUID
708 }