1 import * as safeBuffer from 'safe-buffer'
2 const Buffer = safeBuffer.Buffer
3 import * as createTorrent from 'create-torrent'
4 import * as ffmpeg from 'fluent-ffmpeg'
5 import * as fs from 'fs'
6 import * as magnetUtil from 'magnet-uri'
7 import { map, values } from 'lodash'
8 import { parallel, series } from 'async'
9 import * as parseTorrent from 'parse-torrent'
10 import { join } from 'path'
11 import * as Sequelize from 'sequelize'
13 import { database as db } from '../initializers/database'
14 import { VideoTagInstance } from './video-tag-interface'
22 isVideoDescriptionValid,
35 } from '../initializers'
36 import { JobScheduler, removeVideoToFriends } from '../lib'
38 import { addMethodsToModel, getSort } from './utils'
45 } from './video-interface'
47 let Video: Sequelize.Model<VideoInstance, VideoAttributes>
48 let generateMagnetUri: VideoMethods.GenerateMagnetUri
49 let getVideoFilename: VideoMethods.GetVideoFilename
50 let getThumbnailName: VideoMethods.GetThumbnailName
51 let getPreviewName: VideoMethods.GetPreviewName
52 let getTorrentName: VideoMethods.GetTorrentName
53 let isOwned: VideoMethods.IsOwned
54 let toFormatedJSON: VideoMethods.ToFormatedJSON
55 let toAddRemoteJSON: VideoMethods.ToAddRemoteJSON
56 let toUpdateRemoteJSON: VideoMethods.ToUpdateRemoteJSON
57 let transcodeVideofile: VideoMethods.TranscodeVideofile
59 let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
60 let getDurationFromFile: VideoMethods.GetDurationFromFile
61 let list: VideoMethods.List
62 let listForApi: VideoMethods.ListForApi
63 let loadByHostAndRemoteId: VideoMethods.LoadByHostAndRemoteId
64 let listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAndTags
65 let listOwnedByAuthor: VideoMethods.ListOwnedByAuthor
66 let load: VideoMethods.Load
67 let loadAndPopulateAuthor: VideoMethods.LoadAndPopulateAuthor
68 let loadAndPopulateAuthorAndPodAndTags: VideoMethods.LoadAndPopulateAuthorAndPodAndTags
69 let searchAndPopulateAuthorAndPodAndTags: VideoMethods.SearchAndPopulateAuthorAndPodAndTags
71 export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
72 Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
76 defaultValue: DataTypes.UUIDV4,
83 type: DataTypes.STRING,
86 nameValid: function (value) {
87 const res = isVideoNameValid(value)
88 if (res === false) throw new Error('Video name is not valid.')
93 type: DataTypes.ENUM(values(CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)),
104 type: DataTypes.INTEGER,
107 categoryValid: function (value) {
108 const res = isVideoCategoryValid(value)
109 if (res === false) throw new Error('Video category is not valid.')
114 type: DataTypes.INTEGER,
118 licenceValid: function (value) {
119 const res = isVideoLicenceValid(value)
120 if (res === false) throw new Error('Video licence is not valid.')
125 type: DataTypes.INTEGER,
128 languageValid: function (value) {
129 const res = isVideoLanguageValid(value)
130 if (res === false) throw new Error('Video language is not valid.')
135 type: DataTypes.BOOLEAN,
138 nsfwValid: function (value) {
139 const res = isVideoNSFWValid(value)
140 if (res === false) throw new Error('Video nsfw attribute is not valid.')
145 type: DataTypes.STRING,
148 descriptionValid: function (value) {
149 const res = isVideoDescriptionValid(value)
150 if (res === false) throw new Error('Video description is not valid.')
155 type: DataTypes.STRING,
158 infoHashValid: function (value) {
159 const res = isVideoInfoHashValid(value)
160 if (res === false) throw new Error('Video info hash is not valid.')
165 type: DataTypes.INTEGER,
168 durationValid: function (value) {
169 const res = isVideoDurationValid(value)
170 if (res === false) throw new Error('Video duration is not valid.')
175 type: DataTypes.INTEGER,
184 type: DataTypes.INTEGER,
193 type: DataTypes.INTEGER,
205 fields: [ 'authorId' ]
208 fields: [ 'remoteId' ]
214 fields: [ 'createdAt' ]
217 fields: [ 'duration' ]
220 fields: [ 'infoHash' ]
237 const classMethods = [
240 generateThumbnailFromData,
244 listOwnedAndPopulateAuthorAndTags,
247 loadByHostAndRemoteId,
248 loadAndPopulateAuthor,
249 loadAndPopulateAuthorAndPodAndTags,
250 searchAndPopulateAuthorAndPodAndTags
252 const instanceMethods = [
265 addMethodsToModel(Video, classMethods, instanceMethods)
270 function beforeValidate (video: VideoInstance) {
271 // Put a fake infoHash if it does not exists yet
272 if (video.isOwned() && !video.infoHash) {
274 video.infoHash = '0123456789abcdef0123456789abcdef01234567'
278 function beforeCreate (video: VideoInstance, options: { transaction: Sequelize.Transaction }) {
279 return new Promise(function (resolve, reject) {
282 if (video.isOwned()) {
283 const videoPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
286 function createVideoTorrent (callback) {
287 createTorrentFromVideo(video, videoPath, callback)
290 function createVideoThumbnail (callback) {
291 createThumbnail(video, videoPath, callback)
294 function createVideoPreview (callback) {
295 createPreview(video, videoPath, callback)
299 if (CONFIG.TRANSCODING.ENABLED === true) {
301 function createVideoTranscoderJob (callback) {
306 JobScheduler.Instance.createJob(options.transaction, 'videoTranscoder', dataInput, callback)
311 return parallel(tasks, function (err) {
312 if (err) return reject(err)
322 function afterDestroy (video: VideoInstance) {
323 return new Promise(function (resolve, reject) {
327 function (callback) {
328 removeThumbnail(video, callback)
332 if (video.isOwned()) {
334 function removeVideoFile (callback) {
335 removeFile(video, callback)
338 function removeVideoTorrent (callback) {
339 removeTorrent(video, callback)
342 function removeVideoPreview (callback) {
343 removePreview(video, callback)
346 function notifyFriends (callback) {
351 removeVideoToFriends(params)
358 parallel(tasks, function (err) {
359 if (err) return reject(err)
366 // ------------------------------ METHODS ------------------------------
368 function associate (models) {
369 Video.belongsTo(models.Author, {
377 Video.belongsToMany(models.Tag, {
378 foreignKey: 'videoId',
379 through: models.VideoTag,
383 Video.hasMany(models.VideoAbuse, {
392 generateMagnetUri = function () {
396 if (this.isOwned()) {
397 baseUrlHttp = CONFIG.WEBSERVER.URL
398 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
400 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
401 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
404 const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentName()
405 const announce = [ baseUrlWs + '/tracker/socket' ]
406 const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename() ]
412 infoHash: this.infoHash,
416 return magnetUtil.encode(magnetHash)
419 getVideoFilename = function () {
420 if (this.isOwned()) return this.id + this.extname
422 return this.remoteId + this.extname
425 getThumbnailName = function () {
426 // We always have a copy of the thumbnail
427 return this.id + '.jpg'
430 getPreviewName = function () {
431 const extension = '.jpg'
433 if (this.isOwned()) return this.id + extension
435 return this.remoteId + extension
438 getTorrentName = function () {
439 const extension = '.torrent'
441 if (this.isOwned()) return this.id + extension
443 return this.remoteId + extension
446 isOwned = function () {
447 return this.remoteId === null
450 toFormatedJSON = function (this: VideoInstance) {
453 if (this.Author.Pod) {
454 podHost = this.Author.Pod.host
456 // It means it's our video
457 podHost = CONFIG.WEBSERVER.HOST
460 // Maybe our pod is not up to date and there are new categories since our version
461 let categoryLabel = VIDEO_CATEGORIES[this.category]
462 if (!categoryLabel) categoryLabel = 'Misc'
464 // Maybe our pod is not up to date and there are new licences since our version
465 let licenceLabel = VIDEO_LICENCES[this.licence]
466 if (!licenceLabel) licenceLabel = 'Unknown'
468 // Language is an optional attribute
469 let languageLabel = VIDEO_LANGUAGES[this.language]
470 if (!languageLabel) languageLabel = 'Unknown'
475 category: this.category,
477 licence: this.licence,
479 language: this.language,
482 description: this.description,
484 isLocal: this.isOwned(),
485 magnetUri: this.generateMagnetUri(),
486 author: this.Author.name,
487 duration: this.duration,
490 dislikes: this.dislikes,
491 tags: map<VideoTagInstance, string>(this.Tags, 'name'),
492 thumbnailPath: join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()),
493 createdAt: this.createdAt,
494 updatedAt: this.updatedAt
500 toAddRemoteJSON = function (callback: VideoMethods.ToAddRemoteJSONCallback) {
501 // Get thumbnail data to send to the other pod
502 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
503 fs.readFile(thumbnailPath, (err, thumbnailData) => {
505 logger.error('Cannot read the thumbnail of the video')
509 const remoteVideo = {
511 category: this.category,
512 licence: this.licence,
513 language: this.language,
515 description: this.description,
516 infoHash: this.infoHash,
518 author: this.Author.name,
519 duration: this.duration,
520 thumbnailData: thumbnailData.toString('binary'),
521 tags: map<VideoTagInstance, string>(this.Tags, 'name'),
522 createdAt: this.createdAt,
523 updatedAt: this.updatedAt,
524 extname: this.extname,
527 dislikes: this.dislikes
530 return callback(null, remoteVideo)
534 toUpdateRemoteJSON = function () {
537 category: this.category,
538 licence: this.licence,
539 language: this.language,
541 description: this.description,
542 infoHash: this.infoHash,
544 author: this.Author.name,
545 duration: this.duration,
546 tags: map<VideoTagInstance, string>(this.Tags, 'name'),
547 createdAt: this.createdAt,
548 updatedAt: this.updatedAt,
549 extname: this.extname,
552 dislikes: this.dislikes
558 transcodeVideofile = function (finalCallback: VideoMethods.TranscodeVideofileCallback) {
561 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
562 const newExtname = '.mp4'
563 const videoInputPath = join(videosDirectory, video.getVideoFilename())
564 const videoOutputPath = join(videosDirectory, video.id + '-transcoded' + newExtname)
566 ffmpeg(videoInputPath)
567 .output(videoOutputPath)
568 .videoCodec('libx264')
569 .outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
570 .outputOption('-movflags faststart')
571 .on('error', finalCallback)
572 .on('end', function () {
574 function removeOldFile (callback) {
575 fs.unlink(videoInputPath, callback)
578 function moveNewFile (callback) {
579 // Important to do this before getVideoFilename() to take in account the new file extension
580 video.set('extname', newExtname)
582 const newVideoPath = join(videosDirectory, video.getVideoFilename())
583 fs.rename(videoOutputPath, newVideoPath, callback)
586 function torrent (callback) {
587 const newVideoPath = join(videosDirectory, video.getVideoFilename())
588 createTorrentFromVideo(video, newVideoPath, callback)
591 function videoExtension (callback) {
592 video.save().asCallback(callback)
595 ], function (err: Error) {
597 // Autodesctruction...
598 video.destroy().asCallback(function (err) {
599 if (err) logger.error('Cannot destruct video after transcoding failure.', { error: err })
602 return finalCallback(err)
605 return finalCallback(null)
611 // ------------------------------ STATICS ------------------------------
613 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string, callback: VideoMethods.GenerateThumbnailFromDataCallback) {
614 // Creating the thumbnail for a remote video
616 const thumbnailName = video.getThumbnailName()
617 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
618 fs.writeFile(thumbnailPath, Buffer.from(thumbnailData, 'binary'), function (err) {
619 if (err) return callback(err)
621 return callback(null, thumbnailName)
625 getDurationFromFile = function (videoPath: string, callback: VideoMethods.GetDurationFromFileCallback) {
626 ffmpeg.ffprobe(videoPath, function (err, metadata) {
627 if (err) return callback(err)
629 return callback(null, Math.floor(metadata.format.duration))
633 list = function (callback: VideoMethods.ListCallback) {
634 return Video.findAll().asCallback(callback)
637 listForApi = function (start: number, count: number, sort: string, callback: VideoMethods.ListForApiCallback) {
638 // Exclude Blakclisted videos from the list
643 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
646 model: Video['sequelize'].models.Author,
647 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
650 Video['sequelize'].models.Tag
652 where: createBaseVideosWhere()
655 return Video.findAndCountAll(query).asCallback(function (err, result) {
656 if (err) return callback(err)
658 return callback(null, result.rows, result.count)
662 loadByHostAndRemoteId = function (fromHost: string, remoteId: string, callback: VideoMethods.LoadByHostAndRemoteIdCallback) {
669 model: Video['sequelize'].models.Author,
672 model: Video['sequelize'].models.Pod,
683 return Video.findOne(query).asCallback(callback)
686 listOwnedAndPopulateAuthorAndTags = function (callback: VideoMethods.ListOwnedAndPopulateAuthorAndTagsCallback) {
687 // If remoteId is null this is *our* video
692 include: [ Video['sequelize'].models.Author, Video['sequelize'].models.Tag ]
695 return Video.findAll(query).asCallback(callback)
698 listOwnedByAuthor = function (author: string, callback: VideoMethods.ListOwnedByAuthorCallback) {
705 model: Video['sequelize'].models.Author,
713 return Video.findAll(query).asCallback(callback)
716 load = function (id: string, callback: VideoMethods.LoadCallback) {
717 return Video.findById(id).asCallback(callback)
720 loadAndPopulateAuthor = function (id: string, callback: VideoMethods.LoadAndPopulateAuthorCallback) {
722 include: [ Video['sequelize'].models.Author ]
725 return Video.findById(id, options).asCallback(callback)
728 loadAndPopulateAuthorAndPodAndTags = function (id: string, callback: VideoMethods.LoadAndPopulateAuthorAndPodAndTagsCallback) {
732 model: Video['sequelize'].models.Author,
733 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
735 Video['sequelize'].models.Tag
739 return Video.findById(id, options).asCallback(callback)
742 searchAndPopulateAuthorAndPodAndTags = function (
748 callback: VideoMethods.SearchAndPopulateAuthorAndPodAndTagsCallback
750 const podInclude: any = {
751 model: Video['sequelize'].models.Pod,
755 const authorInclude: any = {
756 model: Video['sequelize'].models.Author,
762 const tagInclude: any = {
763 model: Video['sequelize'].models.Tag
768 where: createBaseVideosWhere(),
771 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
774 // Make an exact search with the magnet
775 if (field === 'magnetUri') {
776 const infoHash = magnetUtil.decode(value).infoHash
777 query.where.infoHash = infoHash
778 } else if (field === 'tags') {
779 const escapedValue = Video['sequelize'].escape('%' + value + '%')
780 query.where.id.$in = Video['sequelize'].literal(
781 '(SELECT "VideoTags"."videoId" FROM "Tags" INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId" WHERE name LIKE ' + escapedValue + ')'
783 } else if (field === 'host') {
784 // FIXME: Include our pod? (not stored in the database)
787 $like: '%' + value + '%'
790 podInclude.required = true
791 } else if (field === 'author') {
792 authorInclude.where = {
794 $like: '%' + value + '%'
798 // authorInclude.or = true
800 query.where[field] = {
801 $like: '%' + value + '%'
806 authorInclude, tagInclude
809 if (tagInclude.where) {
810 // query.include.push([ Video['sequelize'].models.Tag ])
813 return Video.findAndCountAll(query).asCallback(function (err, result) {
814 if (err) return callback(err)
816 return callback(null, result.rows, result.count)
820 // ---------------------------------------------------------------------------
822 function createBaseVideosWhere () {
825 $notIn: Video['sequelize'].literal(
826 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
832 function removeThumbnail (video: VideoInstance, callback: (err: Error) => void) {
833 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
834 fs.unlink(thumbnailPath, callback)
837 function removeFile (video: VideoInstance, callback: (err: Error) => void) {
838 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
839 fs.unlink(filePath, callback)
842 function removeTorrent (video: VideoInstance, callback: (err: Error) => void) {
843 const torrenPath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
844 fs.unlink(torrenPath, callback)
847 function removePreview (video: VideoInstance, callback: (err: Error) => void) {
848 // Same name than video thumnail
849 fs.unlink(CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName(), callback)
852 function createTorrentFromVideo (video: VideoInstance, videoPath: string, callback: (err: Error) => void) {
855 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
858 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + video.getVideoFilename()
862 createTorrent(videoPath, options, function (err, torrent) {
863 if (err) return callback(err)
865 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
866 fs.writeFile(filePath, torrent, function (err) {
867 if (err) return callback(err)
869 const parsedTorrent = parseTorrent(torrent)
870 video.set('infoHash', parsedTorrent.infoHash)
871 video.validate().asCallback(callback)
876 function createPreview (video: VideoInstance, videoPath: string, callback: (err: Error) => void) {
877 generateImage(video, videoPath, CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), null, callback)
880 function createThumbnail (video: VideoInstance, videoPath: string, callback: (err: Error) => void) {
881 generateImage(video, videoPath, CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), THUMBNAILS_SIZE, callback)
884 type GenerateImageCallback = (err: Error, imageName: string) => void
885 function generateImage (video: VideoInstance, videoPath: string, folder: string, imageName: string, size: string, callback?: GenerateImageCallback) {
886 const options: any = {
897 .on('error', callback)
898 .on('end', function () {
899 callback(null, imageName)
904 function removeFromBlacklist (video: VideoInstance, callback: (err: Error) => void) {
905 // Find the blacklisted video
906 db.BlacklistedVideo.loadByVideoId(video.id, function (err, video) {
907 // If an error occured, stop here
909 logger.error('Error when fetching video from blacklist.', { error: err })
913 // If we found the video, remove it from the blacklist
915 video.destroy().asCallback(callback)
917 // If haven't found it, simply ignore it and do nothing
918 return callback(null)