Remove HLS torrents
[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 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, buildServerDirectory } 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, search?: 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     .query({ search: search })
172
173   if (sort) req.query({ sort })
174
175   return req.set('Accept', 'application/json')
176     .set('Authorization', 'Bearer ' + accessToken)
177     .expect(200)
178     .expect('Content-Type', /json/)
179 }
180
181 function getAccountVideos (
182   url: string,
183   accessToken: string,
184   accountName: string,
185   start: number,
186   count: number,
187   sort?: string,
188   query: { nsfw?: boolean } = {}
189 ) {
190   const path = '/api/v1/accounts/' + accountName + '/videos'
191
192   return makeGetRequest({
193     url,
194     path,
195     query: immutableAssign(query, {
196       start,
197       count,
198       sort
199     }),
200     token: accessToken,
201     statusCodeExpected: 200
202   })
203 }
204
205 function getVideoChannelVideos (
206   url: string,
207   accessToken: string,
208   videoChannelName: string,
209   start: number,
210   count: number,
211   sort?: string,
212   query: { nsfw?: boolean } = {}
213 ) {
214   const path = '/api/v1/video-channels/' + videoChannelName + '/videos'
215
216   return makeGetRequest({
217     url,
218     path,
219     query: immutableAssign(query, {
220       start,
221       count,
222       sort
223     }),
224     token: accessToken,
225     statusCodeExpected: 200
226   })
227 }
228
229 function getPlaylistVideos (
230   url: string,
231   accessToken: string,
232   playlistId: number | string,
233   start: number,
234   count: number,
235   query: { nsfw?: boolean } = {}
236 ) {
237   const path = '/api/v1/video-playlists/' + playlistId + '/videos'
238
239   return makeGetRequest({
240     url,
241     path,
242     query: immutableAssign(query, {
243       start,
244       count
245     }),
246     token: accessToken,
247     statusCodeExpected: 200
248   })
249 }
250
251 function getVideosListPagination (url: string, start: number, count: number, sort?: string, skipCount?: boolean) {
252   const path = '/api/v1/videos'
253
254   const req = request(url)
255               .get(path)
256               .query({ start: start })
257               .query({ count: count })
258
259   if (sort) req.query({ sort })
260   if (skipCount) req.query({ skipCount })
261
262   return req.set('Accept', 'application/json')
263            .expect(200)
264            .expect('Content-Type', /json/)
265 }
266
267 function getVideosListSort (url: string, sort: string) {
268   const path = '/api/v1/videos'
269
270   return request(url)
271           .get(path)
272           .query({ sort: sort })
273           .set('Accept', 'application/json')
274           .expect(200)
275           .expect('Content-Type', /json/)
276 }
277
278 function getVideosWithFilters (url: string, query: { tagsAllOf: string[], categoryOneOf: number[] | number }) {
279   const path = '/api/v1/videos'
280
281   return request(url)
282     .get(path)
283     .query(query)
284     .set('Accept', 'application/json')
285     .expect(200)
286     .expect('Content-Type', /json/)
287 }
288
289 function removeVideo (url: string, token: string, id: number | string, expectedStatus = 204) {
290   const path = '/api/v1/videos'
291
292   return request(url)
293           .delete(path + '/' + id)
294           .set('Accept', 'application/json')
295           .set('Authorization', 'Bearer ' + token)
296           .expect(expectedStatus)
297 }
298
299 async function checkVideoFilesWereRemoved (
300   videoUUID: string,
301   serverNumber: number,
302   directories = [
303     'redundancy',
304     'videos',
305     'thumbnails',
306     'torrents',
307     'previews',
308     'captions',
309     join('playlists', 'hls'),
310     join('redundancy', 'hls')
311   ]
312 ) {
313   for (const directory of directories) {
314     const directoryPath = buildServerDirectory(serverNumber, 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.support !== undefined) {
366     req.field('support', attributes.support)
367   }
368
369   if (attributes.description !== undefined) {
370     req.field('description', attributes.description)
371   }
372   if (attributes.language !== undefined) {
373     req.field('language', attributes.language.toString())
374   }
375   if (attributes.category !== undefined) {
376     req.field('category', attributes.category.toString())
377   }
378   if (attributes.licence !== undefined) {
379     req.field('licence', attributes.licence.toString())
380   }
381
382   const tags = attributes.tags || []
383   for (let i = 0; i < tags.length; i++) {
384     req.field('tags[' + i + ']', attributes.tags[i])
385   }
386
387   if (attributes.thumbnailfile !== undefined) {
388     req.attach('thumbnailfile', buildAbsoluteFixturePath(attributes.thumbnailfile))
389   }
390   if (attributes.previewfile !== undefined) {
391     req.attach('previewfile', buildAbsoluteFixturePath(attributes.previewfile))
392   }
393
394   if (attributes.scheduleUpdate) {
395     req.field('scheduleUpdate[updateAt]', attributes.scheduleUpdate.updateAt)
396
397     if (attributes.scheduleUpdate.privacy) {
398       req.field('scheduleUpdate[privacy]', attributes.scheduleUpdate.privacy)
399     }
400   }
401
402   if (attributes.originallyPublishedAt !== undefined) {
403     req.field('originallyPublishedAt', attributes.originallyPublishedAt)
404   }
405
406   return req.attach('videofile', buildAbsoluteFixturePath(attributes.fixture))
407             .expect(specialStatus)
408 }
409
410 function updateVideo (url: string, accessToken: string, id: number | string, attributes: VideoAttributes, statusCodeExpected = 204) {
411   const path = '/api/v1/videos/' + id
412   const body = {}
413
414   if (attributes.name) body['name'] = attributes.name
415   if (attributes.category) body['category'] = attributes.category
416   if (attributes.licence) body['licence'] = attributes.licence
417   if (attributes.language) body['language'] = attributes.language
418   if (attributes.nsfw !== undefined) body['nsfw'] = JSON.stringify(attributes.nsfw)
419   if (attributes.commentsEnabled !== undefined) body['commentsEnabled'] = JSON.stringify(attributes.commentsEnabled)
420   if (attributes.downloadEnabled !== undefined) body['downloadEnabled'] = JSON.stringify(attributes.downloadEnabled)
421   if (attributes.originallyPublishedAt !== undefined) body['originallyPublishedAt'] = attributes.originallyPublishedAt
422   if (attributes.description) body['description'] = attributes.description
423   if (attributes.tags) body['tags'] = attributes.tags
424   if (attributes.privacy) body['privacy'] = attributes.privacy
425   if (attributes.channelId) body['channelId'] = attributes.channelId
426   if (attributes.scheduleUpdate) body['scheduleUpdate'] = attributes.scheduleUpdate
427
428   // Upload request
429   if (attributes.thumbnailfile || attributes.previewfile) {
430     const attaches: any = {}
431     if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile
432     if (attributes.previewfile) attaches.previewfile = attributes.previewfile
433
434     return makeUploadRequest({
435       url,
436       method: 'PUT',
437       path,
438       token: accessToken,
439       fields: body,
440       attaches,
441       statusCodeExpected
442     })
443   }
444
445   return makePutBodyRequest({
446     url,
447     path,
448     fields: body,
449     token: accessToken,
450     statusCodeExpected
451   })
452 }
453
454 function rateVideo (url: string, accessToken: string, id: number, rating: string, specialStatus = 204) {
455   const path = '/api/v1/videos/' + id + '/rate'
456
457   return request(url)
458           .put(path)
459           .set('Accept', 'application/json')
460           .set('Authorization', 'Bearer ' + accessToken)
461           .send({ rating })
462           .expect(specialStatus)
463 }
464
465 function parseTorrentVideo (server: ServerInfo, videoUUID: string, resolution: number) {
466   return new Promise<any>((res, rej) => {
467     const torrentName = videoUUID + '-' + resolution + '.torrent'
468     const torrentPath = join(root(), 'test' + server.internalServerNumber, 'torrents', torrentName)
469     readFile(torrentPath, (err, data) => {
470       if (err) return rej(err)
471
472       return res(parseTorrent(data))
473     })
474   })
475 }
476
477 async function completeVideoCheck (
478   url: string,
479   video: any,
480   attributes: {
481     name: string
482     category: number
483     licence: number
484     language: string
485     nsfw: boolean
486     commentsEnabled: boolean
487     downloadEnabled: boolean
488     description: string
489     publishedAt?: string
490     support: string
491     originallyPublishedAt?: string,
492     account: {
493       name: string
494       host: string
495     }
496     isLocal: boolean
497     tags: string[]
498     privacy: number
499     likes?: number
500     dislikes?: number
501     duration: number
502     channel: {
503       displayName: string
504       name: string
505       description
506       isLocal: boolean
507     }
508     fixture: string
509     files: {
510       resolution: number
511       size: number
512     }[],
513     thumbnailfile?: string
514     previewfile?: string
515   }
516 ) {
517   if (!attributes.likes) attributes.likes = 0
518   if (!attributes.dislikes) attributes.dislikes = 0
519
520   expect(video.name).to.equal(attributes.name)
521   expect(video.category.id).to.equal(attributes.category)
522   expect(video.category.label).to.equal(attributes.category !== null ? VIDEO_CATEGORIES[attributes.category] : 'Misc')
523   expect(video.licence.id).to.equal(attributes.licence)
524   expect(video.licence.label).to.equal(attributes.licence !== null ? VIDEO_LICENCES[attributes.licence] : 'Unknown')
525   expect(video.language.id).to.equal(attributes.language)
526   expect(video.language.label).to.equal(attributes.language !== null ? VIDEO_LANGUAGES[attributes.language] : 'Unknown')
527   expect(video.privacy.id).to.deep.equal(attributes.privacy)
528   expect(video.privacy.label).to.deep.equal(VIDEO_PRIVACIES[attributes.privacy])
529   expect(video.nsfw).to.equal(attributes.nsfw)
530   expect(video.description).to.equal(attributes.description)
531   expect(video.account.id).to.be.a('number')
532   expect(video.account.host).to.equal(attributes.account.host)
533   expect(video.account.name).to.equal(attributes.account.name)
534   expect(video.channel.displayName).to.equal(attributes.channel.displayName)
535   expect(video.channel.name).to.equal(attributes.channel.name)
536   expect(video.likes).to.equal(attributes.likes)
537   expect(video.dislikes).to.equal(attributes.dislikes)
538   expect(video.isLocal).to.equal(attributes.isLocal)
539   expect(video.duration).to.equal(attributes.duration)
540   expect(dateIsValid(video.createdAt)).to.be.true
541   expect(dateIsValid(video.publishedAt)).to.be.true
542   expect(dateIsValid(video.updatedAt)).to.be.true
543
544   if (attributes.publishedAt) {
545     expect(video.publishedAt).to.equal(attributes.publishedAt)
546   }
547
548   if (attributes.originallyPublishedAt) {
549     expect(video.originallyPublishedAt).to.equal(attributes.originallyPublishedAt)
550   } else {
551     expect(video.originallyPublishedAt).to.be.null
552   }
553
554   const res = await getVideo(url, video.uuid)
555   const videoDetails: VideoDetails = res.body
556
557   expect(videoDetails.files).to.have.lengthOf(attributes.files.length)
558   expect(videoDetails.tags).to.deep.equal(attributes.tags)
559   expect(videoDetails.account.name).to.equal(attributes.account.name)
560   expect(videoDetails.account.host).to.equal(attributes.account.host)
561   expect(video.channel.displayName).to.equal(attributes.channel.displayName)
562   expect(video.channel.name).to.equal(attributes.channel.name)
563   expect(videoDetails.channel.host).to.equal(attributes.account.host)
564   expect(videoDetails.channel.isLocal).to.equal(attributes.channel.isLocal)
565   expect(dateIsValid(videoDetails.channel.createdAt.toString())).to.be.true
566   expect(dateIsValid(videoDetails.channel.updatedAt.toString())).to.be.true
567   expect(videoDetails.commentsEnabled).to.equal(attributes.commentsEnabled)
568   expect(videoDetails.downloadEnabled).to.equal(attributes.downloadEnabled)
569
570   for (const attributeFile of attributes.files) {
571     const file = videoDetails.files.find(f => f.resolution.id === attributeFile.resolution)
572     expect(file).not.to.be.undefined
573
574     let extension = extname(attributes.fixture)
575     // Transcoding enabled: extension will always be .mp4
576     if (attributes.files.length > 1) extension = '.mp4'
577
578     expect(file.magnetUri).to.have.lengthOf.above(2)
579     expect(file.torrentUrl).to.equal(`http://${attributes.account.host}/static/torrents/${videoDetails.uuid}-${file.resolution.id}.torrent`)
580     expect(file.fileUrl).to.equal(`http://${attributes.account.host}/static/webseed/${videoDetails.uuid}-${file.resolution.id}${extension}`)
581     expect(file.resolution.id).to.equal(attributeFile.resolution)
582     expect(file.resolution.label).to.equal(attributeFile.resolution + 'p')
583
584     const minSize = attributeFile.size - ((10 * attributeFile.size) / 100)
585     const maxSize = attributeFile.size + ((10 * attributeFile.size) / 100)
586     expect(file.size,
587            'File size for resolution ' + file.resolution.label + ' outside confidence interval (' + minSize + '> size <' + maxSize + ')')
588       .to.be.above(minSize).and.below(maxSize)
589
590     const torrent = await webtorrentAdd(file.magnetUri, true)
591     expect(torrent.files).to.be.an('array')
592     expect(torrent.files.length).to.equal(1)
593     expect(torrent.files[0].path).to.exist.and.to.not.equal('')
594   }
595
596   await testImage(url, attributes.thumbnailfile || attributes.fixture, videoDetails.thumbnailPath)
597
598   if (attributes.previewfile) {
599     await testImage(url, attributes.previewfile, videoDetails.previewPath)
600   }
601 }
602
603 async function videoUUIDToId (url: string, id: number | string) {
604   if (validator.isUUID('' + id) === false) return id
605
606   const res = await getVideo(url, id)
607   return res.body.id
608 }
609
610 async function uploadVideoAndGetId (options: { server: ServerInfo, videoName: string, nsfw?: boolean, token?: string }) {
611   const videoAttrs: any = { name: options.videoName }
612   if (options.nsfw) videoAttrs.nsfw = options.nsfw
613
614   const res = await uploadVideo(options.server.url, options.token || options.server.accessToken, videoAttrs)
615
616   return { id: res.body.video.id, uuid: res.body.video.uuid }
617 }
618
619 // ---------------------------------------------------------------------------
620
621 export {
622   getVideoDescription,
623   getVideoCategories,
624   getVideoLicences,
625   videoUUIDToId,
626   getVideoPrivacies,
627   getVideoLanguages,
628   getMyVideos,
629   getAccountVideos,
630   getVideoChannelVideos,
631   getVideo,
632   getVideoWithToken,
633   getVideosList,
634   getVideosListPagination,
635   getVideosListSort,
636   removeVideo,
637   getVideosListWithToken,
638   uploadVideo,
639   getVideosWithFilters,
640   updateVideo,
641   rateVideo,
642   viewVideo,
643   parseTorrentVideo,
644   getLocalVideos,
645   completeVideoCheck,
646   checkVideoFilesWereRemoved,
647   getPlaylistVideos,
648   uploadVideoAndGetId
649 }