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