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