]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/video.js
Fix david-dm badge links
[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 53 },
6e07c3de
C
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 },
feb4bdfd 64 description: {
67bf9b96
C
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 }
feb4bdfd
C
73 },
74 infoHash: {
67bf9b96
C
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 }
feb4bdfd
C
83 },
84 duration: {
67bf9b96
C
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 }
9e167724
C
93 },
94 views: {
95 type: DataTypes.INTEGER,
96 allowNull: false,
97 defaultValue: 0,
98 validate: {
99 min: 0,
100 isInt: true
101 }
d38b8281
C
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 }
aaf61f38 120 }
feb4bdfd
C
121 },
122 {
319d072e
C
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' ]
9e167724
C
141 },
142 {
143 fields: [ 'views' ]
d38b8281
C
144 },
145 {
146 fields: [ 'likes' ]
319d072e
C
147 }
148 ],
feb4bdfd
C
149 classMethods: {
150 associate,
151
4d324488 152 generateThumbnailFromData,
feb4bdfd 153 getDurationFromFile,
b769007f 154 list,
feb4bdfd 155 listForApi,
7920c273 156 listOwnedAndPopulateAuthorAndTags,
feb4bdfd
C
157 listOwnedByAuthor,
158 load,
3d118fb5 159 loadByHostAndRemoteId,
feb4bdfd 160 loadAndPopulateAuthor,
7920c273
C
161 loadAndPopulateAuthorAndPodAndTags,
162 searchAndPopulateAuthorAndPodAndTags
feb4bdfd
C
163 },
164 instanceMethods: {
165 generateMagnetUri,
166 getVideoFilename,
167 getThumbnailName,
168 getPreviewName,
169 getTorrentName,
170 isOwned,
171 toFormatedJSON,
7b1f49de
C
172 toAddRemoteJSON,
173 toUpdateRemoteJSON
feb4bdfd
C
174 },
175 hooks: {
67bf9b96 176 beforeValidate,
feb4bdfd
C
177 beforeCreate,
178 afterDestroy
179 }
180 }
181 )
aaf61f38 182
feb4bdfd
C
183 return Video
184}
aaf61f38 185
67bf9b96 186function beforeValidate (video, options, next) {
7f4e7c36
C
187 // Put a fake infoHash if it does not exists yet
188 if (video.isOwned() && !video.infoHash) {
67bf9b96
C
189 // 40 hexa length
190 video.infoHash = '0123456789abcdef0123456789abcdef01234567'
191 }
192
193 return next(null)
194}
feb4bdfd
C
195
196function beforeCreate (video, options, next) {
aaf61f38
C
197 const tasks = []
198
199 if (video.isOwned()) {
f285faa0 200 const videoPath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
aaf61f38
C
201
202 tasks.push(
15103f11 203 function createVideoTorrent (callback) {
25cad919
C
204 const options = {
205 announceList: [
3737bbaf 206 [ constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
25cad919
C
207 ],
208 urlList: [
f285faa0 209 constants.CONFIG.WEBSERVER.URL + constants.STATIC_PATHS.WEBSEED + video.getVideoFilename()
25cad919
C
210 ]
211 }
212
213 createTorrent(videoPath, options, function (err, torrent) {
052937db
C
214 if (err) return callback(err)
215
15103f11
C
216 const filePath = pathUtils.join(constants.CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
217 fs.writeFile(filePath, torrent, function (err) {
052937db
C
218 if (err) return callback(err)
219
220 const parsedTorrent = parseTorrent(torrent)
67bf9b96
C
221 video.set('infoHash', parsedTorrent.infoHash)
222 video.validate().asCallback(callback)
052937db
C
223 })
224 })
aaf61f38 225 },
15103f11
C
226
227 function createVideoThumbnail (callback) {
558d7c23 228 createThumbnail(video, videoPath, callback)
6a94a109 229 },
15103f11
C
230
231 function createVIdeoPreview (callback) {
558d7c23 232 createPreview(video, videoPath, callback)
aaf61f38
C
233 }
234 )
235
c77fa067 236 return parallel(tasks, next)
aaf61f38 237 }
c77fa067
C
238
239 return next()
feb4bdfd 240}
aaf61f38 241
feb4bdfd
C
242function 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(
15103f11 253 function removeVideoFile (callback) {
feb4bdfd
C
254 removeFile(video, callback)
255 },
98ac898a 256
15103f11 257 function removeVideoTorrent (callback) {
feb4bdfd
C
258 removeTorrent(video, callback)
259 },
98ac898a 260
15103f11 261 function removeVideoPreview (callback) {
feb4bdfd 262 removePreview(video, callback)
98ac898a
C
263 },
264
15103f11 265 function removeVideoToFriends (callback) {
98ac898a 266 const params = {
98ac898a
C
267 remoteId: video.id
268 }
269
270 friends.removeVideoToFriends(params)
271
272 return callback()
feb4bdfd
C
273 }
274 )
275 }
276
277 parallel(tasks, next)
278}
aaf61f38
C
279
280// ------------------------------ METHODS ------------------------------
281
feb4bdfd
C
282function associate (models) {
283 this.belongsTo(models.Author, {
284 foreignKey: {
285 name: 'authorId',
286 allowNull: false
287 },
288 onDelete: 'cascade'
289 })
7920c273
C
290
291 this.belongsToMany(models.Tag, {
292 foreignKey: 'videoId',
293 through: models.VideoTag,
294 onDelete: 'cascade'
295 })
55fa55a9
C
296
297 this.hasMany(models.VideoAbuse, {
298 foreignKey: {
299 name: 'videoId',
300 allowNull: false
301 },
302 onDelete: 'cascade'
303 })
feb4bdfd
C
304}
305
f285faa0
C
306function 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 {
feb4bdfd
C
313 baseUrlHttp = constants.REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
314 baseUrlWs = constants.REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
f285faa0
C
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,
feb4bdfd 325 infoHash: this.infoHash,
f285faa0
C
326 name: this.name
327 }
328
329 return magnetUtil.encode(magnetHash)
558d7c23
C
330}
331
f285faa0 332function getVideoFilename () {
feb4bdfd 333 if (this.isOwned()) return this.id + this.extname
f285faa0
C
334
335 return this.remoteId + this.extname
336}
337
338function getThumbnailName () {
339 // We always have a copy of the thumbnail
feb4bdfd 340 return this.id + '.jpg'
558d7c23
C
341}
342
f285faa0
C
343function getPreviewName () {
344 const extension = '.jpg'
345
feb4bdfd 346 if (this.isOwned()) return this.id + extension
f285faa0
C
347
348 return this.remoteId + extension
349}
350
558d7c23 351function getTorrentName () {
f285faa0
C
352 const extension = '.torrent'
353
feb4bdfd 354 if (this.isOwned()) return this.id + extension
f285faa0
C
355
356 return this.remoteId + extension
558d7c23
C
357}
358
aaf61f38 359function isOwned () {
558d7c23 360 return this.remoteId === null
aaf61f38
C
361}
362
363function toFormatedJSON () {
feb4bdfd
C
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
6e07c3de
C
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
aaf61f38 377 const json = {
feb4bdfd 378 id: this.id,
aaf61f38 379 name: this.name,
6e07c3de
C
380 category: this.category,
381 categoryLabel,
aaf61f38 382 description: this.description,
feb4bdfd 383 podHost,
aaf61f38 384 isLocal: this.isOwned(),
f285faa0 385 magnetUri: this.generateMagnetUri(),
feb4bdfd 386 author: this.Author.name,
aaf61f38 387 duration: this.duration,
9e167724 388 views: this.views,
d38b8281
C
389 likes: this.likes,
390 dislikes: this.dislikes,
7920c273 391 tags: map(this.Tags, 'name'),
91cc839a 392 thumbnailPath: pathUtils.join(constants.STATIC_PATHS.THUMBNAILS, this.getThumbnailName()),
79066fdf
C
393 createdAt: this.createdAt,
394 updatedAt: this.updatedAt
aaf61f38
C
395 }
396
397 return json
398}
399
7b1f49de 400function toAddRemoteJSON (callback) {
aaf61f38
C
401 const self = this
402
4d324488 403 // Get thumbnail data to send to the other pod
f285faa0 404 const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
558d7c23 405 fs.readFile(thumbnailPath, function (err, thumbnailData) {
aaf61f38
C
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,
6e07c3de 413 category: self.category,
aaf61f38 414 description: self.description,
feb4bdfd
C
415 infoHash: self.infoHash,
416 remoteId: self.id,
417 author: self.Author.name,
aaf61f38 418 duration: self.duration,
4d324488 419 thumbnailData: thumbnailData.toString('binary'),
7920c273 420 tags: map(self.Tags, 'name'),
feb4bdfd 421 createdAt: self.createdAt,
79066fdf 422 updatedAt: self.updatedAt,
e3d156b3 423 extname: self.extname,
d38b8281
C
424 views: self.views,
425 likes: self.likes,
426 dislikes: self.dislikes
aaf61f38
C
427 }
428
429 return callback(null, remoteVideo)
430 })
431}
432
7b1f49de
C
433function toUpdateRemoteJSON (callback) {
434 const json = {
435 name: this.name,
6e07c3de 436 category: this.category,
7b1f49de
C
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,
79066fdf 444 updatedAt: this.updatedAt,
e3d156b3 445 extname: this.extname,
d38b8281
C
446 views: this.views,
447 likes: this.likes,
448 dislikes: this.dislikes
7b1f49de
C
449 }
450
451 return json
452}
453
aaf61f38
C
454// ------------------------------ STATICS ------------------------------
455
4d324488 456function generateThumbnailFromData (video, thumbnailData, callback) {
c77fa067
C
457 // Creating the thumbnail for a remote video
458
459 const thumbnailName = video.getThumbnailName()
15103f11 460 const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
4d324488 461 fs.writeFile(thumbnailPath, Buffer.from(thumbnailData, 'binary'), function (err) {
c77fa067
C
462 if (err) return callback(err)
463
464 return callback(null, thumbnailName)
465 })
466}
467
aaf61f38
C
468function 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
b769007f 476function list (callback) {
9cc99d7b 477 return this.findAll().asCallback(callback)
b769007f
C
478}
479
0ff21c1c 480function listForApi (start, count, sort, callback) {
feb4bdfd
C
481 const query = {
482 offset: start,
483 limit: count,
7920c273 484 distinct: true, // For the count, a video can have many tags
178edb20 485 order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ],
feb4bdfd
C
486 include: [
487 {
488 model: this.sequelize.models.Author,
7920c273
C
489 include: [ { model: this.sequelize.models.Pod, required: false } ]
490 },
491
492 this.sequelize.models.Tag
feb4bdfd
C
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 })
aaf61f38
C
501}
502
3d118fb5 503function loadByHostAndRemoteId (fromHost, remoteId, callback) {
feb4bdfd
C
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,
7920c273 514 required: true,
feb4bdfd
C
515 where: {
516 host: fromHost
517 }
518 }
519 ]
520 }
521 ]
522 }
aaf61f38 523
3d118fb5 524 return this.findOne(query).asCallback(callback)
aaf61f38
C
525}
526
7920c273 527function listOwnedAndPopulateAuthorAndTags (callback) {
558d7c23 528 // If remoteId is null this is *our* video
feb4bdfd
C
529 const query = {
530 where: {
531 remoteId: null
532 },
7920c273 533 include: [ this.sequelize.models.Author, this.sequelize.models.Tag ]
feb4bdfd
C
534 }
535
536 return this.findAll(query).asCallback(callback)
aaf61f38
C
537}
538
9bd26629 539function listOwnedByAuthor (author, callback) {
feb4bdfd
C
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 }
9bd26629 553
feb4bdfd 554 return this.findAll(query).asCallback(callback)
aaf61f38
C
555}
556
557function load (id, callback) {
feb4bdfd
C
558 return this.findById(id).asCallback(callback)
559}
560
561function loadAndPopulateAuthor (id, callback) {
562 const options = {
563 include: [ this.sequelize.models.Author ]
564 }
565
566 return this.findById(id, options).asCallback(callback)
567}
568
7920c273 569function loadAndPopulateAuthorAndPodAndTags (id, callback) {
feb4bdfd
C
570 const options = {
571 include: [
572 {
573 model: this.sequelize.models.Author,
7920c273
C
574 include: [ { model: this.sequelize.models.Pod, required: false } ]
575 },
576 this.sequelize.models.Tag
feb4bdfd
C
577 ]
578 }
579
580 return this.findById(id, options).asCallback(callback)
aaf61f38
C
581}
582
7920c273 583function searchAndPopulateAuthorAndPodAndTags (value, field, start, count, sort, callback) {
feb4bdfd 584 const podInclude = {
7920c273
C
585 model: this.sequelize.models.Pod,
586 required: false
feb4bdfd 587 }
7920c273 588
feb4bdfd
C
589 const authorInclude = {
590 model: this.sequelize.models.Author,
591 include: [
592 podInclude
593 ]
594 }
595
7920c273
C
596 const tagInclude = {
597 model: this.sequelize.models.Tag
598 }
599
feb4bdfd
C
600 const query = {
601 where: {},
feb4bdfd
C
602 offset: start,
603 limit: count,
7920c273 604 distinct: true, // For the count, a video can have many tags
178edb20 605 order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ]
feb4bdfd
C
606 }
607
aaf61f38 608 // Make an exact search with the magnet
55723d16
C
609 if (field === 'magnetUri') {
610 const infoHash = magnetUtil.decode(value).infoHash
feb4bdfd 611 query.where.infoHash = infoHash
55723d16 612 } else if (field === 'tags') {
7920c273
C
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 )
feb4bdfd
C
619 }
620 }
7920c273
C
621 } else if (field === 'host') {
622 // FIXME: Include our pod? (not stored in the database)
623 podInclude.where = {
624 host: {
625 $like: '%' + value + '%'
feb4bdfd 626 }
feb4bdfd 627 }
7920c273 628 podInclude.required = true
feb4bdfd 629 } else if (field === 'author') {
7920c273
C
630 authorInclude.where = {
631 name: {
feb4bdfd
C
632 $like: '%' + value + '%'
633 }
634 }
7920c273
C
635
636 // authorInclude.or = true
aaf61f38 637 } else {
feb4bdfd
C
638 query.where[field] = {
639 $like: '%' + value + '%'
640 }
aaf61f38
C
641 }
642
7920c273
C
643 query.include = [
644 authorInclude, tagInclude
645 ]
646
647 if (tagInclude.where) {
648 // query.include.push([ this.sequelize.models.Tag ])
649 }
650
feb4bdfd
C
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 })
aaf61f38
C
656}
657
aaf61f38
C
658// ---------------------------------------------------------------------------
659
aaf61f38 660function removeThumbnail (video, callback) {
15103f11
C
661 const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
662 fs.unlink(thumbnailPath, callback)
aaf61f38
C
663}
664
665function removeFile (video, callback) {
15103f11
C
666 const filePath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
667 fs.unlink(filePath, callback)
aaf61f38
C
668}
669
aaf61f38 670function removeTorrent (video, callback) {
15103f11
C
671 const torrenPath = pathUtils.join(constants.CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName())
672 fs.unlink(torrenPath, callback)
aaf61f38
C
673}
674
6a94a109
C
675function removePreview (video, callback) {
676 // Same name than video thumnail
f285faa0 677 fs.unlink(constants.CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName(), callback)
6a94a109
C
678}
679
558d7c23 680function createPreview (video, videoPath, callback) {
f285faa0 681 generateImage(video, videoPath, constants.CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), callback)
6a94a109
C
682}
683
558d7c23 684function createThumbnail (video, videoPath, callback) {
f285faa0 685 generateImage(video, videoPath, constants.CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), constants.THUMBNAILS_SIZE, callback)
aaf61f38
C
686}
687
f285faa0 688function generateImage (video, videoPath, folder, imageName, size, callback) {
558d7c23 689 const options = {
f285faa0 690 filename: imageName,
558d7c23
C
691 count: 1,
692 folder
693 }
aaf61f38 694
558d7c23
C
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 () {
f285faa0 704 callback(null, imageName)
aaf61f38 705 })
558d7c23 706 .thumbnail(options)
aaf61f38 707}