]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
27f59f3a9c920242ef34cf4d9c7d64f0c0bade34
[github/Chocobozzz/PeerTube.git] / server / models / video / video.ts
1 import * as safeBuffer from 'safe-buffer'
2 const Buffer = safeBuffer.Buffer
3 import * as magnetUtil from 'magnet-uri'
4 import { map } from 'lodash'
5 import * as parseTorrent from 'parse-torrent'
6 import { join } from 'path'
7 import * as Sequelize from 'sequelize'
8 import * as Promise from 'bluebird'
9 import { maxBy } from 'lodash'
10
11 import { TagInstance } from './tag-interface'
12 import {
13 logger,
14 isVideoNameValid,
15 isVideoCategoryValid,
16 isVideoLicenceValid,
17 isVideoLanguageValid,
18 isVideoNSFWValid,
19 isVideoDescriptionValid,
20 isVideoDurationValid,
21 readFileBufferPromise,
22 unlinkPromise,
23 renamePromise,
24 writeFilePromise,
25 createTorrentPromise,
26 statPromise,
27 generateImageFromVideoFile,
28 transcode,
29 getVideoFileHeight
30 } from '../../helpers'
31 import {
32 CONFIG,
33 REMOTE_SCHEME,
34 STATIC_PATHS,
35 VIDEO_CATEGORIES,
36 VIDEO_LICENCES,
37 VIDEO_LANGUAGES,
38 THUMBNAILS_SIZE
39 } from '../../initializers'
40 import { removeVideoToFriends } from '../../lib'
41 import { VideoResolution } from '../../../shared'
42 import { VideoFileInstance, VideoFileModel } from './video-file-interface'
43
44 import { addMethodsToModel, getSort } from '../utils'
45 import {
46 VideoInstance,
47 VideoAttributes,
48
49 VideoMethods
50 } from './video-interface'
51 import { PREVIEWS_SIZE } from '../../initializers/constants'
52
53 let Video: Sequelize.Model<VideoInstance, VideoAttributes>
54 let getOriginalFile: VideoMethods.GetOriginalFile
55 let getVideoFilename: VideoMethods.GetVideoFilename
56 let getThumbnailName: VideoMethods.GetThumbnailName
57 let getThumbnailPath: VideoMethods.GetThumbnailPath
58 let getPreviewName: VideoMethods.GetPreviewName
59 let getPreviewPath: VideoMethods.GetPreviewPath
60 let getTorrentFileName: VideoMethods.GetTorrentFileName
61 let isOwned: VideoMethods.IsOwned
62 let toFormattedJSON: VideoMethods.ToFormattedJSON
63 let toFormattedDetailsJSON: VideoMethods.ToFormattedDetailsJSON
64 let toAddRemoteJSON: VideoMethods.ToAddRemoteJSON
65 let toUpdateRemoteJSON: VideoMethods.ToUpdateRemoteJSON
66 let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
67 let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
68 let createPreview: VideoMethods.CreatePreview
69 let createThumbnail: VideoMethods.CreateThumbnail
70 let getVideoFilePath: VideoMethods.GetVideoFilePath
71 let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
72 let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
73 let getEmbedPath: VideoMethods.GetEmbedPath
74
75 let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
76 let list: VideoMethods.List
77 let listForApi: VideoMethods.ListForApi
78 let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID
79 let listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAndTags
80 let listOwnedByAuthor: VideoMethods.ListOwnedByAuthor
81 let load: VideoMethods.Load
82 let loadByUUID: VideoMethods.LoadByUUID
83 let loadLocalVideoByUUID: VideoMethods.LoadLocalVideoByUUID
84 let loadAndPopulateAuthor: VideoMethods.LoadAndPopulateAuthor
85 let loadAndPopulateAuthorAndPodAndTags: VideoMethods.LoadAndPopulateAuthorAndPodAndTags
86 let loadByUUIDAndPopulateAuthorAndPodAndTags: VideoMethods.LoadByUUIDAndPopulateAuthorAndPodAndTags
87 let searchAndPopulateAuthorAndPodAndTags: VideoMethods.SearchAndPopulateAuthorAndPodAndTags
88 let removeThumbnail: VideoMethods.RemoveThumbnail
89 let removePreview: VideoMethods.RemovePreview
90 let removeFile: VideoMethods.RemoveFile
91 let removeTorrent: VideoMethods.RemoveTorrent
92
93 export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
94 Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
95 {
96 uuid: {
97 type: DataTypes.UUID,
98 defaultValue: DataTypes.UUIDV4,
99 allowNull: false,
100 validate: {
101 isUUID: 4
102 }
103 },
104 name: {
105 type: DataTypes.STRING,
106 allowNull: false,
107 validate: {
108 nameValid: value => {
109 const res = isVideoNameValid(value)
110 if (res === false) throw new Error('Video name is not valid.')
111 }
112 }
113 },
114 category: {
115 type: DataTypes.INTEGER,
116 allowNull: false,
117 validate: {
118 categoryValid: value => {
119 const res = isVideoCategoryValid(value)
120 if (res === false) throw new Error('Video category is not valid.')
121 }
122 }
123 },
124 licence: {
125 type: DataTypes.INTEGER,
126 allowNull: false,
127 defaultValue: null,
128 validate: {
129 licenceValid: value => {
130 const res = isVideoLicenceValid(value)
131 if (res === false) throw new Error('Video licence is not valid.')
132 }
133 }
134 },
135 language: {
136 type: DataTypes.INTEGER,
137 allowNull: true,
138 validate: {
139 languageValid: value => {
140 const res = isVideoLanguageValid(value)
141 if (res === false) throw new Error('Video language is not valid.')
142 }
143 }
144 },
145 nsfw: {
146 type: DataTypes.BOOLEAN,
147 allowNull: false,
148 validate: {
149 nsfwValid: value => {
150 const res = isVideoNSFWValid(value)
151 if (res === false) throw new Error('Video nsfw attribute is not valid.')
152 }
153 }
154 },
155 description: {
156 type: DataTypes.STRING,
157 allowNull: false,
158 validate: {
159 descriptionValid: value => {
160 const res = isVideoDescriptionValid(value)
161 if (res === false) throw new Error('Video description is not valid.')
162 }
163 }
164 },
165 duration: {
166 type: DataTypes.INTEGER,
167 allowNull: false,
168 validate: {
169 durationValid: value => {
170 const res = isVideoDurationValid(value)
171 if (res === false) throw new Error('Video duration is not valid.')
172 }
173 }
174 },
175 views: {
176 type: DataTypes.INTEGER,
177 allowNull: false,
178 defaultValue: 0,
179 validate: {
180 min: 0,
181 isInt: true
182 }
183 },
184 likes: {
185 type: DataTypes.INTEGER,
186 allowNull: false,
187 defaultValue: 0,
188 validate: {
189 min: 0,
190 isInt: true
191 }
192 },
193 dislikes: {
194 type: DataTypes.INTEGER,
195 allowNull: false,
196 defaultValue: 0,
197 validate: {
198 min: 0,
199 isInt: true
200 }
201 },
202 remote: {
203 type: DataTypes.BOOLEAN,
204 allowNull: false,
205 defaultValue: false
206 }
207 },
208 {
209 indexes: [
210 {
211 fields: [ 'name' ]
212 },
213 {
214 fields: [ 'createdAt' ]
215 },
216 {
217 fields: [ 'duration' ]
218 },
219 {
220 fields: [ 'views' ]
221 },
222 {
223 fields: [ 'likes' ]
224 },
225 {
226 fields: [ 'uuid' ]
227 },
228 {
229 fields: [ 'channelId' ]
230 }
231 ],
232 hooks: {
233 afterDestroy
234 }
235 }
236 )
237
238 const classMethods = [
239 associate,
240
241 generateThumbnailFromData,
242 list,
243 listForApi,
244 listOwnedAndPopulateAuthorAndTags,
245 listOwnedByAuthor,
246 load,
247 loadAndPopulateAuthor,
248 loadAndPopulateAuthorAndPodAndTags,
249 loadByHostAndUUID,
250 loadByUUID,
251 loadLocalVideoByUUID,
252 loadByUUIDAndPopulateAuthorAndPodAndTags,
253 searchAndPopulateAuthorAndPodAndTags
254 ]
255 const instanceMethods = [
256 createPreview,
257 createThumbnail,
258 createTorrentAndSetInfoHash,
259 getPreviewName,
260 getPreviewPath,
261 getThumbnailName,
262 getThumbnailPath,
263 getTorrentFileName,
264 getVideoFilename,
265 getVideoFilePath,
266 getOriginalFile,
267 isOwned,
268 removeFile,
269 removePreview,
270 removeThumbnail,
271 removeTorrent,
272 toAddRemoteJSON,
273 toFormattedJSON,
274 toFormattedDetailsJSON,
275 toUpdateRemoteJSON,
276 optimizeOriginalVideofile,
277 transcodeOriginalVideofile,
278 getOriginalFileHeight,
279 getEmbedPath
280 ]
281 addMethodsToModel(Video, classMethods, instanceMethods)
282
283 return Video
284 }
285
286 // ------------------------------ METHODS ------------------------------
287
288 function associate (models) {
289 Video.belongsTo(models.VideoChannel, {
290 foreignKey: {
291 name: 'channelId',
292 allowNull: false
293 },
294 onDelete: 'cascade'
295 })
296
297 Video.belongsToMany(models.Tag, {
298 foreignKey: 'videoId',
299 through: models.VideoTag,
300 onDelete: 'cascade'
301 })
302
303 Video.hasMany(models.VideoAbuse, {
304 foreignKey: {
305 name: 'videoId',
306 allowNull: false
307 },
308 onDelete: 'cascade'
309 })
310
311 Video.hasMany(models.VideoFile, {
312 foreignKey: {
313 name: 'videoId',
314 allowNull: false
315 },
316 onDelete: 'cascade'
317 })
318 }
319
320 function afterDestroy (video: VideoInstance) {
321 const tasks = []
322
323 tasks.push(
324 video.removeThumbnail()
325 )
326
327 if (video.isOwned()) {
328 const removeVideoToFriendsParams = {
329 uuid: video.uuid
330 }
331
332 tasks.push(
333 video.removePreview(),
334 removeVideoToFriends(removeVideoToFriendsParams)
335 )
336
337 // Remove physical files and torrents
338 video.VideoFiles.forEach(file => {
339 tasks.push(video.removeFile(file))
340 tasks.push(video.removeTorrent(file))
341 })
342 }
343
344 return Promise.all(tasks)
345 .catch(err => {
346 logger.error('Some errors when removing files of video %s in after destroy hook.', video.uuid, err)
347 })
348 }
349
350 getOriginalFile = function (this: VideoInstance) {
351 if (Array.isArray(this.VideoFiles) === false) return undefined
352
353 // The original file is the file that have the higher resolution
354 return maxBy(this.VideoFiles, file => file.resolution)
355 }
356
357 getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
358 return this.uuid + '-' + videoFile.resolution + videoFile.extname
359 }
360
361 getThumbnailName = function (this: VideoInstance) {
362 // We always have a copy of the thumbnail
363 const extension = '.jpg'
364 return this.uuid + extension
365 }
366
367 getPreviewName = function (this: VideoInstance) {
368 const extension = '.jpg'
369 return this.uuid + extension
370 }
371
372 getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
373 const extension = '.torrent'
374 return this.uuid + '-' + videoFile.resolution + extension
375 }
376
377 isOwned = function (this: VideoInstance) {
378 return this.remote === false
379 }
380
381 createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
382 const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
383
384 return generateImageFromVideoFile(
385 this.getVideoFilePath(videoFile),
386 CONFIG.STORAGE.PREVIEWS_DIR,
387 this.getPreviewName(),
388 imageSize
389 )
390 }
391
392 createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
393 const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
394
395 return generateImageFromVideoFile(
396 this.getVideoFilePath(videoFile),
397 CONFIG.STORAGE.THUMBNAILS_DIR,
398 this.getThumbnailName(),
399 imageSize
400 )
401 }
402
403 getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
404 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
405 }
406
407 createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFileInstance) {
408 const options = {
409 announceList: [
410 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
411 ],
412 urlList: [
413 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
414 ]
415 }
416
417 return createTorrentPromise(this.getVideoFilePath(videoFile), options)
418 .then(torrent => {
419 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
420 logger.info('Creating torrent %s.', filePath)
421
422 return writeFilePromise(filePath, torrent).then(() => torrent)
423 })
424 .then(torrent => {
425 const parsedTorrent = parseTorrent(torrent)
426
427 videoFile.infoHash = parsedTorrent.infoHash
428 })
429 }
430
431 getEmbedPath = function (this: VideoInstance) {
432 return '/videos/embed/' + this.uuid
433 }
434
435 getThumbnailPath = function (this: VideoInstance) {
436 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
437 }
438
439 getPreviewPath = function (this: VideoInstance) {
440 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
441 }
442
443 toFormattedJSON = function (this: VideoInstance) {
444 let podHost
445
446 if (this.VideoChannel.Author.Pod) {
447 podHost = this.VideoChannel.Author.Pod.host
448 } else {
449 // It means it's our video
450 podHost = CONFIG.WEBSERVER.HOST
451 }
452
453 // Maybe our pod is not up to date and there are new categories since our version
454 let categoryLabel = VIDEO_CATEGORIES[this.category]
455 if (!categoryLabel) categoryLabel = 'Misc'
456
457 // Maybe our pod is not up to date and there are new licences since our version
458 let licenceLabel = VIDEO_LICENCES[this.licence]
459 if (!licenceLabel) licenceLabel = 'Unknown'
460
461 // Language is an optional attribute
462 let languageLabel = VIDEO_LANGUAGES[this.language]
463 if (!languageLabel) languageLabel = 'Unknown'
464
465 const json = {
466 id: this.id,
467 uuid: this.uuid,
468 name: this.name,
469 category: this.category,
470 categoryLabel,
471 licence: this.licence,
472 licenceLabel,
473 language: this.language,
474 languageLabel,
475 nsfw: this.nsfw,
476 description: this.description,
477 podHost,
478 isLocal: this.isOwned(),
479 author: this.VideoChannel.Author.name,
480 duration: this.duration,
481 views: this.views,
482 likes: this.likes,
483 dislikes: this.dislikes,
484 tags: map<TagInstance, string>(this.Tags, 'name'),
485 thumbnailPath: this.getThumbnailPath(),
486 previewPath: this.getPreviewPath(),
487 embedPath: this.getEmbedPath(),
488 createdAt: this.createdAt,
489 updatedAt: this.updatedAt
490 }
491
492 return json
493 }
494
495 toFormattedDetailsJSON = function (this: VideoInstance) {
496 let podHost
497
498 if (this.VideoChannel.Author.Pod) {
499 podHost = this.VideoChannel.Author.Pod.host
500 } else {
501 // It means it's our video
502 podHost = CONFIG.WEBSERVER.HOST
503 }
504
505 // Maybe our pod is not up to date and there are new categories since our version
506 let categoryLabel = VIDEO_CATEGORIES[this.category]
507 if (!categoryLabel) categoryLabel = 'Misc'
508
509 // Maybe our pod is not up to date and there are new licences since our version
510 let licenceLabel = VIDEO_LICENCES[this.licence]
511 if (!licenceLabel) licenceLabel = 'Unknown'
512
513 // Language is an optional attribute
514 let languageLabel = VIDEO_LANGUAGES[this.language]
515 if (!languageLabel) languageLabel = 'Unknown'
516
517 const json = {
518 id: this.id,
519 uuid: this.uuid,
520 name: this.name,
521 category: this.category,
522 categoryLabel,
523 licence: this.licence,
524 licenceLabel,
525 language: this.language,
526 languageLabel,
527 nsfw: this.nsfw,
528 description: this.description,
529 podHost,
530 isLocal: this.isOwned(),
531 author: this.VideoChannel.Author.name,
532 duration: this.duration,
533 views: this.views,
534 likes: this.likes,
535 dislikes: this.dislikes,
536 tags: map<TagInstance, string>(this.Tags, 'name'),
537 thumbnailPath: this.getThumbnailPath(),
538 previewPath: this.getPreviewPath(),
539 embedPath: this.getEmbedPath(),
540 createdAt: this.createdAt,
541 updatedAt: this.updatedAt,
542 channel: this.VideoChannel.toFormattedJSON(),
543 files: []
544 }
545
546 // Format and sort video files
547 const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
548 json.files = this.VideoFiles
549 .map(videoFile => {
550 let resolutionLabel = videoFile.resolution + 'p'
551
552 const videoFileJson = {
553 resolution: videoFile.resolution,
554 resolutionLabel,
555 magnetUri: generateMagnetUri(this, videoFile, baseUrlHttp, baseUrlWs),
556 size: videoFile.size,
557 torrentUrl: getTorrentUrl(this, videoFile, baseUrlHttp),
558 fileUrl: getVideoFileUrl(this, videoFile, baseUrlHttp)
559 }
560
561 return videoFileJson
562 })
563 .sort((a, b) => {
564 if (a.resolution < b.resolution) return 1
565 if (a.resolution === b.resolution) return 0
566 return -1
567 })
568
569 return json
570 }
571
572 toAddRemoteJSON = function (this: VideoInstance) {
573 // Get thumbnail data to send to the other pod
574 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
575
576 return readFileBufferPromise(thumbnailPath).then(thumbnailData => {
577 const remoteVideo = {
578 uuid: this.uuid,
579 name: this.name,
580 category: this.category,
581 licence: this.licence,
582 language: this.language,
583 nsfw: this.nsfw,
584 description: this.description,
585 channelUUID: this.VideoChannel.uuid,
586 duration: this.duration,
587 thumbnailData: thumbnailData.toString('binary'),
588 tags: map<TagInstance, string>(this.Tags, 'name'),
589 createdAt: this.createdAt,
590 updatedAt: this.updatedAt,
591 views: this.views,
592 likes: this.likes,
593 dislikes: this.dislikes,
594 files: []
595 }
596
597 this.VideoFiles.forEach(videoFile => {
598 remoteVideo.files.push({
599 infoHash: videoFile.infoHash,
600 resolution: videoFile.resolution,
601 extname: videoFile.extname,
602 size: videoFile.size
603 })
604 })
605
606 return remoteVideo
607 })
608 }
609
610 toUpdateRemoteJSON = function (this: VideoInstance) {
611 const json = {
612 uuid: this.uuid,
613 name: this.name,
614 category: this.category,
615 licence: this.licence,
616 language: this.language,
617 nsfw: this.nsfw,
618 description: this.description,
619 duration: this.duration,
620 tags: map<TagInstance, string>(this.Tags, 'name'),
621 createdAt: this.createdAt,
622 updatedAt: this.updatedAt,
623 views: this.views,
624 likes: this.likes,
625 dislikes: this.dislikes,
626 files: []
627 }
628
629 this.VideoFiles.forEach(videoFile => {
630 json.files.push({
631 infoHash: videoFile.infoHash,
632 resolution: videoFile.resolution,
633 extname: videoFile.extname,
634 size: videoFile.size
635 })
636 })
637
638 return json
639 }
640
641 optimizeOriginalVideofile = function (this: VideoInstance) {
642 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
643 const newExtname = '.mp4'
644 const inputVideoFile = this.getOriginalFile()
645 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
646 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
647
648 const transcodeOptions = {
649 inputPath: videoInputPath,
650 outputPath: videoOutputPath
651 }
652
653 return transcode(transcodeOptions)
654 .then(() => {
655 return unlinkPromise(videoInputPath)
656 })
657 .then(() => {
658 // Important to do this before getVideoFilename() to take in account the new file extension
659 inputVideoFile.set('extname', newExtname)
660
661 return renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
662 })
663 .then(() => {
664 return statPromise(this.getVideoFilePath(inputVideoFile))
665 })
666 .then(stats => {
667 return inputVideoFile.set('size', stats.size)
668 })
669 .then(() => {
670 return this.createTorrentAndSetInfoHash(inputVideoFile)
671 })
672 .then(() => {
673 return inputVideoFile.save()
674 })
675 .then(() => {
676 return undefined
677 })
678 .catch(err => {
679 // Auto destruction...
680 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
681
682 throw err
683 })
684 }
685
686 transcodeOriginalVideofile = function (this: VideoInstance, resolution: VideoResolution) {
687 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
688 const extname = '.mp4'
689
690 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
691 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
692
693 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
694 resolution,
695 extname,
696 size: 0,
697 videoId: this.id
698 })
699 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
700
701 const transcodeOptions = {
702 inputPath: videoInputPath,
703 outputPath: videoOutputPath,
704 resolution
705 }
706 return transcode(transcodeOptions)
707 .then(() => {
708 return statPromise(videoOutputPath)
709 })
710 .then(stats => {
711 newVideoFile.set('size', stats.size)
712
713 return undefined
714 })
715 .then(() => {
716 return this.createTorrentAndSetInfoHash(newVideoFile)
717 })
718 .then(() => {
719 return newVideoFile.save()
720 })
721 .then(() => {
722 return this.VideoFiles.push(newVideoFile)
723 })
724 .then(() => undefined)
725 }
726
727 getOriginalFileHeight = function (this: VideoInstance) {
728 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
729
730 return getVideoFileHeight(originalFilePath)
731 }
732
733 removeThumbnail = function (this: VideoInstance) {
734 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
735 return unlinkPromise(thumbnailPath)
736 }
737
738 removePreview = function (this: VideoInstance) {
739 // Same name than video thumbnail
740 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
741 }
742
743 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
744 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
745 return unlinkPromise(filePath)
746 }
747
748 removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
749 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
750 return unlinkPromise(torrentPath)
751 }
752
753 // ------------------------------ STATICS ------------------------------
754
755 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
756 // Creating the thumbnail for a remote video
757
758 const thumbnailName = video.getThumbnailName()
759 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
760 return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
761 return thumbnailName
762 })
763 }
764
765 list = function () {
766 const query = {
767 include: [ Video['sequelize'].models.VideoFile ]
768 }
769
770 return Video.findAll(query)
771 }
772
773 listForApi = function (start: number, count: number, sort: string) {
774 // Exclude blacklisted videos from the list
775 const query = {
776 distinct: true,
777 offset: start,
778 limit: count,
779 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
780 include: [
781 {
782 model: Video['sequelize'].models.VideoChannel,
783 include: [
784 {
785 model: Video['sequelize'].models.Author,
786 include: [
787 {
788 model: Video['sequelize'].models.Pod,
789 required: false
790 }
791 ]
792 }
793 ]
794 },
795 Video['sequelize'].models.Tag,
796 Video['sequelize'].models.VideoFile
797 ],
798 where: createBaseVideosWhere()
799 }
800
801 return Video.findAndCountAll(query).then(({ rows, count }) => {
802 return {
803 data: rows,
804 total: count
805 }
806 })
807 }
808
809 loadByHostAndUUID = function (fromHost: string, uuid: string, t?: Sequelize.Transaction) {
810 const query: Sequelize.FindOptions<VideoAttributes> = {
811 where: {
812 uuid
813 },
814 include: [
815 {
816 model: Video['sequelize'].models.VideoFile
817 },
818 {
819 model: Video['sequelize'].models.VideoChannel,
820 include: [
821 {
822 model: Video['sequelize'].models.Author,
823 include: [
824 {
825 model: Video['sequelize'].models.Pod,
826 required: true,
827 where: {
828 host: fromHost
829 }
830 }
831 ]
832 }
833 ]
834 }
835 ]
836 }
837
838 if (t !== undefined) query.transaction = t
839
840 return Video.findOne(query)
841 }
842
843 listOwnedAndPopulateAuthorAndTags = function () {
844 const query = {
845 where: {
846 remote: false
847 },
848 include: [
849 Video['sequelize'].models.VideoFile,
850 {
851 model: Video['sequelize'].models.VideoChannel,
852 include: [ Video['sequelize'].models.Author ]
853 },
854 Video['sequelize'].models.Tag
855 ]
856 }
857
858 return Video.findAll(query)
859 }
860
861 listOwnedByAuthor = function (author: string) {
862 const query = {
863 where: {
864 remote: false
865 },
866 include: [
867 {
868 model: Video['sequelize'].models.VideoFile
869 },
870 {
871 model: Video['sequelize'].models.VideoChannel,
872 include: [
873 {
874 model: Video['sequelize'].models.Author,
875 where: {
876 name: author
877 }
878 }
879 ]
880 }
881 ]
882 }
883
884 return Video.findAll(query)
885 }
886
887 load = function (id: number) {
888 return Video.findById(id)
889 }
890
891 loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
892 const query: Sequelize.FindOptions<VideoAttributes> = {
893 where: {
894 uuid
895 },
896 include: [ Video['sequelize'].models.VideoFile ]
897 }
898
899 if (t !== undefined) query.transaction = t
900
901 return Video.findOne(query)
902 }
903
904 loadLocalVideoByUUID = function (uuid: string, t?: Sequelize.Transaction) {
905 const query: Sequelize.FindOptions<VideoAttributes> = {
906 where: {
907 uuid,
908 remote: false
909 },
910 include: [ Video['sequelize'].models.VideoFile ]
911 }
912
913 if (t !== undefined) query.transaction = t
914
915 return Video.findOne(query)
916 }
917
918 loadAndPopulateAuthor = function (id: number) {
919 const options = {
920 include: [
921 Video['sequelize'].models.VideoFile,
922 {
923 model: Video['sequelize'].models.VideoChannel,
924 include: [ Video['sequelize'].models.Author ]
925 }
926 ]
927 }
928
929 return Video.findById(id, options)
930 }
931
932 loadAndPopulateAuthorAndPodAndTags = function (id: number) {
933 const options = {
934 include: [
935 {
936 model: Video['sequelize'].models.VideoChannel,
937 include: [
938 {
939 model: Video['sequelize'].models.Author,
940 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
941 }
942 ]
943 },
944 Video['sequelize'].models.Tag,
945 Video['sequelize'].models.VideoFile
946 ]
947 }
948
949 return Video.findById(id, options)
950 }
951
952 loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
953 const options = {
954 where: {
955 uuid
956 },
957 include: [
958 {
959 model: Video['sequelize'].models.VideoChannel,
960 include: [
961 {
962 model: Video['sequelize'].models.Author,
963 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
964 }
965 ]
966 },
967 Video['sequelize'].models.Tag,
968 Video['sequelize'].models.VideoFile
969 ]
970 }
971
972 return Video.findOne(options)
973 }
974
975 searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
976 const podInclude: Sequelize.IncludeOptions = {
977 model: Video['sequelize'].models.Pod,
978 required: false
979 }
980
981 const authorInclude: Sequelize.IncludeOptions = {
982 model: Video['sequelize'].models.Author,
983 include: [ podInclude ]
984 }
985
986 const videoChannelInclude: Sequelize.IncludeOptions = {
987 model: Video['sequelize'].models.VideoChannel,
988 include: [ authorInclude ],
989 required: true
990 }
991
992 const tagInclude: Sequelize.IncludeOptions = {
993 model: Video['sequelize'].models.Tag
994 }
995
996 const videoFileInclude: Sequelize.IncludeOptions = {
997 model: Video['sequelize'].models.VideoFile
998 }
999
1000 const query: Sequelize.FindOptions<VideoAttributes> = {
1001 distinct: true,
1002 where: createBaseVideosWhere(),
1003 offset: start,
1004 limit: count,
1005 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
1006 }
1007
1008 // Make an exact search with the magnet
1009 if (field === 'magnetUri') {
1010 videoFileInclude.where = {
1011 infoHash: magnetUtil.decode(value).infoHash
1012 }
1013 } else if (field === 'tags') {
1014 const escapedValue = Video['sequelize'].escape('%' + value + '%')
1015 query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal(
1016 `(SELECT "VideoTags"."videoId"
1017 FROM "Tags"
1018 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
1019 WHERE name ILIKE ${escapedValue}
1020 )`
1021 )
1022 } else if (field === 'host') {
1023 // FIXME: Include our pod? (not stored in the database)
1024 podInclude.where = {
1025 host: {
1026 [Sequelize.Op.iLike]: '%' + value + '%'
1027 }
1028 }
1029 podInclude.required = true
1030 } else if (field === 'author') {
1031 authorInclude.where = {
1032 name: {
1033 [Sequelize.Op.iLike]: '%' + value + '%'
1034 }
1035 }
1036 } else {
1037 query.where[field] = {
1038 [Sequelize.Op.iLike]: '%' + value + '%'
1039 }
1040 }
1041
1042 query.include = [
1043 videoChannelInclude, tagInclude, videoFileInclude
1044 ]
1045
1046 return Video.findAndCountAll(query).then(({ rows, count }) => {
1047 return {
1048 data: rows,
1049 total: count
1050 }
1051 })
1052 }
1053
1054 // ---------------------------------------------------------------------------
1055
1056 function createBaseVideosWhere () {
1057 return {
1058 id: {
1059 [Sequelize.Op.notIn]: Video['sequelize'].literal(
1060 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
1061 )
1062 }
1063 }
1064 }
1065
1066 function getBaseUrls (video: VideoInstance) {
1067 let baseUrlHttp
1068 let baseUrlWs
1069
1070 if (video.isOwned()) {
1071 baseUrlHttp = CONFIG.WEBSERVER.URL
1072 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1073 } else {
1074 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Author.Pod.host
1075 baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Author.Pod.host
1076 }
1077
1078 return { baseUrlHttp, baseUrlWs }
1079 }
1080
1081 function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1082 return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
1083 }
1084
1085 function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
1086 return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
1087 }
1088
1089 function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
1090 const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
1091 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1092 const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
1093
1094 const magnetHash = {
1095 xs,
1096 announce,
1097 urlList,
1098 infoHash: videoFile.infoHash,
1099 name: video.name
1100 }
1101
1102 return magnetUtil.encode(magnetHash)
1103 }