]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/video.js
Videos likes/dislikes is implemented :)
[github/Chocobozzz/PeerTube.git] / server / models / video.js
CommitLineData
aaf61f38
C
1'use strict'
2
4d324488 3const Buffer = require('safe-buffer').Buffer
052937db 4const createTorrent = require('create-torrent')
aaf61f38
C
5const ffmpeg = require('fluent-ffmpeg')
6const fs = require('fs')
f285faa0 7const magnetUtil = require('magnet-uri')
7920c273 8const map = require('lodash/map')
1a42c9e2 9const parallel = require('async/parallel')
052937db 10const parseTorrent = require('parse-torrent')
aaf61f38 11const pathUtils = require('path')
67bf9b96 12const values = require('lodash/values')
aaf61f38
C
13
14const constants = require('../initializers/constants')
aaf61f38 15const logger = require('../helpers/logger')
98ac898a 16const friends = require('../lib/friends')
0ff21c1c 17const modelUtils = require('./utils')
67bf9b96 18const customVideosValidators = require('../helpers/custom-validators').videos
aaf61f38 19
aaf61f38
C
20// ---------------------------------------------------------------------------
21
feb4bdfd 22module.exports = function (sequelize, DataTypes) {
feb4bdfd
C
23 const Video = sequelize.define('Video',
24 {
25 id: {
26 type: DataTypes.UUID,
27 defaultValue: DataTypes.UUIDV4,
67bf9b96
C
28 primaryKey: true,
29 validate: {
30 isUUID: 4
31 }
aaf61f38 32 },
feb4bdfd 33 name: {
67bf9b96
C
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 }
6a94a109 42 },
feb4bdfd 43 extname: {
67bf9b96
C
44 type: DataTypes.ENUM(values(constants.CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)),
45 allowNull: false
feb4bdfd
C
46 },
47 remoteId: {
67bf9b96
C
48 type: DataTypes.UUID,
49 allowNull: true,
50 validate: {
51 isUUID: 4
52 }
feb4bdfd
C
53 },
54 description: {
67bf9b96
C
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 }
feb4bdfd
C
63 },
64 infoHash: {
67bf9b96
C
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 }
feb4bdfd
C
73 },
74 duration: {
67bf9b96
C
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 }
9e167724
C
83 },
84 views: {
85 type: DataTypes.INTEGER,
86 allowNull: false,
87 defaultValue: 0,
88 validate: {
89 min: 0,
90 isInt: true
91 }
d38b8281
C
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 }
aaf61f38 110 }
feb4bdfd
C
111 },
112 {
319d072e
C
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' ]
9e167724
C
131 },
132 {
133 fields: [ 'views' ]
d38b8281
C
134 },
135 {
136 fields: [ 'likes' ]
319d072e
C
137 }
138 ],
feb4bdfd
C
139 classMethods: {
140 associate,
141
4d324488 142 generateThumbnailFromData,
feb4bdfd 143 getDurationFromFile,
b769007f 144 list,
feb4bdfd 145 listForApi,
7920c273 146 listOwnedAndPopulateAuthorAndTags,
feb4bdfd
C
147 listOwnedByAuthor,
148 load,
3d118fb5 149 loadByHostAndRemoteId,
feb4bdfd 150 loadAndPopulateAuthor,
7920c273
C
151 loadAndPopulateAuthorAndPodAndTags,
152 searchAndPopulateAuthorAndPodAndTags
feb4bdfd
C
153 },
154 instanceMethods: {
155 generateMagnetUri,
156 getVideoFilename,
157 getThumbnailName,
158 getPreviewName,
159 getTorrentName,
160 isOwned,
161 toFormatedJSON,
7b1f49de
C
162 toAddRemoteJSON,
163 toUpdateRemoteJSON
feb4bdfd
C
164 },
165 hooks: {
67bf9b96 166 beforeValidate,
feb4bdfd
C
167 beforeCreate,
168 afterDestroy
169 }
170 }
171 )
aaf61f38 172
feb4bdfd
C
173 return Video
174}
aaf61f38 175
67bf9b96 176function beforeValidate (video, options, next) {
7f4e7c36
C
177 // Put a fake infoHash if it does not exists yet
178 if (video.isOwned() && !video.infoHash) {
67bf9b96
C
179 // 40 hexa length
180 video.infoHash = '0123456789abcdef0123456789abcdef01234567'
181 }
182
183 return next(null)
184}
feb4bdfd
C
185
186function beforeCreate (video, options, next) {
aaf61f38
C
187 const tasks = []
188
189 if (video.isOwned()) {
f285faa0 190 const videoPath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
aaf61f38
C
191
192 tasks.push(
15103f11 193 function createVideoTorrent (callback) {
25cad919
C
194 const options = {
195 announceList: [
3737bbaf 196 [ constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
25cad919
C
197 ],
198 urlList: [
f285faa0 199 constants.CONFIG.WEBSERVER.URL + constants.STATIC_PATHS.WEBSEED + video.getVideoFilename()
25cad919
C
200 ]
201 }
202
203 createTorrent(videoPath, options, function (err, torrent) {
052937db
C
204 if (err) return callback(err)
205
15103f11
C
206 const filePath = pathUtils.join(constants.CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
207 fs.writeFile(filePath, torrent, function (err) {
052937db
C
208 if (err) return callback(err)
209
210 const parsedTorrent = parseTorrent(torrent)
67bf9b96
C
211 video.set('infoHash', parsedTorrent.infoHash)
212 video.validate().asCallback(callback)
052937db
C
213 })
214 })
aaf61f38 215 },
15103f11
C
216
217 function createVideoThumbnail (callback) {
558d7c23 218 createThumbnail(video, videoPath, callback)
6a94a109 219 },
15103f11
C
220
221 function createVIdeoPreview (callback) {
558d7c23 222 createPreview(video, videoPath, callback)
aaf61f38
C
223 }
224 )
225
c77fa067 226 return parallel(tasks, next)
aaf61f38 227 }
c77fa067
C
228
229 return next()
feb4bdfd 230}
aaf61f38 231
feb4bdfd
C
232function 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(
15103f11 243 function removeVideoFile (callback) {
feb4bdfd
C
244 removeFile(video, callback)
245 },
98ac898a 246
15103f11 247 function removeVideoTorrent (callback) {
feb4bdfd
C
248 removeTorrent(video, callback)
249 },
98ac898a 250
15103f11 251 function removeVideoPreview (callback) {
feb4bdfd 252 removePreview(video, callback)
98ac898a
C
253 },
254
15103f11 255 function removeVideoToFriends (callback) {
98ac898a 256 const params = {
98ac898a
C
257 remoteId: video.id
258 }
259
260 friends.removeVideoToFriends(params)
261
262 return callback()
feb4bdfd
C
263 }
264 )
265 }
266
267 parallel(tasks, next)
268}
aaf61f38
C
269
270// ------------------------------ METHODS ------------------------------
271
feb4bdfd
C
272function associate (models) {
273 this.belongsTo(models.Author, {
274 foreignKey: {
275 name: 'authorId',
276 allowNull: false
277 },
278 onDelete: 'cascade'
279 })
7920c273
C
280
281 this.belongsToMany(models.Tag, {
282 foreignKey: 'videoId',
283 through: models.VideoTag,
284 onDelete: 'cascade'
285 })
55fa55a9
C
286
287 this.hasMany(models.VideoAbuse, {
288 foreignKey: {
289 name: 'videoId',
290 allowNull: false
291 },
292 onDelete: 'cascade'
293 })
feb4bdfd
C
294}
295
f285faa0
C
296function 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 {
feb4bdfd
C
303 baseUrlHttp = constants.REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
304 baseUrlWs = constants.REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
f285faa0
C
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,
feb4bdfd 315 infoHash: this.infoHash,
f285faa0
C
316 name: this.name
317 }
318
319 return magnetUtil.encode(magnetHash)
558d7c23
C
320}
321
f285faa0 322function getVideoFilename () {
feb4bdfd 323 if (this.isOwned()) return this.id + this.extname
f285faa0
C
324
325 return this.remoteId + this.extname
326}
327
328function getThumbnailName () {
329 // We always have a copy of the thumbnail
feb4bdfd 330 return this.id + '.jpg'
558d7c23
C
331}
332
f285faa0
C
333function getPreviewName () {
334 const extension = '.jpg'
335
feb4bdfd 336 if (this.isOwned()) return this.id + extension
f285faa0
C
337
338 return this.remoteId + extension
339}
340
558d7c23 341function getTorrentName () {
f285faa0
C
342 const extension = '.torrent'
343
feb4bdfd 344 if (this.isOwned()) return this.id + extension
f285faa0
C
345
346 return this.remoteId + extension
558d7c23
C
347}
348
aaf61f38 349function isOwned () {
558d7c23 350 return this.remoteId === null
aaf61f38
C
351}
352
353function toFormatedJSON () {
feb4bdfd
C
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
aaf61f38 363 const json = {
feb4bdfd 364 id: this.id,
aaf61f38
C
365 name: this.name,
366 description: this.description,
feb4bdfd 367 podHost,
aaf61f38 368 isLocal: this.isOwned(),
f285faa0 369 magnetUri: this.generateMagnetUri(),
feb4bdfd 370 author: this.Author.name,
aaf61f38 371 duration: this.duration,
9e167724 372 views: this.views,
d38b8281
C
373 likes: this.likes,
374 dislikes: this.dislikes,
7920c273 375 tags: map(this.Tags, 'name'),
91cc839a 376 thumbnailPath: pathUtils.join(constants.STATIC_PATHS.THUMBNAILS, this.getThumbnailName()),
79066fdf
C
377 createdAt: this.createdAt,
378 updatedAt: this.updatedAt
aaf61f38
C
379 }
380
381 return json
382}
383
7b1f49de 384function toAddRemoteJSON (callback) {
aaf61f38
C
385 const self = this
386
4d324488 387 // Get thumbnail data to send to the other pod
f285faa0 388 const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
558d7c23 389 fs.readFile(thumbnailPath, function (err, thumbnailData) {
aaf61f38
C
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,
feb4bdfd
C
398 infoHash: self.infoHash,
399 remoteId: self.id,
400 author: self.Author.name,
aaf61f38 401 duration: self.duration,
4d324488 402 thumbnailData: thumbnailData.toString('binary'),
7920c273 403 tags: map(self.Tags, 'name'),
feb4bdfd 404 createdAt: self.createdAt,
79066fdf 405 updatedAt: self.updatedAt,
e3d156b3 406 extname: self.extname,
d38b8281
C
407 views: self.views,
408 likes: self.likes,
409 dislikes: self.dislikes
aaf61f38
C
410 }
411
412 return callback(null, remoteVideo)
413 })
414}
415
7b1f49de
C
416function 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,
79066fdf 426 updatedAt: this.updatedAt,
e3d156b3 427 extname: this.extname,
d38b8281
C
428 views: this.views,
429 likes: this.likes,
430 dislikes: this.dislikes
7b1f49de
C
431 }
432
433 return json
434}
435
aaf61f38
C
436// ------------------------------ STATICS ------------------------------
437
4d324488 438function generateThumbnailFromData (video, thumbnailData, callback) {
c77fa067
C
439 // Creating the thumbnail for a remote video
440
441 const thumbnailName = video.getThumbnailName()
15103f11 442 const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
4d324488 443 fs.writeFile(thumbnailPath, Buffer.from(thumbnailData, 'binary'), function (err) {
c77fa067
C
444 if (err) return callback(err)
445
446 return callback(null, thumbnailName)
447 })
448}
449
aaf61f38
C
450function 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
b769007f 458function list (callback) {
9cc99d7b 459 return this.findAll().asCallback(callback)
b769007f
C
460}
461
0ff21c1c 462function listForApi (start, count, sort, callback) {
feb4bdfd
C
463 const query = {
464 offset: start,
465 limit: count,
7920c273 466 distinct: true, // For the count, a video can have many tags
178edb20 467 order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ],
feb4bdfd
C
468 include: [
469 {
470 model: this.sequelize.models.Author,
7920c273
C
471 include: [ { model: this.sequelize.models.Pod, required: false } ]
472 },
473
474 this.sequelize.models.Tag
feb4bdfd
C
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 })
aaf61f38
C
483}
484
3d118fb5 485function loadByHostAndRemoteId (fromHost, remoteId, callback) {
feb4bdfd
C
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,
7920c273 496 required: true,
feb4bdfd
C
497 where: {
498 host: fromHost
499 }
500 }
501 ]
502 }
503 ]
504 }
aaf61f38 505
3d118fb5 506 return this.findOne(query).asCallback(callback)
aaf61f38
C
507}
508
7920c273 509function listOwnedAndPopulateAuthorAndTags (callback) {
558d7c23 510 // If remoteId is null this is *our* video
feb4bdfd
C
511 const query = {
512 where: {
513 remoteId: null
514 },
7920c273 515 include: [ this.sequelize.models.Author, this.sequelize.models.Tag ]
feb4bdfd
C
516 }
517
518 return this.findAll(query).asCallback(callback)
aaf61f38
C
519}
520
9bd26629 521function listOwnedByAuthor (author, callback) {
feb4bdfd
C
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 }
9bd26629 535
feb4bdfd 536 return this.findAll(query).asCallback(callback)
aaf61f38
C
537}
538
539function load (id, callback) {
feb4bdfd
C
540 return this.findById(id).asCallback(callback)
541}
542
543function loadAndPopulateAuthor (id, callback) {
544 const options = {
545 include: [ this.sequelize.models.Author ]
546 }
547
548 return this.findById(id, options).asCallback(callback)
549}
550
7920c273 551function loadAndPopulateAuthorAndPodAndTags (id, callback) {
feb4bdfd
C
552 const options = {
553 include: [
554 {
555 model: this.sequelize.models.Author,
7920c273
C
556 include: [ { model: this.sequelize.models.Pod, required: false } ]
557 },
558 this.sequelize.models.Tag
feb4bdfd
C
559 ]
560 }
561
562 return this.findById(id, options).asCallback(callback)
aaf61f38
C
563}
564
7920c273 565function searchAndPopulateAuthorAndPodAndTags (value, field, start, count, sort, callback) {
feb4bdfd 566 const podInclude = {
7920c273
C
567 model: this.sequelize.models.Pod,
568 required: false
feb4bdfd 569 }
7920c273 570
feb4bdfd
C
571 const authorInclude = {
572 model: this.sequelize.models.Author,
573 include: [
574 podInclude
575 ]
576 }
577
7920c273
C
578 const tagInclude = {
579 model: this.sequelize.models.Tag
580 }
581
feb4bdfd
C
582 const query = {
583 where: {},
feb4bdfd
C
584 offset: start,
585 limit: count,
7920c273 586 distinct: true, // For the count, a video can have many tags
178edb20 587 order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ]
feb4bdfd
C
588 }
589
aaf61f38 590 // Make an exact search with the magnet
55723d16
C
591 if (field === 'magnetUri') {
592 const infoHash = magnetUtil.decode(value).infoHash
feb4bdfd 593 query.where.infoHash = infoHash
55723d16 594 } else if (field === 'tags') {
7920c273
C
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 )
feb4bdfd
C
601 }
602 }
7920c273
C
603 } else if (field === 'host') {
604 // FIXME: Include our pod? (not stored in the database)
605 podInclude.where = {
606 host: {
607 $like: '%' + value + '%'
feb4bdfd 608 }
feb4bdfd 609 }
7920c273 610 podInclude.required = true
feb4bdfd 611 } else if (field === 'author') {
7920c273
C
612 authorInclude.where = {
613 name: {
feb4bdfd
C
614 $like: '%' + value + '%'
615 }
616 }
7920c273
C
617
618 // authorInclude.or = true
aaf61f38 619 } else {
feb4bdfd
C
620 query.where[field] = {
621 $like: '%' + value + '%'
622 }
aaf61f38
C
623 }
624
7920c273
C
625 query.include = [
626 authorInclude, tagInclude
627 ]
628
629 if (tagInclude.where) {
630 // query.include.push([ this.sequelize.models.Tag ])
631 }
632
feb4bdfd
C
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 })
aaf61f38
C
638}
639
aaf61f38
C
640// ---------------------------------------------------------------------------
641
aaf61f38 642function removeThumbnail (video, callback) {
15103f11
C
643 const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
644 fs.unlink(thumbnailPath, callback)
aaf61f38
C
645}
646
647function removeFile (video, callback) {
15103f11
C
648 const filePath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
649 fs.unlink(filePath, callback)
aaf61f38
C
650}
651
aaf61f38 652function removeTorrent (video, callback) {
15103f11
C
653 const torrenPath = pathUtils.join(constants.CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
654 fs.unlink(torrenPath, callback)
aaf61f38
C
655}
656
6a94a109
C
657function removePreview (video, callback) {
658 // Same name than video thumnail
f285faa0 659 fs.unlink(constants.CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName(), callback)
6a94a109
C
660}
661
558d7c23 662function createPreview (video, videoPath, callback) {
f285faa0 663 generateImage(video, videoPath, constants.CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), callback)
6a94a109
C
664}
665
558d7c23 666function createThumbnail (video, videoPath, callback) {
f285faa0 667 generateImage(video, videoPath, constants.CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), constants.THUMBNAILS_SIZE, callback)
aaf61f38
C
668}
669
f285faa0 670function generateImage (video, videoPath, folder, imageName, size, callback) {
558d7c23 671 const options = {
f285faa0 672 filename: imageName,
558d7c23
C
673 count: 1,
674 folder
675 }
aaf61f38 676
558d7c23
C
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 () {
f285faa0 686 callback(null, imageName)
aaf61f38 687 })
558d7c23 688 .thumbnail(options)
aaf61f38 689}