]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video.js
Update README
[github/Chocobozzz/PeerTube.git] / server / models / video.js
1 'use strict'
2
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')
14
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')
22
23 // ---------------------------------------------------------------------------
24
25 module.exports = function (sequelize, DataTypes) {
26 const Video = sequelize.define('Video',
27 {
28 id: {
29 type: DataTypes.UUID,
30 defaultValue: DataTypes.UUIDV4,
31 primaryKey: true,
32 validate: {
33 isUUID: 4
34 }
35 },
36 name: {
37 type: DataTypes.STRING,
38 allowNull: false,
39 validate: {
40 nameValid: function (value) {
41 const res = customVideosValidators.isVideoNameValid(value)
42 if (res === false) throw new Error('Video name is not valid.')
43 }
44 }
45 },
46 extname: {
47 type: DataTypes.ENUM(values(constants.CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)),
48 allowNull: false
49 },
50 remoteId: {
51 type: DataTypes.UUID,
52 allowNull: true,
53 validate: {
54 isUUID: 4
55 }
56 },
57 category: {
58 type: DataTypes.INTEGER,
59 allowNull: false,
60 validate: {
61 categoryValid: function (value) {
62 const res = customVideosValidators.isVideoCategoryValid(value)
63 if (res === false) throw new Error('Video category is not valid.')
64 }
65 }
66 },
67 licence: {
68 type: DataTypes.INTEGER,
69 allowNull: false,
70 defaultValue: null,
71 validate: {
72 licenceValid: function (value) {
73 const res = customVideosValidators.isVideoLicenceValid(value)
74 if (res === false) throw new Error('Video licence is not valid.')
75 }
76 }
77 },
78 language: {
79 type: DataTypes.INTEGER,
80 allowNull: true,
81 validate: {
82 languageValid: function (value) {
83 const res = customVideosValidators.isVideoLanguageValid(value)
84 if (res === false) throw new Error('Video language is not valid.')
85 }
86 }
87 },
88 nsfw: {
89 type: DataTypes.BOOLEAN,
90 allowNull: false,
91 validate: {
92 nsfwValid: function (value) {
93 const res = customVideosValidators.isVideoNSFWValid(value)
94 if (res === false) throw new Error('Video nsfw attribute is not valid.')
95 }
96 }
97 },
98 description: {
99 type: DataTypes.STRING,
100 allowNull: false,
101 validate: {
102 descriptionValid: function (value) {
103 const res = customVideosValidators.isVideoDescriptionValid(value)
104 if (res === false) throw new Error('Video description is not valid.')
105 }
106 }
107 },
108 infoHash: {
109 type: DataTypes.STRING,
110 allowNull: false,
111 validate: {
112 infoHashValid: function (value) {
113 const res = customVideosValidators.isVideoInfoHashValid(value)
114 if (res === false) throw new Error('Video info hash is not valid.')
115 }
116 }
117 },
118 duration: {
119 type: DataTypes.INTEGER,
120 allowNull: false,
121 validate: {
122 durationValid: function (value) {
123 const res = customVideosValidators.isVideoDurationValid(value)
124 if (res === false) throw new Error('Video duration is not valid.')
125 }
126 }
127 },
128 views: {
129 type: DataTypes.INTEGER,
130 allowNull: false,
131 defaultValue: 0,
132 validate: {
133 min: 0,
134 isInt: true
135 }
136 },
137 likes: {
138 type: DataTypes.INTEGER,
139 allowNull: false,
140 defaultValue: 0,
141 validate: {
142 min: 0,
143 isInt: true
144 }
145 },
146 dislikes: {
147 type: DataTypes.INTEGER,
148 allowNull: false,
149 defaultValue: 0,
150 validate: {
151 min: 0,
152 isInt: true
153 }
154 }
155 },
156 {
157 indexes: [
158 {
159 fields: [ 'authorId' ]
160 },
161 {
162 fields: [ 'remoteId' ]
163 },
164 {
165 fields: [ 'name' ]
166 },
167 {
168 fields: [ 'createdAt' ]
169 },
170 {
171 fields: [ 'duration' ]
172 },
173 {
174 fields: [ 'infoHash' ]
175 },
176 {
177 fields: [ 'views' ]
178 },
179 {
180 fields: [ 'likes' ]
181 }
182 ],
183 classMethods: {
184 associate,
185
186 generateThumbnailFromData,
187 getDurationFromFile,
188 list,
189 listForApi,
190 listOwnedAndPopulateAuthorAndTags,
191 listOwnedByAuthor,
192 load,
193 loadByHostAndRemoteId,
194 loadAndPopulateAuthor,
195 loadAndPopulateAuthorAndPodAndTags,
196 searchAndPopulateAuthorAndPodAndTags
197 },
198 instanceMethods: {
199 generateMagnetUri,
200 getVideoFilename,
201 getThumbnailName,
202 getPreviewName,
203 getTorrentName,
204 isOwned,
205 toFormatedJSON,
206 toAddRemoteJSON,
207 toUpdateRemoteJSON,
208 transcodeVideofile,
209 removeFromBlacklist
210 },
211 hooks: {
212 beforeValidate,
213 beforeCreate,
214 afterDestroy
215 }
216 }
217 )
218
219 return Video
220 }
221
222 function beforeValidate (video, options, next) {
223 // Put a fake infoHash if it does not exists yet
224 if (video.isOwned() && !video.infoHash) {
225 // 40 hexa length
226 video.infoHash = '0123456789abcdef0123456789abcdef01234567'
227 }
228
229 return next(null)
230 }
231
232 function beforeCreate (video, options, next) {
233 const tasks = []
234
235 if (video.isOwned()) {
236 const videoPath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
237
238 tasks.push(
239 function createVideoTorrent (callback) {
240 createTorrentFromVideo(video, videoPath, callback)
241 },
242
243 function createVideoThumbnail (callback) {
244 createThumbnail(video, videoPath, callback)
245 },
246
247 function createVideoPreview (callback) {
248 createPreview(video, videoPath, callback)
249 }
250 )
251
252 if (constants.CONFIG.TRANSCODING.ENABLED === true) {
253 tasks.push(
254 function createVideoTranscoderJob (callback) {
255 const dataInput = {
256 id: video.id
257 }
258
259 jobScheduler.createJob(options.transaction, 'videoTranscoder', dataInput, callback)
260 }
261 )
262 }
263
264 return parallel(tasks, next)
265 }
266
267 return next()
268 }
269
270 function afterDestroy (video, options, next) {
271 const tasks = []
272
273 tasks.push(
274 function (callback) {
275 removeThumbnail(video, callback)
276 }
277 )
278
279 if (video.isOwned()) {
280 tasks.push(
281 function removeVideoFile (callback) {
282 removeFile(video, callback)
283 },
284
285 function removeVideoTorrent (callback) {
286 removeTorrent(video, callback)
287 },
288
289 function removeVideoPreview (callback) {
290 removePreview(video, callback)
291 },
292
293 function removeVideoToFriends (callback) {
294 const params = {
295 remoteId: video.id
296 }
297
298 friends.removeVideoToFriends(params)
299
300 return callback()
301 }
302 )
303 }
304
305 parallel(tasks, next)
306 }
307
308 // ------------------------------ METHODS ------------------------------
309
310 function associate (models) {
311 this.belongsTo(models.Author, {
312 foreignKey: {
313 name: 'authorId',
314 allowNull: false
315 },
316 onDelete: 'cascade'
317 })
318
319 this.belongsToMany(models.Tag, {
320 foreignKey: 'videoId',
321 through: models.VideoTag,
322 onDelete: 'cascade'
323 })
324
325 this.hasMany(models.VideoAbuse, {
326 foreignKey: {
327 name: 'videoId',
328 allowNull: false
329 },
330 onDelete: 'cascade'
331 })
332 }
333
334 function generateMagnetUri () {
335 let baseUrlHttp, baseUrlWs
336
337 if (this.isOwned()) {
338 baseUrlHttp = constants.CONFIG.WEBSERVER.URL
339 baseUrlWs = constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT
340 } else {
341 baseUrlHttp = constants.REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
342 baseUrlWs = constants.REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
343 }
344
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() ]
348
349 const magnetHash = {
350 xs,
351 announce,
352 urlList,
353 infoHash: this.infoHash,
354 name: this.name
355 }
356
357 return magnetUtil.encode(magnetHash)
358 }
359
360 function getVideoFilename () {
361 if (this.isOwned()) return this.id + this.extname
362
363 return this.remoteId + this.extname
364 }
365
366 function getThumbnailName () {
367 // We always have a copy of the thumbnail
368 return this.id + '.jpg'
369 }
370
371 function getPreviewName () {
372 const extension = '.jpg'
373
374 if (this.isOwned()) return this.id + extension
375
376 return this.remoteId + extension
377 }
378
379 function getTorrentName () {
380 const extension = '.torrent'
381
382 if (this.isOwned()) return this.id + extension
383
384 return this.remoteId + extension
385 }
386
387 function isOwned () {
388 return this.remoteId === null
389 }
390
391 function toFormatedJSON () {
392 let podHost
393
394 if (this.Author.Pod) {
395 podHost = this.Author.Pod.host
396 } else {
397 // It means it's our video
398 podHost = constants.CONFIG.WEBSERVER.HOST
399 }
400
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'
404
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'
408
409 // Language is an optional attribute
410 let languageLabel = constants.VIDEO_LANGUAGES[this.language]
411 if (!languageLabel) languageLabel = 'Unknown'
412
413 const json = {
414 id: this.id,
415 name: this.name,
416 category: this.category,
417 categoryLabel,
418 licence: this.licence,
419 licenceLabel,
420 language: this.language,
421 languageLabel,
422 nsfw: this.nsfw,
423 description: this.description,
424 podHost,
425 isLocal: this.isOwned(),
426 magnetUri: this.generateMagnetUri(),
427 author: this.Author.name,
428 duration: this.duration,
429 views: this.views,
430 likes: this.likes,
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
436 }
437
438 return json
439 }
440
441 function toAddRemoteJSON (callback) {
442 const self = this
443
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) {
447 if (err) {
448 logger.error('Cannot read the thumbnail of the video')
449 return callback(err)
450 }
451
452 const remoteVideo = {
453 name: self.name,
454 category: self.category,
455 licence: self.licence,
456 language: self.language,
457 nsfw: self.nsfw,
458 description: self.description,
459 infoHash: self.infoHash,
460 remoteId: self.id,
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,
468 views: self.views,
469 likes: self.likes,
470 dislikes: self.dislikes
471 }
472
473 return callback(null, remoteVideo)
474 })
475 }
476
477 function toUpdateRemoteJSON (callback) {
478 const json = {
479 name: this.name,
480 category: this.category,
481 licence: this.licence,
482 language: this.language,
483 nsfw: this.nsfw,
484 description: this.description,
485 infoHash: this.infoHash,
486 remoteId: this.id,
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,
493 views: this.views,
494 likes: this.likes,
495 dislikes: this.dislikes
496 }
497
498 return json
499 }
500
501 function transcodeVideofile (finalCallback) {
502 const video = this
503
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)
508
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 () {
516 series([
517 function removeOldFile (callback) {
518 fs.unlink(videoInputPath, callback)
519 },
520
521 function moveNewFile (callback) {
522 // Important to do this before getVideoFilename() to take in account the new file extension
523 video.set('extname', newExtname)
524
525 const newVideoPath = pathUtils.join(videosDirectory, video.getVideoFilename())
526 fs.rename(videoOutputPath, newVideoPath, callback)
527 },
528
529 function torrent (callback) {
530 const newVideoPath = pathUtils.join(videosDirectory, video.getVideoFilename())
531 createTorrentFromVideo(video, newVideoPath, callback)
532 },
533
534 function videoExtension (callback) {
535 video.save().asCallback(callback)
536 }
537
538 ], function (err) {
539 if (err) {
540 // Autodescruction...
541 video.destroy().asCallback(function (err) {
542 if (err) logger.error('Cannot destruct video after transcoding failure.', { error: err })
543 })
544
545 return finalCallback(err)
546 }
547
548 return finalCallback(null)
549 })
550 })
551 .run()
552 }
553
554 // ------------------------------ STATICS ------------------------------
555
556 function generateThumbnailFromData (video, thumbnailData, callback) {
557 // Creating the thumbnail for a remote video
558
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)
563
564 return callback(null, thumbnailName)
565 })
566 }
567
568 function getDurationFromFile (videoPath, callback) {
569 ffmpeg.ffprobe(videoPath, function (err, metadata) {
570 if (err) return callback(err)
571
572 return callback(null, Math.floor(metadata.format.duration))
573 })
574 }
575
576 function list (callback) {
577 return this.findAll().asCallback(callback)
578 }
579
580 function listForApi (start, count, sort, callback) {
581 // Exclude Blakclisted videos from the list
582 const query = {
583 offset: start,
584 limit: count,
585 distinct: true, // For the count, a video can have many tags
586 order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ],
587 include: [
588 {
589 model: this.sequelize.models.Author,
590 include: [ { model: this.sequelize.models.Pod, required: false } ]
591 },
592
593 this.sequelize.models.Tag
594 ],
595 where: createBaseVideosWhere.call(this)
596 }
597
598 return this.findAndCountAll(query).asCallback(function (err, result) {
599 if (err) return callback(err)
600
601 return callback(null, result.rows, result.count)
602 })
603 }
604
605 function loadByHostAndRemoteId (fromHost, remoteId, callback) {
606 const query = {
607 where: {
608 remoteId: remoteId
609 },
610 include: [
611 {
612 model: this.sequelize.models.Author,
613 include: [
614 {
615 model: this.sequelize.models.Pod,
616 required: true,
617 where: {
618 host: fromHost
619 }
620 }
621 ]
622 }
623 ]
624 }
625
626 return this.findOne(query).asCallback(callback)
627 }
628
629 function listOwnedAndPopulateAuthorAndTags (callback) {
630 // If remoteId is null this is *our* video
631 const query = {
632 where: {
633 remoteId: null
634 },
635 include: [ this.sequelize.models.Author, this.sequelize.models.Tag ]
636 }
637
638 return this.findAll(query).asCallback(callback)
639 }
640
641 function listOwnedByAuthor (author, callback) {
642 const query = {
643 where: {
644 remoteId: null
645 },
646 include: [
647 {
648 model: this.sequelize.models.Author,
649 where: {
650 name: author
651 }
652 }
653 ]
654 }
655
656 return this.findAll(query).asCallback(callback)
657 }
658
659 function load (id, callback) {
660 return this.findById(id).asCallback(callback)
661 }
662
663 function loadAndPopulateAuthor (id, callback) {
664 const options = {
665 include: [ this.sequelize.models.Author ]
666 }
667
668 return this.findById(id, options).asCallback(callback)
669 }
670
671 function loadAndPopulateAuthorAndPodAndTags (id, callback) {
672 const options = {
673 include: [
674 {
675 model: this.sequelize.models.Author,
676 include: [ { model: this.sequelize.models.Pod, required: false } ]
677 },
678 this.sequelize.models.Tag
679 ]
680 }
681
682 return this.findById(id, options).asCallback(callback)
683 }
684
685 function searchAndPopulateAuthorAndPodAndTags (value, field, start, count, sort, callback) {
686 const podInclude = {
687 model: this.sequelize.models.Pod,
688 required: false
689 }
690
691 const authorInclude = {
692 model: this.sequelize.models.Author,
693 include: [
694 podInclude
695 ]
696 }
697
698 const tagInclude = {
699 model: this.sequelize.models.Tag
700 }
701
702 const query = {
703 where: createBaseVideosWhere.call(this),
704 offset: start,
705 limit: count,
706 distinct: true, // For the count, a video can have many tags
707 order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ]
708 }
709
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 + ')'
718 )
719 } else if (field === 'host') {
720 // FIXME: Include our pod? (not stored in the database)
721 podInclude.where = {
722 host: {
723 $like: '%' + value + '%'
724 }
725 }
726 podInclude.required = true
727 } else if (field === 'author') {
728 authorInclude.where = {
729 name: {
730 $like: '%' + value + '%'
731 }
732 }
733
734 // authorInclude.or = true
735 } else {
736 query.where[field] = {
737 $like: '%' + value + '%'
738 }
739 }
740
741 query.include = [
742 authorInclude, tagInclude
743 ]
744
745 if (tagInclude.where) {
746 // query.include.push([ this.sequelize.models.Tag ])
747 }
748
749 return this.findAndCountAll(query).asCallback(function (err, result) {
750 if (err) return callback(err)
751
752 return callback(null, result.rows, result.count)
753 })
754 }
755
756 // ---------------------------------------------------------------------------
757
758 function createBaseVideosWhere () {
759 return {
760 id: {
761 $notIn: this.sequelize.literal(
762 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
763 )
764 }
765 }
766 }
767
768 function removeThumbnail (video, callback) {
769 const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
770 fs.unlink(thumbnailPath, callback)
771 }
772
773 function removeFile (video, callback) {
774 const filePath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
775 fs.unlink(filePath, callback)
776 }
777
778 function removeTorrent (video, callback) {
779 const torrenPath = pathUtils.join(constants.CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
780 fs.unlink(torrenPath, callback)
781 }
782
783 function removePreview (video, callback) {
784 // Same name than video thumnail
785 fs.unlink(constants.CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName(), callback)
786 }
787
788 function createTorrentFromVideo (video, videoPath, callback) {
789 const options = {
790 announceList: [
791 [ constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
792 ],
793 urlList: [
794 constants.CONFIG.WEBSERVER.URL + constants.STATIC_PATHS.WEBSEED + video.getVideoFilename()
795 ]
796 }
797
798 createTorrent(videoPath, options, function (err, torrent) {
799 if (err) return callback(err)
800
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)
804
805 const parsedTorrent = parseTorrent(torrent)
806 video.set('infoHash', parsedTorrent.infoHash)
807 video.validate().asCallback(callback)
808 })
809 })
810 }
811
812 function createPreview (video, videoPath, callback) {
813 generateImage(video, videoPath, constants.CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), callback)
814 }
815
816 function createThumbnail (video, videoPath, callback) {
817 generateImage(video, videoPath, constants.CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), constants.THUMBNAILS_SIZE, callback)
818 }
819
820 function generateImage (video, videoPath, folder, imageName, size, callback) {
821 const options = {
822 filename: imageName,
823 count: 1,
824 folder
825 }
826
827 if (!callback) {
828 callback = size
829 } else {
830 options.size = size
831 }
832
833 ffmpeg(videoPath)
834 .on('error', callback)
835 .on('end', function () {
836 callback(null, imageName)
837 })
838 .thumbnail(options)
839 }
840
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
845 if (err) {
846 logger.error('Error when fetching video from blacklist.', { error: err })
847 return callback(err)
848 }
849
850 // If we found the video, remove it from the blacklist
851 if (video) {
852 video.destroy().asCallback(callback)
853 } else {
854 // If haven't found it, simply ignore it and do nothing
855 return callback()
856 }
857 })
858 }