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