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 parseTorrent = require('parse-torrent')
11 const pathUtils = require('path')
12 const values = require('lodash/values')
14 const constants = require('../initializers/constants')
15 const logger = require('../helpers/logger')
16 const friends = require('../lib/friends')
17 const modelUtils = require('./utils')
18 const customVideosValidators = require('../helpers/custom-validators').videos
20 // ---------------------------------------------------------------------------
22 module.exports = function (sequelize, DataTypes) {
23 const Video = sequelize.define('Video',
27 defaultValue: DataTypes.UUIDV4,
34 type: DataTypes.STRING,
37 nameValid: function (value) {
38 const res = customVideosValidators.isVideoNameValid(value)
39 if (res === false) throw new Error('Video name is not valid.')
44 type: DataTypes.ENUM(values(constants.CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)),
55 type: DataTypes.INTEGER,
58 categoryValid: function (value) {
59 const res = customVideosValidators.isVideoCategoryValid(value)
60 if (res === false) throw new Error('Video category is not valid.')
65 type: DataTypes.INTEGER,
69 licenceValid: function (value) {
70 const res = customVideosValidators.isVideoLicenceValid(value)
71 if (res === false) throw new Error('Video licence is not valid.')
76 type: DataTypes.INTEGER,
79 languageValid: function (value) {
80 const res = customVideosValidators.isVideoLanguageValid(value)
81 if (res === false) throw new Error('Video language is not valid.')
86 type: DataTypes.BOOLEAN,
89 nsfwValid: function (value) {
90 const res = customVideosValidators.isVideoNSFWValid(value)
91 if (res === false) throw new Error('Video nsfw attribute is not valid.')
96 type: DataTypes.STRING,
99 descriptionValid: function (value) {
100 const res = customVideosValidators.isVideoDescriptionValid(value)
101 if (res === false) throw new Error('Video description is not valid.')
106 type: DataTypes.STRING,
109 infoHashValid: function (value) {
110 const res = customVideosValidators.isVideoInfoHashValid(value)
111 if (res === false) throw new Error('Video info hash is not valid.')
116 type: DataTypes.INTEGER,
119 durationValid: function (value) {
120 const res = customVideosValidators.isVideoDurationValid(value)
121 if (res === false) throw new Error('Video duration is not valid.')
126 type: DataTypes.INTEGER,
135 type: DataTypes.INTEGER,
144 type: DataTypes.INTEGER,
156 fields: [ 'authorId' ]
159 fields: [ 'remoteId' ]
165 fields: [ 'createdAt' ]
168 fields: [ 'duration' ]
171 fields: [ 'infoHash' ]
183 generateThumbnailFromData,
187 listOwnedAndPopulateAuthorAndTags,
190 loadByHostAndRemoteId,
191 loadAndPopulateAuthor,
192 loadAndPopulateAuthorAndPodAndTags,
193 searchAndPopulateAuthorAndPodAndTags
217 function beforeValidate (video, options, next) {
218 // Put a fake infoHash if it does not exists yet
219 if (video.isOwned() && !video.infoHash) {
221 video.infoHash = '0123456789abcdef0123456789abcdef01234567'
227 function beforeCreate (video, options, next) {
230 if (video.isOwned()) {
231 const videoPath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
234 function createVideoTorrent (callback) {
237 [ constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
240 constants.CONFIG.WEBSERVER.URL + constants.STATIC_PATHS.WEBSEED + video.getVideoFilename()
244 createTorrent(videoPath, options, function (err, torrent) {
245 if (err) return callback(err)
247 const filePath = pathUtils.join(constants.CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
248 fs.writeFile(filePath, torrent, function (err) {
249 if (err) return callback(err)
251 const parsedTorrent = parseTorrent(torrent)
252 video.set('infoHash', parsedTorrent.infoHash)
253 video.validate().asCallback(callback)
258 function createVideoThumbnail (callback) {
259 createThumbnail(video, videoPath, callback)
262 function createVIdeoPreview (callback) {
263 createPreview(video, videoPath, callback)
267 return parallel(tasks, next)
273 function afterDestroy (video, options, next) {
277 function (callback) {
278 removeThumbnail(video, callback)
282 if (video.isOwned()) {
284 function removeVideoFile (callback) {
285 removeFile(video, callback)
288 function removeVideoTorrent (callback) {
289 removeTorrent(video, callback)
292 function removeVideoPreview (callback) {
293 removePreview(video, callback)
296 function removeVideoToFriends (callback) {
301 friends.removeVideoToFriends(params)
308 parallel(tasks, next)
311 // ------------------------------ METHODS ------------------------------
313 function associate (models) {
314 this.belongsTo(models.Author, {
322 this.belongsToMany(models.Tag, {
323 foreignKey: 'videoId',
324 through: models.VideoTag,
328 this.hasMany(models.VideoAbuse, {
337 function generateMagnetUri () {
338 let baseUrlHttp, baseUrlWs
340 if (this.isOwned()) {
341 baseUrlHttp = constants.CONFIG.WEBSERVER.URL
342 baseUrlWs = constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT
344 baseUrlHttp = constants.REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
345 baseUrlWs = constants.REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
348 const xs = baseUrlHttp + constants.STATIC_PATHS.TORRENTS + this.getTorrentName()
349 const announce = baseUrlWs + '/tracker/socket'
350 const urlList = [ baseUrlHttp + constants.STATIC_PATHS.WEBSEED + this.getVideoFilename() ]
356 infoHash: this.infoHash,
360 return magnetUtil.encode(magnetHash)
363 function getVideoFilename () {
364 if (this.isOwned()) return this.id + this.extname
366 return this.remoteId + this.extname
369 function getThumbnailName () {
370 // We always have a copy of the thumbnail
371 return this.id + '.jpg'
374 function getPreviewName () {
375 const extension = '.jpg'
377 if (this.isOwned()) return this.id + extension
379 return this.remoteId + extension
382 function getTorrentName () {
383 const extension = '.torrent'
385 if (this.isOwned()) return this.id + extension
387 return this.remoteId + extension
390 function isOwned () {
391 return this.remoteId === null
394 function toFormatedJSON () {
397 if (this.Author.Pod) {
398 podHost = this.Author.Pod.host
400 // It means it's our video
401 podHost = constants.CONFIG.WEBSERVER.HOST
404 // Maybe our pod is not up to date and there are new categories since our version
405 let categoryLabel = constants.VIDEO_CATEGORIES[this.category]
406 if (!categoryLabel) categoryLabel = 'Misc'
408 // Maybe our pod is not up to date and there are new licences since our version
409 let licenceLabel = constants.VIDEO_LICENCES[this.licence]
410 if (!licenceLabel) licenceLabel = 'Unknown'
412 // Language is an optional attribute
413 let languageLabel = constants.VIDEO_LANGUAGES[this.language]
414 if (!languageLabel) languageLabel = 'Unknown'
419 category: this.category,
421 licence: this.licence,
423 language: this.language,
426 description: this.description,
428 isLocal: this.isOwned(),
429 magnetUri: this.generateMagnetUri(),
430 author: this.Author.name,
431 duration: this.duration,
434 dislikes: this.dislikes,
435 tags: map(this.Tags, 'name'),
436 thumbnailPath: pathUtils.join(constants.STATIC_PATHS.THUMBNAILS, this.getThumbnailName()),
437 createdAt: this.createdAt,
438 updatedAt: this.updatedAt
444 function toAddRemoteJSON (callback) {
447 // Get thumbnail data to send to the other pod
448 const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
449 fs.readFile(thumbnailPath, function (err, thumbnailData) {
451 logger.error('Cannot read the thumbnail of the video')
455 const remoteVideo = {
457 category: self.category,
458 licence: self.licence,
459 language: self.language,
461 description: self.description,
462 infoHash: self.infoHash,
464 author: self.Author.name,
465 duration: self.duration,
466 thumbnailData: thumbnailData.toString('binary'),
467 tags: map(self.Tags, 'name'),
468 createdAt: self.createdAt,
469 updatedAt: self.updatedAt,
470 extname: self.extname,
473 dislikes: self.dislikes
476 return callback(null, remoteVideo)
480 function toUpdateRemoteJSON (callback) {
483 category: this.category,
484 licence: this.licence,
485 language: this.language,
487 description: this.description,
488 infoHash: this.infoHash,
490 author: this.Author.name,
491 duration: this.duration,
492 tags: map(this.Tags, 'name'),
493 createdAt: this.createdAt,
494 updatedAt: this.updatedAt,
495 extname: this.extname,
498 dislikes: this.dislikes
504 // ------------------------------ STATICS ------------------------------
506 function generateThumbnailFromData (video, thumbnailData, callback) {
507 // Creating the thumbnail for a remote video
509 const thumbnailName = video.getThumbnailName()
510 const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
511 fs.writeFile(thumbnailPath, Buffer.from(thumbnailData, 'binary'), function (err) {
512 if (err) return callback(err)
514 return callback(null, thumbnailName)
518 function getDurationFromFile (videoPath, callback) {
519 ffmpeg.ffprobe(videoPath, function (err, metadata) {
520 if (err) return callback(err)
522 return callback(null, Math.floor(metadata.format.duration))
526 function list (callback) {
527 return this.findAll().asCallback(callback)
530 function listForApi (start, count, sort, callback) {
534 distinct: true, // For the count, a video can have many tags
535 order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ],
538 model: this.sequelize.models.Author,
539 include: [ { model: this.sequelize.models.Pod, required: false } ]
542 this.sequelize.models.Tag
546 return this.findAndCountAll(query).asCallback(function (err, result) {
547 if (err) return callback(err)
549 return callback(null, result.rows, result.count)
553 function loadByHostAndRemoteId (fromHost, remoteId, callback) {
560 model: this.sequelize.models.Author,
563 model: this.sequelize.models.Pod,
574 return this.findOne(query).asCallback(callback)
577 function listOwnedAndPopulateAuthorAndTags (callback) {
578 // If remoteId is null this is *our* video
583 include: [ this.sequelize.models.Author, this.sequelize.models.Tag ]
586 return this.findAll(query).asCallback(callback)
589 function listOwnedByAuthor (author, callback) {
596 model: this.sequelize.models.Author,
604 return this.findAll(query).asCallback(callback)
607 function load (id, callback) {
608 return this.findById(id).asCallback(callback)
611 function loadAndPopulateAuthor (id, callback) {
613 include: [ this.sequelize.models.Author ]
616 return this.findById(id, options).asCallback(callback)
619 function loadAndPopulateAuthorAndPodAndTags (id, callback) {
623 model: this.sequelize.models.Author,
624 include: [ { model: this.sequelize.models.Pod, required: false } ]
626 this.sequelize.models.Tag
630 return this.findById(id, options).asCallback(callback)
633 function searchAndPopulateAuthorAndPodAndTags (value, field, start, count, sort, callback) {
635 model: this.sequelize.models.Pod,
639 const authorInclude = {
640 model: this.sequelize.models.Author,
647 model: this.sequelize.models.Tag
654 distinct: true, // For the count, a video can have many tags
655 order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ]
658 // Make an exact search with the magnet
659 if (field === 'magnetUri') {
660 const infoHash = magnetUtil.decode(value).infoHash
661 query.where.infoHash = infoHash
662 } else if (field === 'tags') {
663 const escapedValue = this.sequelize.escape('%' + value + '%')
666 $in: this.sequelize.literal(
667 '(SELECT "VideoTags"."videoId" FROM "Tags" INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId" WHERE name LIKE ' + escapedValue + ')'
671 } else if (field === 'host') {
672 // FIXME: Include our pod? (not stored in the database)
675 $like: '%' + value + '%'
678 podInclude.required = true
679 } else if (field === 'author') {
680 authorInclude.where = {
682 $like: '%' + value + '%'
686 // authorInclude.or = true
688 query.where[field] = {
689 $like: '%' + value + '%'
694 authorInclude, tagInclude
697 if (tagInclude.where) {
698 // query.include.push([ this.sequelize.models.Tag ])
701 return this.findAndCountAll(query).asCallback(function (err, result) {
702 if (err) return callback(err)
704 return callback(null, result.rows, result.count)
708 // ---------------------------------------------------------------------------
710 function removeThumbnail (video, callback) {
711 const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
712 fs.unlink(thumbnailPath, callback)
715 function removeFile (video, callback) {
716 const filePath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
717 fs.unlink(filePath, callback)
720 function removeTorrent (video, callback) {
721 const torrenPath = pathUtils.join(constants.CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
722 fs.unlink(torrenPath, callback)
725 function removePreview (video, callback) {
726 // Same name than video thumnail
727 fs.unlink(constants.CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName(), callback)
730 function createPreview (video, videoPath, callback) {
731 generateImage(video, videoPath, constants.CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), callback)
734 function createThumbnail (video, videoPath, callback) {
735 generateImage(video, videoPath, constants.CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), constants.THUMBNAILS_SIZE, callback)
738 function generateImage (video, videoPath, folder, imageName, size, callback) {
752 .on('error', callback)
753 .on('end', function () {
754 callback(null, imageName)