1 import safeBuffer = require('safe-buffer')
2 const Buffer = safeBuffer.Buffer
3 import createTorrent = require('create-torrent')
4 import ffmpeg = require('fluent-ffmpeg')
5 import fs = require('fs')
6 import magnetUtil = require('magnet-uri')
7 import { map, values } from 'lodash'
8 import { parallel, series } from 'async'
9 import parseTorrent = require('parse-torrent')
10 import { join } from 'path'
12 const db = require('../initializers/database')
20 isVideoDescriptionValid,
33 } from '../initializers'
34 import { JobScheduler, removeVideoToFriends } from '../lib'
35 import { getSort } from './utils'
37 // ---------------------------------------------------------------------------
39 module.exports = function (sequelize, DataTypes) {
40 const Video = sequelize.define('Video',
44 defaultValue: DataTypes.UUIDV4,
51 type: DataTypes.STRING,
54 nameValid: function (value) {
55 const res = isVideoNameValid(value)
56 if (res === false) throw new Error('Video name is not valid.')
61 type: DataTypes.ENUM(values(CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)),
72 type: DataTypes.INTEGER,
75 categoryValid: function (value) {
76 const res = isVideoCategoryValid(value)
77 if (res === false) throw new Error('Video category is not valid.')
82 type: DataTypes.INTEGER,
86 licenceValid: function (value) {
87 const res = isVideoLicenceValid(value)
88 if (res === false) throw new Error('Video licence is not valid.')
93 type: DataTypes.INTEGER,
96 languageValid: function (value) {
97 const res = isVideoLanguageValid(value)
98 if (res === false) throw new Error('Video language is not valid.')
103 type: DataTypes.BOOLEAN,
106 nsfwValid: function (value) {
107 const res = isVideoNSFWValid(value)
108 if (res === false) throw new Error('Video nsfw attribute is not valid.')
113 type: DataTypes.STRING,
116 descriptionValid: function (value) {
117 const res = isVideoDescriptionValid(value)
118 if (res === false) throw new Error('Video description is not valid.')
123 type: DataTypes.STRING,
126 infoHashValid: function (value) {
127 const res = isVideoInfoHashValid(value)
128 if (res === false) throw new Error('Video info hash is not valid.')
133 type: DataTypes.INTEGER,
136 durationValid: function (value) {
137 const res = isVideoDurationValid(value)
138 if (res === false) throw new Error('Video duration is not valid.')
143 type: DataTypes.INTEGER,
152 type: DataTypes.INTEGER,
161 type: DataTypes.INTEGER,
173 fields: [ 'authorId' ]
176 fields: [ 'remoteId' ]
182 fields: [ 'createdAt' ]
185 fields: [ 'duration' ]
188 fields: [ 'infoHash' ]
200 generateThumbnailFromData,
204 listOwnedAndPopulateAuthorAndTags,
207 loadByHostAndRemoteId,
208 loadAndPopulateAuthor,
209 loadAndPopulateAuthorAndPodAndTags,
210 searchAndPopulateAuthorAndPodAndTags
236 function beforeValidate (video, options, next) {
237 // Put a fake infoHash if it does not exists yet
238 if (video.isOwned() && !video.infoHash) {
240 video.infoHash = '0123456789abcdef0123456789abcdef01234567'
246 function beforeCreate (video, options, next) {
249 if (video.isOwned()) {
250 const videoPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
253 function createVideoTorrent (callback) {
254 createTorrentFromVideo(video, videoPath, callback)
257 function createVideoThumbnail (callback) {
258 createThumbnail(video, videoPath, callback)
261 function createVideoPreview (callback) {
262 createPreview(video, videoPath, callback)
266 if (CONFIG.TRANSCODING.ENABLED === true) {
268 function createVideoTranscoderJob (callback) {
273 JobScheduler.Instance.createJob(options.transaction, 'videoTranscoder', dataInput, callback)
278 return parallel(tasks, next)
284 function afterDestroy (video, options, next) {
288 function (callback) {
289 removeThumbnail(video, callback)
293 if (video.isOwned()) {
295 function removeVideoFile (callback) {
296 removeFile(video, callback)
299 function removeVideoTorrent (callback) {
300 removeTorrent(video, callback)
303 function removeVideoPreview (callback) {
304 removePreview(video, callback)
307 function removeVideoToFriends (callback) {
312 removeVideoToFriends(params)
319 parallel(tasks, next)
322 // ------------------------------ METHODS ------------------------------
324 function associate (models) {
325 this.belongsTo(models.Author, {
333 this.belongsToMany(models.Tag, {
334 foreignKey: 'videoId',
335 through: models.VideoTag,
339 this.hasMany(models.VideoAbuse, {
348 function generateMagnetUri () {
352 if (this.isOwned()) {
353 baseUrlHttp = CONFIG.WEBSERVER.URL
354 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
356 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
357 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
360 const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentName()
361 const announce = baseUrlWs + '/tracker/socket'
362 const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename() ]
368 infoHash: this.infoHash,
372 return magnetUtil.encode(magnetHash)
375 function getVideoFilename () {
376 if (this.isOwned()) return this.id + this.extname
378 return this.remoteId + this.extname
381 function getThumbnailName () {
382 // We always have a copy of the thumbnail
383 return this.id + '.jpg'
386 function getPreviewName () {
387 const extension = '.jpg'
389 if (this.isOwned()) return this.id + extension
391 return this.remoteId + extension
394 function getTorrentName () {
395 const extension = '.torrent'
397 if (this.isOwned()) return this.id + extension
399 return this.remoteId + extension
402 function isOwned () {
403 return this.remoteId === null
406 function toFormatedJSON () {
409 if (this.Author.Pod) {
410 podHost = this.Author.Pod.host
412 // It means it's our video
413 podHost = CONFIG.WEBSERVER.HOST
416 // Maybe our pod is not up to date and there are new categories since our version
417 let categoryLabel = VIDEO_CATEGORIES[this.category]
418 if (!categoryLabel) categoryLabel = 'Misc'
420 // Maybe our pod is not up to date and there are new licences since our version
421 let licenceLabel = VIDEO_LICENCES[this.licence]
422 if (!licenceLabel) licenceLabel = 'Unknown'
424 // Language is an optional attribute
425 let languageLabel = VIDEO_LANGUAGES[this.language]
426 if (!languageLabel) languageLabel = 'Unknown'
431 category: this.category,
433 licence: this.licence,
435 language: this.language,
438 description: this.description,
440 isLocal: this.isOwned(),
441 magnetUri: this.generateMagnetUri(),
442 author: this.Author.name,
443 duration: this.duration,
446 dislikes: this.dislikes,
447 tags: map(this.Tags, 'name'),
448 thumbnailPath: join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()),
449 createdAt: this.createdAt,
450 updatedAt: this.updatedAt
456 function toAddRemoteJSON (callback) {
459 // Get thumbnail data to send to the other pod
460 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
461 fs.readFile(thumbnailPath, function (err, thumbnailData) {
463 logger.error('Cannot read the thumbnail of the video')
467 const remoteVideo = {
469 category: self.category,
470 licence: self.licence,
471 language: self.language,
473 description: self.description,
474 infoHash: self.infoHash,
476 author: self.Author.name,
477 duration: self.duration,
478 thumbnailData: thumbnailData.toString('binary'),
479 tags: map(self.Tags, 'name'),
480 createdAt: self.createdAt,
481 updatedAt: self.updatedAt,
482 extname: self.extname,
485 dislikes: self.dislikes
488 return callback(null, remoteVideo)
492 function toUpdateRemoteJSON (callback) {
495 category: this.category,
496 licence: this.licence,
497 language: this.language,
499 description: this.description,
500 infoHash: this.infoHash,
502 author: this.Author.name,
503 duration: this.duration,
504 tags: map(this.Tags, 'name'),
505 createdAt: this.createdAt,
506 updatedAt: this.updatedAt,
507 extname: this.extname,
510 dislikes: this.dislikes
516 function transcodeVideofile (finalCallback) {
519 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
520 const newExtname = '.mp4'
521 const videoInputPath = join(videosDirectory, video.getVideoFilename())
522 const videoOutputPath = join(videosDirectory, video.id + '-transcoded' + newExtname)
524 ffmpeg(videoInputPath)
525 .output(videoOutputPath)
526 .videoCodec('libx264')
527 .outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
528 .outputOption('-movflags faststart')
529 .on('error', finalCallback)
530 .on('end', function () {
532 function removeOldFile (callback) {
533 fs.unlink(videoInputPath, callback)
536 function moveNewFile (callback) {
537 // Important to do this before getVideoFilename() to take in account the new file extension
538 video.set('extname', newExtname)
540 const newVideoPath = join(videosDirectory, video.getVideoFilename())
541 fs.rename(videoOutputPath, newVideoPath, callback)
544 function torrent (callback) {
545 const newVideoPath = join(videosDirectory, video.getVideoFilename())
546 createTorrentFromVideo(video, newVideoPath, callback)
549 function videoExtension (callback) {
550 video.save().asCallback(callback)
555 // Autodescruction...
556 video.destroy().asCallback(function (err) {
557 if (err) logger.error('Cannot destruct video after transcoding failure.', { error: err })
560 return finalCallback(err)
563 return finalCallback(null)
569 // ------------------------------ STATICS ------------------------------
571 function generateThumbnailFromData (video, thumbnailData, callback) {
572 // Creating the thumbnail for a remote video
574 const thumbnailName = video.getThumbnailName()
575 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
576 fs.writeFile(thumbnailPath, Buffer.from(thumbnailData, 'binary'), function (err) {
577 if (err) return callback(err)
579 return callback(null, thumbnailName)
583 function getDurationFromFile (videoPath, callback) {
584 ffmpeg.ffprobe(videoPath, function (err, metadata) {
585 if (err) return callback(err)
587 return callback(null, Math.floor(metadata.format.duration))
591 function list (callback) {
592 return this.findAll().asCallback(callback)
595 function listForApi (start, count, sort, callback) {
596 // Exclude Blakclisted videos from the list
600 distinct: true, // For the count, a video can have many tags
601 order: [ getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ],
604 model: this.sequelize.models.Author,
605 include: [ { model: this.sequelize.models.Pod, required: false } ]
608 this.sequelize.models.Tag
610 where: createBaseVideosWhere.call(this)
613 return this.findAndCountAll(query).asCallback(function (err, result) {
614 if (err) return callback(err)
616 return callback(null, result.rows, result.count)
620 function loadByHostAndRemoteId (fromHost, remoteId, callback) {
627 model: this.sequelize.models.Author,
630 model: this.sequelize.models.Pod,
641 return this.findOne(query).asCallback(callback)
644 function listOwnedAndPopulateAuthorAndTags (callback) {
645 // If remoteId is null this is *our* video
650 include: [ this.sequelize.models.Author, this.sequelize.models.Tag ]
653 return this.findAll(query).asCallback(callback)
656 function listOwnedByAuthor (author, callback) {
663 model: this.sequelize.models.Author,
671 return this.findAll(query).asCallback(callback)
674 function load (id, callback) {
675 return this.findById(id).asCallback(callback)
678 function loadAndPopulateAuthor (id, callback) {
680 include: [ this.sequelize.models.Author ]
683 return this.findById(id, options).asCallback(callback)
686 function loadAndPopulateAuthorAndPodAndTags (id, callback) {
690 model: this.sequelize.models.Author,
691 include: [ { model: this.sequelize.models.Pod, required: false } ]
693 this.sequelize.models.Tag
697 return this.findById(id, options).asCallback(callback)
700 function searchAndPopulateAuthorAndPodAndTags (value, field, start, count, sort, callback) {
701 const podInclude: any = {
702 model: this.sequelize.models.Pod,
706 const authorInclude: any = {
707 model: this.sequelize.models.Author,
713 const tagInclude: any = {
714 model: this.sequelize.models.Tag
718 where: createBaseVideosWhere.call(this),
721 distinct: true, // For the count, a video can have many tags
722 order: [ getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ]
725 // Make an exact search with the magnet
726 if (field === 'magnetUri') {
727 const infoHash = magnetUtil.decode(value).infoHash
728 query.where.infoHash = infoHash
729 } else if (field === 'tags') {
730 const escapedValue = this.sequelize.escape('%' + value + '%')
731 query.where.id.$in = this.sequelize.literal(
732 '(SELECT "VideoTags"."videoId" FROM "Tags" INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId" WHERE name LIKE ' + escapedValue + ')'
734 } else if (field === 'host') {
735 // FIXME: Include our pod? (not stored in the database)
738 $like: '%' + value + '%'
741 podInclude.required = true
742 } else if (field === 'author') {
743 authorInclude.where = {
745 $like: '%' + value + '%'
749 // authorInclude.or = true
751 query.where[field] = {
752 $like: '%' + value + '%'
757 authorInclude, tagInclude
760 if (tagInclude.where) {
761 // query.include.push([ this.sequelize.models.Tag ])
764 return this.findAndCountAll(query).asCallback(function (err, result) {
765 if (err) return callback(err)
767 return callback(null, result.rows, result.count)
771 // ---------------------------------------------------------------------------
773 function createBaseVideosWhere () {
776 $notIn: this.sequelize.literal(
777 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
783 function removeThumbnail (video, callback) {
784 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
785 fs.unlink(thumbnailPath, callback)
788 function removeFile (video, callback) {
789 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
790 fs.unlink(filePath, callback)
793 function removeTorrent (video, callback) {
794 const torrenPath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
795 fs.unlink(torrenPath, callback)
798 function removePreview (video, callback) {
799 // Same name than video thumnail
800 fs.unlink(CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName(), callback)
803 function createTorrentFromVideo (video, videoPath, callback) {
806 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
809 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + video.getVideoFilename()
813 createTorrent(videoPath, options, function (err, torrent) {
814 if (err) return callback(err)
816 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
817 fs.writeFile(filePath, torrent, function (err) {
818 if (err) return callback(err)
820 const parsedTorrent = parseTorrent(torrent)
821 video.set('infoHash', parsedTorrent.infoHash)
822 video.validate().asCallback(callback)
827 function createPreview (video, videoPath, callback) {
828 generateImage(video, videoPath, CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), callback)
831 function createThumbnail (video, videoPath, callback) {
832 generateImage(video, videoPath, CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), THUMBNAILS_SIZE, callback)
835 function generateImage (video, videoPath, folder, imageName, size, callback?) {
836 const options: any = {
849 .on('error', callback)
850 .on('end', function () {
851 callback(null, imageName)
856 function removeFromBlacklist (video, callback) {
857 // Find the blacklisted video
858 db.BlacklistedVideo.loadByVideoId(video.id, function (err, video) {
859 // If an error occured, stop here
861 logger.error('Error when fetching video from blacklist.', { error: err })
865 // If we found the video, remove it from the blacklist
867 video.destroy().asCallback(callback)
869 // If haven't found it, simply ignore it and do nothing