3 const Buffer
= require('safe-buffer').Buffer
4 const createTorrent
= require('create-torrent')
5 const ffmpeg
= require('fluent-ffmpeg')
6 const fs
= require('fs')
7 const magnetUtil
= require('magnet-uri')
8 const map
= require('lodash/map')
9 const parallel
= require('async/parallel')
10 const series
= require('async/series')
11 const parseTorrent
= require('parse-torrent')
12 const pathUtils
= require('path')
13 const values
= require('lodash/values')
15 const constants
= require('../initializers/constants')
16 const logger
= require('../helpers/logger')
17 const friends
= require('../lib/friends')
18 const modelUtils
= require('./utils')
19 const customVideosValidators
= require('../helpers/custom-validators').videos
20 const db
= require('../initializers/database')
21 const jobScheduler
= require('../lib/jobs/job-scheduler')
23 // ---------------------------------------------------------------------------
25 module
.exports = function (sequelize
, DataTypes
) {
26 const Video
= sequelize
.define('Video',
30 defaultValue: DataTypes
.UUIDV4
,
37 type: DataTypes
.STRING
,
40 nameValid: function (value
) {
41 const res
= customVideosValidators
.isVideoNameValid(value
)
42 if (res
=== false) throw new Error('Video name is not valid.')
47 type: DataTypes
.ENUM(values(constants
.CONSTRAINTS_FIELDS
.VIDEOS
.EXTNAME
)),
58 type: DataTypes
.INTEGER
,
61 categoryValid: function (value
) {
62 const res
= customVideosValidators
.isVideoCategoryValid(value
)
63 if (res
=== false) throw new Error('Video category is not valid.')
68 type: DataTypes
.INTEGER
,
72 licenceValid: function (value
) {
73 const res
= customVideosValidators
.isVideoLicenceValid(value
)
74 if (res
=== false) throw new Error('Video licence is not valid.')
79 type: DataTypes
.INTEGER
,
82 languageValid: function (value
) {
83 const res
= customVideosValidators
.isVideoLanguageValid(value
)
84 if (res
=== false) throw new Error('Video language is not valid.')
89 type: DataTypes
.BOOLEAN
,
92 nsfwValid: function (value
) {
93 const res
= customVideosValidators
.isVideoNSFWValid(value
)
94 if (res
=== false) throw new Error('Video nsfw attribute is not valid.')
99 type: DataTypes
.STRING
,
102 descriptionValid: function (value
) {
103 const res
= customVideosValidators
.isVideoDescriptionValid(value
)
104 if (res
=== false) throw new Error('Video description is not valid.')
109 type: DataTypes
.STRING
,
112 infoHashValid: function (value
) {
113 const res
= customVideosValidators
.isVideoInfoHashValid(value
)
114 if (res
=== false) throw new Error('Video info hash is not valid.')
119 type: DataTypes
.INTEGER
,
122 durationValid: function (value
) {
123 const res
= customVideosValidators
.isVideoDurationValid(value
)
124 if (res
=== false) throw new Error('Video duration is not valid.')
129 type: DataTypes
.INTEGER
,
138 type: DataTypes
.INTEGER
,
147 type: DataTypes
.INTEGER
,
159 fields: [ 'authorId' ]
162 fields: [ 'remoteId' ]
168 fields: [ 'createdAt' ]
171 fields: [ 'duration' ]
174 fields: [ 'infoHash' ]
186 generateThumbnailFromData
,
190 listOwnedAndPopulateAuthorAndTags
,
193 loadByHostAndRemoteId
,
194 loadAndPopulateAuthor
,
195 loadAndPopulateAuthorAndPodAndTags
,
196 searchAndPopulateAuthorAndPodAndTags
222 function beforeValidate (video
, options
, next
) {
223 // Put a fake infoHash if it does not exists yet
224 if (video
.isOwned() && !video
.infoHash
) {
226 video
.infoHash
= '0123456789abcdef0123456789abcdef01234567'
232 function beforeCreate (video
, options
, next
) {
235 if (video
.isOwned()) {
236 const videoPath
= pathUtils
.join(constants
.CONFIG
.STORAGE
.VIDEOS_DIR
, video
.getVideoFilename())
239 function createVideoTorrent (callback
) {
240 createTorrentFromVideo(video
, videoPath
, callback
)
243 function createVideoThumbnail (callback
) {
244 createThumbnail(video
, videoPath
, callback
)
247 function createVideoPreview (callback
) {
248 createPreview(video
, videoPath
, callback
)
252 if (constants
.CONFIG
.TRANSCODING
.ENABLED
=== true) {
254 function createVideoTranscoderJob (callback
) {
259 jobScheduler
.createJob(options
.transaction
, 'videoTranscoder', dataInput
, callback
)
264 return parallel(tasks
, next
)
270 function afterDestroy (video
, options
, next
) {
274 function (callback
) {
275 removeThumbnail(video
, callback
)
279 if (video
.isOwned()) {
281 function removeVideoFile (callback
) {
282 removeFile(video
, callback
)
285 function removeVideoTorrent (callback
) {
286 removeTorrent(video
, callback
)
289 function removeVideoPreview (callback
) {
290 removePreview(video
, callback
)
293 function removeVideoToFriends (callback
) {
298 friends
.removeVideoToFriends(params
)
305 parallel(tasks
, next
)
308 // ------------------------------ METHODS ------------------------------
310 function associate (models
) {
311 this.belongsTo(models
.Author
, {
319 this.belongsToMany(models
.Tag
, {
320 foreignKey: 'videoId',
321 through: models
.VideoTag
,
325 this.hasMany(models
.VideoAbuse
, {
334 function generateMagnetUri () {
335 let baseUrlHttp
, baseUrlWs
337 if (this.isOwned()) {
338 baseUrlHttp
= constants
.CONFIG
.WEBSERVER
.URL
339 baseUrlWs
= constants
.CONFIG
.WEBSERVER
.WS
+ '://' + constants
.CONFIG
.WEBSERVER
.HOSTNAME
+ ':' + constants
.CONFIG
.WEBSERVER
.PORT
341 baseUrlHttp
= constants
.REMOTE_SCHEME
.HTTP
+ '://' + this.Author
.Pod
.host
342 baseUrlWs
= constants
.REMOTE_SCHEME
.WS
+ '://' + this.Author
.Pod
.host
345 const xs
= baseUrlHttp
+ constants
.STATIC_PATHS
.TORRENTS
+ this.getTorrentName()
346 const announce
= baseUrlWs
+ '/tracker/socket'
347 const urlList
= [ baseUrlHttp
+ constants
.STATIC_PATHS
.WEBSEED
+ this.getVideoFilename() ]
353 infoHash: this.infoHash
,
357 return magnetUtil
.encode(magnetHash
)
360 function getVideoFilename () {
361 if (this.isOwned()) return this.id
+ this.extname
363 return this.remoteId
+ this.extname
366 function getThumbnailName () {
367 // We always have a copy of the thumbnail
368 return this.id
+ '.jpg'
371 function getPreviewName () {
372 const extension
= '.jpg'
374 if (this.isOwned()) return this.id
+ extension
376 return this.remoteId
+ extension
379 function getTorrentName () {
380 const extension
= '.torrent'
382 if (this.isOwned()) return this.id
+ extension
384 return this.remoteId
+ extension
387 function isOwned () {
388 return this.remoteId
=== null
391 function toFormatedJSON () {
394 if (this.Author
.Pod
) {
395 podHost
= this.Author
.Pod
.host
397 // It means it's our video
398 podHost
= constants
.CONFIG
.WEBSERVER
.HOST
401 // Maybe our pod is not up to date and there are new categories since our version
402 let categoryLabel
= constants
.VIDEO_CATEGORIES
[this.category
]
403 if (!categoryLabel
) categoryLabel
= 'Misc'
405 // Maybe our pod is not up to date and there are new licences since our version
406 let licenceLabel
= constants
.VIDEO_LICENCES
[this.licence
]
407 if (!licenceLabel
) licenceLabel
= 'Unknown'
409 // Language is an optional attribute
410 let languageLabel
= constants
.VIDEO_LANGUAGES
[this.language
]
411 if (!languageLabel
) languageLabel
= 'Unknown'
416 category: this.category
,
418 licence: this.licence
,
420 language: this.language
,
423 description: this.description
,
425 isLocal: this.isOwned(),
426 magnetUri: this.generateMagnetUri(),
427 author: this.Author
.name
,
428 duration: this.duration
,
431 dislikes: this.dislikes
,
432 tags: map(this.Tags
, 'name'),
433 thumbnailPath: pathUtils
.join(constants
.STATIC_PATHS
.THUMBNAILS
, this.getThumbnailName()),
434 createdAt: this.createdAt
,
435 updatedAt: this.updatedAt
441 function toAddRemoteJSON (callback
) {
444 // Get thumbnail data to send to the other pod
445 const thumbnailPath
= pathUtils
.join(constants
.CONFIG
.STORAGE
.THUMBNAILS_DIR
, this.getThumbnailName())
446 fs
.readFile(thumbnailPath
, function (err
, thumbnailData
) {
448 logger
.error('Cannot read the thumbnail of the video')
452 const remoteVideo
= {
454 category: self
.category
,
455 licence: self
.licence
,
456 language: self
.language
,
458 description: self
.description
,
459 infoHash: self
.infoHash
,
461 author: self
.Author
.name
,
462 duration: self
.duration
,
463 thumbnailData: thumbnailData
.toString('binary'),
464 tags: map(self
.Tags
, 'name'),
465 createdAt: self
.createdAt
,
466 updatedAt: self
.updatedAt
,
467 extname: self
.extname
,
470 dislikes: self
.dislikes
473 return callback(null, remoteVideo
)
477 function toUpdateRemoteJSON (callback
) {
480 category: this.category
,
481 licence: this.licence
,
482 language: this.language
,
484 description: this.description
,
485 infoHash: this.infoHash
,
487 author: this.Author
.name
,
488 duration: this.duration
,
489 tags: map(this.Tags
, 'name'),
490 createdAt: this.createdAt
,
491 updatedAt: this.updatedAt
,
492 extname: this.extname
,
495 dislikes: this.dislikes
501 function transcodeVideofile (finalCallback
) {
504 const videosDirectory
= constants
.CONFIG
.STORAGE
.VIDEOS_DIR
505 const newExtname
= '.mp4'
506 const videoInputPath
= pathUtils
.join(videosDirectory
, video
.getVideoFilename())
507 const videoOutputPath
= pathUtils
.join(videosDirectory
, video
.id
+ '-transcoded' + newExtname
)
509 ffmpeg(videoInputPath
)
510 .output(videoOutputPath
)
511 .videoCodec('libx264')
512 .outputOption('-threads ' + constants
.CONFIG
.TRANSCODING
.THREADS
)
513 .outputOption('-movflags faststart')
514 .on('error', finalCallback
)
515 .on('end', function () {
517 function removeOldFile (callback
) {
518 fs
.unlink(videoInputPath
, callback
)
521 function moveNewFile (callback
) {
522 // Important to do this before getVideoFilename() to take in account the new file extension
523 video
.set('extname', newExtname
)
525 const newVideoPath
= pathUtils
.join(videosDirectory
, video
.getVideoFilename())
526 fs
.rename(videoOutputPath
, newVideoPath
, callback
)
529 function torrent (callback
) {
530 const newVideoPath
= pathUtils
.join(videosDirectory
, video
.getVideoFilename())
531 createTorrentFromVideo(video
, newVideoPath
, callback
)
534 function videoExtension (callback
) {
535 video
.save().asCallback(callback
)
540 // Autodescruction...
541 video
.destroy().asCallback(function (err
) {
542 if (err
) logger
.error('Cannot destruct video after transcoding failure.', { error: err
})
545 return finalCallback(err
)
548 return finalCallback(null)
554 // ------------------------------ STATICS ------------------------------
556 function generateThumbnailFromData (video
, thumbnailData
, callback
) {
557 // Creating the thumbnail for a remote video
559 const thumbnailName
= video
.getThumbnailName()
560 const thumbnailPath
= pathUtils
.join(constants
.CONFIG
.STORAGE
.THUMBNAILS_DIR
, thumbnailName
)
561 fs
.writeFile(thumbnailPath
, Buffer
.from(thumbnailData
, 'binary'), function (err
) {
562 if (err
) return callback(err
)
564 return callback(null, thumbnailName
)
568 function getDurationFromFile (videoPath
, callback
) {
569 ffmpeg
.ffprobe(videoPath
, function (err
, metadata
) {
570 if (err
) return callback(err
)
572 return callback(null, Math
.floor(metadata
.format
.duration
))
576 function list (callback
) {
577 return this.findAll().asCallback(callback
)
580 function listForApi (start
, count
, sort
, callback
) {
581 // Exclude Blakclisted videos from the list
585 distinct: true, // For the count, a video can have many tags
586 order: [ modelUtils
.getSort(sort
), [ this.sequelize
.models
.Tag
, 'name', 'ASC' ] ],
589 model: this.sequelize
.models
.Author
,
590 include: [ { model: this.sequelize
.models
.Pod
, required: false } ]
593 this.sequelize
.models
.Tag
595 where: createBaseVideosWhere
.call(this)
598 return this.findAndCountAll(query
).asCallback(function (err
, result
) {
599 if (err
) return callback(err
)
601 return callback(null, result
.rows
, result
.count
)
605 function loadByHostAndRemoteId (fromHost
, remoteId
, callback
) {
612 model: this.sequelize
.models
.Author
,
615 model: this.sequelize
.models
.Pod
,
626 return this.findOne(query
).asCallback(callback
)
629 function listOwnedAndPopulateAuthorAndTags (callback
) {
630 // If remoteId is null this is *our* video
635 include: [ this.sequelize
.models
.Author
, this.sequelize
.models
.Tag
]
638 return this.findAll(query
).asCallback(callback
)
641 function listOwnedByAuthor (author
, callback
) {
648 model: this.sequelize
.models
.Author
,
656 return this.findAll(query
).asCallback(callback
)
659 function load (id
, callback
) {
660 return this.findById(id
).asCallback(callback
)
663 function loadAndPopulateAuthor (id
, callback
) {
665 include: [ this.sequelize
.models
.Author
]
668 return this.findById(id
, options
).asCallback(callback
)
671 function loadAndPopulateAuthorAndPodAndTags (id
, callback
) {
675 model: this.sequelize
.models
.Author
,
676 include: [ { model: this.sequelize
.models
.Pod
, required: false } ]
678 this.sequelize
.models
.Tag
682 return this.findById(id
, options
).asCallback(callback
)
685 function searchAndPopulateAuthorAndPodAndTags (value
, field
, start
, count
, sort
, callback
) {
687 model: this.sequelize
.models
.Pod
,
691 const authorInclude
= {
692 model: this.sequelize
.models
.Author
,
699 model: this.sequelize
.models
.Tag
703 where: createBaseVideosWhere
.call(this),
706 distinct: true, // For the count, a video can have many tags
707 order: [ modelUtils
.getSort(sort
), [ this.sequelize
.models
.Tag
, 'name', 'ASC' ] ]
710 // Make an exact search with the magnet
711 if (field
=== 'magnetUri') {
712 const infoHash
= magnetUtil
.decode(value
).infoHash
713 query
.where
.infoHash
= infoHash
714 } else if (field
=== 'tags') {
715 const escapedValue
= this.sequelize
.escape('%' + value
+ '%')
716 query
.where
.id
.$in = this.sequelize
.literal(
717 '(SELECT "VideoTags"."videoId" FROM "Tags" INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId" WHERE name LIKE ' + escapedValue
+ ')'
719 } else if (field
=== 'host') {
720 // FIXME: Include our pod? (not stored in the database)
723 $like: '%' + value
+ '%'
726 podInclude
.required
= true
727 } else if (field
=== 'author') {
728 authorInclude
.where
= {
730 $like: '%' + value
+ '%'
734 // authorInclude.or = true
736 query
.where
[field
] = {
737 $like: '%' + value
+ '%'
742 authorInclude
, tagInclude
745 if (tagInclude
.where
) {
746 // query.include.push([ this.sequelize.models.Tag ])
749 return this.findAndCountAll(query
).asCallback(function (err
, result
) {
750 if (err
) return callback(err
)
752 return callback(null, result
.rows
, result
.count
)
756 // ---------------------------------------------------------------------------
758 function createBaseVideosWhere () {
761 $notIn: this.sequelize
.literal(
762 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
768 function removeThumbnail (video
, callback
) {
769 const thumbnailPath
= pathUtils
.join(constants
.CONFIG
.STORAGE
.THUMBNAILS_DIR
, video
.getThumbnailName())
770 fs
.unlink(thumbnailPath
, callback
)
773 function removeFile (video
, callback
) {
774 const filePath
= pathUtils
.join(constants
.CONFIG
.STORAGE
.VIDEOS_DIR
, video
.getVideoFilename())
775 fs
.unlink(filePath
, callback
)
778 function removeTorrent (video
, callback
) {
779 const torrenPath
= pathUtils
.join(constants
.CONFIG
.STORAGE
.TORRENTS_DIR
, video
.getTorrentName())
780 fs
.unlink(torrenPath
, callback
)
783 function removePreview (video
, callback
) {
784 // Same name than video thumnail
785 fs
.unlink(constants
.CONFIG
.STORAGE
.PREVIEWS_DIR
+ video
.getPreviewName(), callback
)
788 function createTorrentFromVideo (video
, videoPath
, callback
) {
791 [ constants
.CONFIG
.WEBSERVER
.WS
+ '://' + constants
.CONFIG
.WEBSERVER
.HOSTNAME
+ ':' + constants
.CONFIG
.WEBSERVER
.PORT
+ '/tracker/socket' ]
794 constants
.CONFIG
.WEBSERVER
.URL
+ constants
.STATIC_PATHS
.WEBSEED
+ video
.getVideoFilename()
798 createTorrent(videoPath
, options
, function (err
, torrent
) {
799 if (err
) return callback(err
)
801 const filePath
= pathUtils
.join(constants
.CONFIG
.STORAGE
.TORRENTS_DIR
, video
.getTorrentName())
802 fs
.writeFile(filePath
, torrent
, function (err
) {
803 if (err
) return callback(err
)
805 const parsedTorrent
= parseTorrent(torrent
)
806 video
.set('infoHash', parsedTorrent
.infoHash
)
807 video
.validate().asCallback(callback
)
812 function createPreview (video
, videoPath
, callback
) {
813 generateImage(video
, videoPath
, constants
.CONFIG
.STORAGE
.PREVIEWS_DIR
, video
.getPreviewName(), callback
)
816 function createThumbnail (video
, videoPath
, callback
) {
817 generateImage(video
, videoPath
, constants
.CONFIG
.STORAGE
.THUMBNAILS_DIR
, video
.getThumbnailName(), constants
.THUMBNAILS_SIZE
, callback
)
820 function generateImage (video
, videoPath
, folder
, imageName
, size
, callback
) {
834 .on('error', callback
)
835 .on('end', function () {
836 callback(null, imageName
)
841 function removeFromBlacklist (video
, callback
) {
842 // Find the blacklisted video
843 db
.BlacklistedVideo
.loadByVideoId(video
.id
, function (err
, video
) {
844 // If an error occured, stop here
846 logger
.error('Error when fetching video from blacklist.', { error: err
})
850 // If we found the video, remove it from the blacklist
852 video
.destroy().asCallback(callback
)
854 // If haven't found it, simply ignore it and do nothing