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