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