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