diff options
Diffstat (limited to 'server/models/video.ts')
-rw-r--r-- | server/models/video.ts | 873 |
1 files changed, 873 insertions, 0 deletions
diff --git a/server/models/video.ts b/server/models/video.ts new file mode 100644 index 000000000..1e29f1355 --- /dev/null +++ b/server/models/video.ts | |||
@@ -0,0 +1,873 @@ | |||
1 | import safeBuffer = require('safe-buffer') | ||
2 | const Buffer = safeBuffer.Buffer | ||
3 | import createTorrent = require('create-torrent') | ||
4 | import ffmpeg = require('fluent-ffmpeg') | ||
5 | import fs = require('fs') | ||
6 | import magnetUtil = require('magnet-uri') | ||
7 | import { map, values } from 'lodash' | ||
8 | import { parallel, series } from 'async' | ||
9 | import parseTorrent = require('parse-torrent') | ||
10 | import { join } from 'path' | ||
11 | |||
12 | const db = require('../initializers/database') | ||
13 | import { | ||
14 | logger, | ||
15 | isVideoNameValid, | ||
16 | isVideoCategoryValid, | ||
17 | isVideoLicenceValid, | ||
18 | isVideoLanguageValid, | ||
19 | isVideoNSFWValid, | ||
20 | isVideoDescriptionValid, | ||
21 | isVideoInfoHashValid, | ||
22 | isVideoDurationValid | ||
23 | } from '../helpers' | ||
24 | import { | ||
25 | CONSTRAINTS_FIELDS, | ||
26 | CONFIG, | ||
27 | REMOTE_SCHEME, | ||
28 | STATIC_PATHS, | ||
29 | VIDEO_CATEGORIES, | ||
30 | VIDEO_LICENCES, | ||
31 | VIDEO_LANGUAGES, | ||
32 | THUMBNAILS_SIZE | ||
33 | } from '../initializers' | ||
34 | import { JobScheduler, removeVideoToFriends } from '../lib' | ||
35 | import { getSort } from './utils' | ||
36 | |||
37 | // --------------------------------------------------------------------------- | ||
38 | |||
39 | module.exports = function (sequelize, DataTypes) { | ||
40 | const Video = sequelize.define('Video', | ||
41 | { | ||
42 | id: { | ||
43 | type: DataTypes.UUID, | ||
44 | defaultValue: DataTypes.UUIDV4, | ||
45 | primaryKey: true, | ||
46 | validate: { | ||
47 | isUUID: 4 | ||
48 | } | ||
49 | }, | ||
50 | name: { | ||
51 | type: DataTypes.STRING, | ||
52 | allowNull: false, | ||
53 | validate: { | ||
54 | nameValid: function (value) { | ||
55 | const res = isVideoNameValid(value) | ||
56 | if (res === false) throw new Error('Video name is not valid.') | ||
57 | } | ||
58 | } | ||
59 | }, | ||
60 | extname: { | ||
61 | type: DataTypes.ENUM(values(CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)), | ||
62 | allowNull: false | ||
63 | }, | ||
64 | remoteId: { | ||
65 | type: DataTypes.UUID, | ||
66 | allowNull: true, | ||
67 | validate: { | ||
68 | isUUID: 4 | ||
69 | } | ||
70 | }, | ||
71 | category: { | ||
72 | type: DataTypes.INTEGER, | ||
73 | allowNull: false, | ||
74 | validate: { | ||
75 | categoryValid: function (value) { | ||
76 | const res = isVideoCategoryValid(value) | ||
77 | if (res === false) throw new Error('Video category is not valid.') | ||
78 | } | ||
79 | } | ||
80 | }, | ||
81 | licence: { | ||
82 | type: DataTypes.INTEGER, | ||
83 | allowNull: false, | ||
84 | defaultValue: null, | ||
85 | validate: { | ||
86 | licenceValid: function (value) { | ||
87 | const res = isVideoLicenceValid(value) | ||
88 | if (res === false) throw new Error('Video licence is not valid.') | ||
89 | } | ||
90 | } | ||
91 | }, | ||
92 | language: { | ||
93 | type: DataTypes.INTEGER, | ||
94 | allowNull: true, | ||
95 | validate: { | ||
96 | languageValid: function (value) { | ||
97 | const res = isVideoLanguageValid(value) | ||
98 | if (res === false) throw new Error('Video language is not valid.') | ||
99 | } | ||
100 | } | ||
101 | }, | ||
102 | nsfw: { | ||
103 | type: DataTypes.BOOLEAN, | ||
104 | allowNull: false, | ||
105 | validate: { | ||
106 | nsfwValid: function (value) { | ||
107 | const res = isVideoNSFWValid(value) | ||
108 | if (res === false) throw new Error('Video nsfw attribute is not valid.') | ||
109 | } | ||
110 | } | ||
111 | }, | ||
112 | description: { | ||
113 | type: DataTypes.STRING, | ||
114 | allowNull: false, | ||
115 | validate: { | ||
116 | descriptionValid: function (value) { | ||
117 | const res = isVideoDescriptionValid(value) | ||
118 | if (res === false) throw new Error('Video description is not valid.') | ||
119 | } | ||
120 | } | ||
121 | }, | ||
122 | infoHash: { | ||
123 | type: DataTypes.STRING, | ||
124 | allowNull: false, | ||
125 | validate: { | ||
126 | infoHashValid: function (value) { | ||
127 | const res = isVideoInfoHashValid(value) | ||
128 | if (res === false) throw new Error('Video info hash is not valid.') | ||
129 | } | ||
130 | } | ||
131 | }, | ||
132 | duration: { | ||
133 | type: DataTypes.INTEGER, | ||
134 | allowNull: false, | ||
135 | validate: { | ||
136 | durationValid: function (value) { | ||
137 | const res = isVideoDurationValid(value) | ||
138 | if (res === false) throw new Error('Video duration is not valid.') | ||
139 | } | ||
140 | } | ||
141 | }, | ||
142 | views: { | ||
143 | type: DataTypes.INTEGER, | ||
144 | allowNull: false, | ||
145 | defaultValue: 0, | ||
146 | validate: { | ||
147 | min: 0, | ||
148 | isInt: true | ||
149 | } | ||
150 | }, | ||
151 | likes: { | ||
152 | type: DataTypes.INTEGER, | ||
153 | allowNull: false, | ||
154 | defaultValue: 0, | ||
155 | validate: { | ||
156 | min: 0, | ||
157 | isInt: true | ||
158 | } | ||
159 | }, | ||
160 | dislikes: { | ||
161 | type: DataTypes.INTEGER, | ||
162 | allowNull: false, | ||
163 | defaultValue: 0, | ||
164 | validate: { | ||
165 | min: 0, | ||
166 | isInt: true | ||
167 | } | ||
168 | } | ||
169 | }, | ||
170 | { | ||
171 | indexes: [ | ||
172 | { | ||
173 | fields: [ 'authorId' ] | ||
174 | }, | ||
175 | { | ||
176 | fields: [ 'remoteId' ] | ||
177 | }, | ||
178 | { | ||
179 | fields: [ 'name' ] | ||
180 | }, | ||
181 | { | ||
182 | fields: [ 'createdAt' ] | ||
183 | }, | ||
184 | { | ||
185 | fields: [ 'duration' ] | ||
186 | }, | ||
187 | { | ||
188 | fields: [ 'infoHash' ] | ||
189 | }, | ||
190 | { | ||
191 | fields: [ 'views' ] | ||
192 | }, | ||
193 | { | ||
194 | fields: [ 'likes' ] | ||
195 | } | ||
196 | ], | ||
197 | classMethods: { | ||
198 | associate, | ||
199 | |||
200 | generateThumbnailFromData, | ||
201 | getDurationFromFile, | ||
202 | list, | ||
203 | listForApi, | ||
204 | listOwnedAndPopulateAuthorAndTags, | ||
205 | listOwnedByAuthor, | ||
206 | load, | ||
207 | loadByHostAndRemoteId, | ||
208 | loadAndPopulateAuthor, | ||
209 | loadAndPopulateAuthorAndPodAndTags, | ||
210 | searchAndPopulateAuthorAndPodAndTags | ||
211 | }, | ||
212 | instanceMethods: { | ||
213 | generateMagnetUri, | ||
214 | getVideoFilename, | ||
215 | getThumbnailName, | ||
216 | getPreviewName, | ||
217 | getTorrentName, | ||
218 | isOwned, | ||
219 | toFormatedJSON, | ||
220 | toAddRemoteJSON, | ||
221 | toUpdateRemoteJSON, | ||
222 | transcodeVideofile, | ||
223 | removeFromBlacklist | ||
224 | }, | ||
225 | hooks: { | ||
226 | beforeValidate, | ||
227 | beforeCreate, | ||
228 | afterDestroy | ||
229 | } | ||
230 | } | ||
231 | ) | ||
232 | |||
233 | return Video | ||
234 | } | ||
235 | |||
236 | function beforeValidate (video, options, next) { | ||
237 | // Put a fake infoHash if it does not exists yet | ||
238 | if (video.isOwned() && !video.infoHash) { | ||
239 | // 40 hexa length | ||
240 | video.infoHash = '0123456789abcdef0123456789abcdef01234567' | ||
241 | } | ||
242 | |||
243 | return next(null) | ||
244 | } | ||
245 | |||
246 | function beforeCreate (video, options, next) { | ||
247 | const tasks = [] | ||
248 | |||
249 | if (video.isOwned()) { | ||
250 | const videoPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename()) | ||
251 | |||
252 | tasks.push( | ||
253 | function createVideoTorrent (callback) { | ||
254 | createTorrentFromVideo(video, videoPath, callback) | ||
255 | }, | ||
256 | |||
257 | function createVideoThumbnail (callback) { | ||
258 | createThumbnail(video, videoPath, callback) | ||
259 | }, | ||
260 | |||
261 | function createVideoPreview (callback) { | ||
262 | createPreview(video, videoPath, callback) | ||
263 | } | ||
264 | ) | ||
265 | |||
266 | if (CONFIG.TRANSCODING.ENABLED === true) { | ||
267 | tasks.push( | ||
268 | function createVideoTranscoderJob (callback) { | ||
269 | const dataInput = { | ||
270 | id: video.id | ||
271 | } | ||
272 | |||
273 | JobScheduler.Instance.createJob(options.transaction, 'videoTranscoder', dataInput, callback) | ||
274 | } | ||
275 | ) | ||
276 | } | ||
277 | |||
278 | return parallel(tasks, next) | ||
279 | } | ||
280 | |||
281 | return next() | ||
282 | } | ||
283 | |||
284 | function afterDestroy (video, options, next) { | ||
285 | const tasks = [] | ||
286 | |||
287 | tasks.push( | ||
288 | function (callback) { | ||
289 | removeThumbnail(video, callback) | ||
290 | } | ||
291 | ) | ||
292 | |||
293 | if (video.isOwned()) { | ||
294 | tasks.push( | ||
295 | function removeVideoFile (callback) { | ||
296 | removeFile(video, callback) | ||
297 | }, | ||
298 | |||
299 | function removeVideoTorrent (callback) { | ||
300 | removeTorrent(video, callback) | ||
301 | }, | ||
302 | |||
303 | function removeVideoPreview (callback) { | ||
304 | removePreview(video, callback) | ||
305 | }, | ||
306 | |||
307 | function removeVideoToFriends (callback) { | ||
308 | const params = { | ||
309 | remoteId: video.id | ||
310 | } | ||
311 | |||
312 | removeVideoToFriends(params) | ||
313 | |||
314 | return callback() | ||
315 | } | ||
316 | ) | ||
317 | } | ||
318 | |||
319 | parallel(tasks, next) | ||
320 | } | ||
321 | |||
322 | // ------------------------------ METHODS ------------------------------ | ||
323 | |||
324 | function associate (models) { | ||
325 | this.belongsTo(models.Author, { | ||
326 | foreignKey: { | ||
327 | name: 'authorId', | ||
328 | allowNull: false | ||
329 | }, | ||
330 | onDelete: 'cascade' | ||
331 | }) | ||
332 | |||
333 | this.belongsToMany(models.Tag, { | ||
334 | foreignKey: 'videoId', | ||
335 | through: models.VideoTag, | ||
336 | onDelete: 'cascade' | ||
337 | }) | ||
338 | |||
339 | this.hasMany(models.VideoAbuse, { | ||
340 | foreignKey: { | ||
341 | name: 'videoId', | ||
342 | allowNull: false | ||
343 | }, | ||
344 | onDelete: 'cascade' | ||
345 | }) | ||
346 | } | ||
347 | |||
348 | function generateMagnetUri () { | ||
349 | let baseUrlHttp | ||
350 | let baseUrlWs | ||
351 | |||
352 | if (this.isOwned()) { | ||
353 | baseUrlHttp = CONFIG.WEBSERVER.URL | ||
354 | baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT | ||
355 | } else { | ||
356 | baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host | ||
357 | baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host | ||
358 | } | ||
359 | |||
360 | const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentName() | ||
361 | const announce = baseUrlWs + '/tracker/socket' | ||
362 | const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename() ] | ||
363 | |||
364 | const magnetHash = { | ||
365 | xs, | ||
366 | announce, | ||
367 | urlList, | ||
368 | infoHash: this.infoHash, | ||
369 | name: this.name | ||
370 | } | ||
371 | |||
372 | return magnetUtil.encode(magnetHash) | ||
373 | } | ||
374 | |||
375 | function getVideoFilename () { | ||
376 | if (this.isOwned()) return this.id + this.extname | ||
377 | |||
378 | return this.remoteId + this.extname | ||
379 | } | ||
380 | |||
381 | function getThumbnailName () { | ||
382 | // We always have a copy of the thumbnail | ||
383 | return this.id + '.jpg' | ||
384 | } | ||
385 | |||
386 | function getPreviewName () { | ||
387 | const extension = '.jpg' | ||
388 | |||
389 | if (this.isOwned()) return this.id + extension | ||
390 | |||
391 | return this.remoteId + extension | ||
392 | } | ||
393 | |||
394 | function getTorrentName () { | ||
395 | const extension = '.torrent' | ||
396 | |||
397 | if (this.isOwned()) return this.id + extension | ||
398 | |||
399 | return this.remoteId + extension | ||
400 | } | ||
401 | |||
402 | function isOwned () { | ||
403 | return this.remoteId === null | ||
404 | } | ||
405 | |||
406 | function toFormatedJSON () { | ||
407 | let podHost | ||
408 | |||
409 | if (this.Author.Pod) { | ||
410 | podHost = this.Author.Pod.host | ||
411 | } else { | ||
412 | // It means it's our video | ||
413 | podHost = CONFIG.WEBSERVER.HOST | ||
414 | } | ||
415 | |||
416 | // Maybe our pod is not up to date and there are new categories since our version | ||
417 | let categoryLabel = VIDEO_CATEGORIES[this.category] | ||
418 | if (!categoryLabel) categoryLabel = 'Misc' | ||
419 | |||
420 | // Maybe our pod is not up to date and there are new licences since our version | ||
421 | let licenceLabel = VIDEO_LICENCES[this.licence] | ||
422 | if (!licenceLabel) licenceLabel = 'Unknown' | ||
423 | |||
424 | // Language is an optional attribute | ||
425 | let languageLabel = VIDEO_LANGUAGES[this.language] | ||
426 | if (!languageLabel) languageLabel = 'Unknown' | ||
427 | |||
428 | const json = { | ||
429 | id: this.id, | ||
430 | name: this.name, | ||
431 | category: this.category, | ||
432 | categoryLabel, | ||
433 | licence: this.licence, | ||
434 | licenceLabel, | ||
435 | language: this.language, | ||
436 | languageLabel, | ||
437 | nsfw: this.nsfw, | ||
438 | description: this.description, | ||
439 | podHost, | ||
440 | isLocal: this.isOwned(), | ||
441 | magnetUri: this.generateMagnetUri(), | ||
442 | author: this.Author.name, | ||
443 | duration: this.duration, | ||
444 | views: this.views, | ||
445 | likes: this.likes, | ||
446 | dislikes: this.dislikes, | ||
447 | tags: map(this.Tags, 'name'), | ||
448 | thumbnailPath: join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()), | ||
449 | createdAt: this.createdAt, | ||
450 | updatedAt: this.updatedAt | ||
451 | } | ||
452 | |||
453 | return json | ||
454 | } | ||
455 | |||
456 | function toAddRemoteJSON (callback) { | ||
457 | const self = this | ||
458 | |||
459 | // Get thumbnail data to send to the other pod | ||
460 | const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) | ||
461 | fs.readFile(thumbnailPath, function (err, thumbnailData) { | ||
462 | if (err) { | ||
463 | logger.error('Cannot read the thumbnail of the video') | ||
464 | return callback(err) | ||
465 | } | ||
466 | |||
467 | const remoteVideo = { | ||
468 | name: self.name, | ||
469 | category: self.category, | ||
470 | licence: self.licence, | ||
471 | language: self.language, | ||
472 | nsfw: self.nsfw, | ||
473 | description: self.description, | ||
474 | infoHash: self.infoHash, | ||
475 | remoteId: self.id, | ||
476 | author: self.Author.name, | ||
477 | duration: self.duration, | ||
478 | thumbnailData: thumbnailData.toString('binary'), | ||
479 | tags: map(self.Tags, 'name'), | ||
480 | createdAt: self.createdAt, | ||
481 | updatedAt: self.updatedAt, | ||
482 | extname: self.extname, | ||
483 | views: self.views, | ||
484 | likes: self.likes, | ||
485 | dislikes: self.dislikes | ||
486 | } | ||
487 | |||
488 | return callback(null, remoteVideo) | ||
489 | }) | ||
490 | } | ||
491 | |||
492 | function toUpdateRemoteJSON (callback) { | ||
493 | const json = { | ||
494 | name: this.name, | ||
495 | category: this.category, | ||
496 | licence: this.licence, | ||
497 | language: this.language, | ||
498 | nsfw: this.nsfw, | ||
499 | description: this.description, | ||
500 | infoHash: this.infoHash, | ||
501 | remoteId: this.id, | ||
502 | author: this.Author.name, | ||
503 | duration: this.duration, | ||
504 | tags: map(this.Tags, 'name'), | ||
505 | createdAt: this.createdAt, | ||
506 | updatedAt: this.updatedAt, | ||
507 | extname: this.extname, | ||
508 | views: this.views, | ||
509 | likes: this.likes, | ||
510 | dislikes: this.dislikes | ||
511 | } | ||
512 | |||
513 | return json | ||
514 | } | ||
515 | |||
516 | function transcodeVideofile (finalCallback) { | ||
517 | const video = this | ||
518 | |||
519 | const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR | ||
520 | const newExtname = '.mp4' | ||
521 | const videoInputPath = join(videosDirectory, video.getVideoFilename()) | ||
522 | const videoOutputPath = join(videosDirectory, video.id + '-transcoded' + newExtname) | ||
523 | |||
524 | ffmpeg(videoInputPath) | ||
525 | .output(videoOutputPath) | ||
526 | .videoCodec('libx264') | ||
527 | .outputOption('-threads ' + CONFIG.TRANSCODING.THREADS) | ||
528 | .outputOption('-movflags faststart') | ||
529 | .on('error', finalCallback) | ||
530 | .on('end', function () { | ||
531 | series([ | ||
532 | function removeOldFile (callback) { | ||
533 | fs.unlink(videoInputPath, callback) | ||
534 | }, | ||
535 | |||
536 | function moveNewFile (callback) { | ||
537 | // Important to do this before getVideoFilename() to take in account the new file extension | ||
538 | video.set('extname', newExtname) | ||
539 | |||
540 | const newVideoPath = join(videosDirectory, video.getVideoFilename()) | ||
541 | fs.rename(videoOutputPath, newVideoPath, callback) | ||
542 | }, | ||
543 | |||
544 | function torrent (callback) { | ||
545 | const newVideoPath = join(videosDirectory, video.getVideoFilename()) | ||
546 | createTorrentFromVideo(video, newVideoPath, callback) | ||
547 | }, | ||
548 | |||
549 | function videoExtension (callback) { | ||
550 | video.save().asCallback(callback) | ||
551 | } | ||
552 | |||
553 | ], function (err) { | ||
554 | if (err) { | ||
555 | // Autodescruction... | ||
556 | video.destroy().asCallback(function (err) { | ||
557 | if (err) logger.error('Cannot destruct video after transcoding failure.', { error: err }) | ||
558 | }) | ||
559 | |||
560 | return finalCallback(err) | ||
561 | } | ||
562 | |||
563 | return finalCallback(null) | ||
564 | }) | ||
565 | }) | ||
566 | .run() | ||
567 | } | ||
568 | |||
569 | // ------------------------------ STATICS ------------------------------ | ||
570 | |||
571 | function generateThumbnailFromData (video, thumbnailData, callback) { | ||
572 | // Creating the thumbnail for a remote video | ||
573 | |||
574 | const thumbnailName = video.getThumbnailName() | ||
575 | const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName) | ||
576 | fs.writeFile(thumbnailPath, Buffer.from(thumbnailData, 'binary'), function (err) { | ||
577 | if (err) return callback(err) | ||
578 | |||
579 | return callback(null, thumbnailName) | ||
580 | }) | ||
581 | } | ||
582 | |||
583 | function getDurationFromFile (videoPath, callback) { | ||
584 | ffmpeg.ffprobe(videoPath, function (err, metadata) { | ||
585 | if (err) return callback(err) | ||
586 | |||
587 | return callback(null, Math.floor(metadata.format.duration)) | ||
588 | }) | ||
589 | } | ||
590 | |||
591 | function list (callback) { | ||
592 | return this.findAll().asCallback(callback) | ||
593 | } | ||
594 | |||
595 | function listForApi (start, count, sort, callback) { | ||
596 | // Exclude Blakclisted videos from the list | ||
597 | const query = { | ||
598 | offset: start, | ||
599 | limit: count, | ||
600 | distinct: true, // For the count, a video can have many tags | ||
601 | order: [ getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ], | ||
602 | include: [ | ||
603 | { | ||
604 | model: this.sequelize.models.Author, | ||
605 | include: [ { model: this.sequelize.models.Pod, required: false } ] | ||
606 | }, | ||
607 | |||
608 | this.sequelize.models.Tag | ||
609 | ], | ||
610 | where: createBaseVideosWhere.call(this) | ||
611 | } | ||
612 | |||
613 | return this.findAndCountAll(query).asCallback(function (err, result) { | ||
614 | if (err) return callback(err) | ||
615 | |||
616 | return callback(null, result.rows, result.count) | ||
617 | }) | ||
618 | } | ||
619 | |||
620 | function loadByHostAndRemoteId (fromHost, remoteId, callback) { | ||
621 | const query = { | ||
622 | where: { | ||
623 | remoteId: remoteId | ||
624 | }, | ||
625 | include: [ | ||
626 | { | ||
627 | model: this.sequelize.models.Author, | ||
628 | include: [ | ||
629 | { | ||
630 | model: this.sequelize.models.Pod, | ||
631 | required: true, | ||
632 | where: { | ||
633 | host: fromHost | ||
634 | } | ||
635 | } | ||
636 | ] | ||
637 | } | ||
638 | ] | ||
639 | } | ||
640 | |||
641 | return this.findOne(query).asCallback(callback) | ||
642 | } | ||
643 | |||
644 | function listOwnedAndPopulateAuthorAndTags (callback) { | ||
645 | // If remoteId is null this is *our* video | ||
646 | const query = { | ||
647 | where: { | ||
648 | remoteId: null | ||
649 | }, | ||
650 | include: [ this.sequelize.models.Author, this.sequelize.models.Tag ] | ||
651 | } | ||
652 | |||
653 | return this.findAll(query).asCallback(callback) | ||
654 | } | ||
655 | |||
656 | function listOwnedByAuthor (author, callback) { | ||
657 | const query = { | ||
658 | where: { | ||
659 | remoteId: null | ||
660 | }, | ||
661 | include: [ | ||
662 | { | ||
663 | model: this.sequelize.models.Author, | ||
664 | where: { | ||
665 | name: author | ||
666 | } | ||
667 | } | ||
668 | ] | ||
669 | } | ||
670 | |||
671 | return this.findAll(query).asCallback(callback) | ||
672 | } | ||
673 | |||
674 | function load (id, callback) { | ||
675 | return this.findById(id).asCallback(callback) | ||
676 | } | ||
677 | |||
678 | function loadAndPopulateAuthor (id, callback) { | ||
679 | const options = { | ||
680 | include: [ this.sequelize.models.Author ] | ||
681 | } | ||
682 | |||
683 | return this.findById(id, options).asCallback(callback) | ||
684 | } | ||
685 | |||
686 | function loadAndPopulateAuthorAndPodAndTags (id, callback) { | ||
687 | const options = { | ||
688 | include: [ | ||
689 | { | ||
690 | model: this.sequelize.models.Author, | ||
691 | include: [ { model: this.sequelize.models.Pod, required: false } ] | ||
692 | }, | ||
693 | this.sequelize.models.Tag | ||
694 | ] | ||
695 | } | ||
696 | |||
697 | return this.findById(id, options).asCallback(callback) | ||
698 | } | ||
699 | |||
700 | function searchAndPopulateAuthorAndPodAndTags (value, field, start, count, sort, callback) { | ||
701 | const podInclude: any = { | ||
702 | model: this.sequelize.models.Pod, | ||
703 | required: false | ||
704 | } | ||
705 | |||
706 | const authorInclude: any = { | ||
707 | model: this.sequelize.models.Author, | ||
708 | include: [ | ||
709 | podInclude | ||
710 | ] | ||
711 | } | ||
712 | |||
713 | const tagInclude: any = { | ||
714 | model: this.sequelize.models.Tag | ||
715 | } | ||
716 | |||
717 | const query: any = { | ||
718 | where: createBaseVideosWhere.call(this), | ||
719 | offset: start, | ||
720 | limit: count, | ||
721 | distinct: true, // For the count, a video can have many tags | ||
722 | order: [ getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ] | ||
723 | } | ||
724 | |||
725 | // Make an exact search with the magnet | ||
726 | if (field === 'magnetUri') { | ||
727 | const infoHash = magnetUtil.decode(value).infoHash | ||
728 | query.where.infoHash = infoHash | ||
729 | } else if (field === 'tags') { | ||
730 | const escapedValue = this.sequelize.escape('%' + value + '%') | ||
731 | query.where.id.$in = this.sequelize.literal( | ||
732 | '(SELECT "VideoTags"."videoId" FROM "Tags" INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId" WHERE name LIKE ' + escapedValue + ')' | ||
733 | ) | ||
734 | } else if (field === 'host') { | ||
735 | // FIXME: Include our pod? (not stored in the database) | ||
736 | podInclude.where = { | ||
737 | host: { | ||
738 | $like: '%' + value + '%' | ||
739 | } | ||
740 | } | ||
741 | podInclude.required = true | ||
742 | } else if (field === 'author') { | ||
743 | authorInclude.where = { | ||
744 | name: { | ||
745 | $like: '%' + value + '%' | ||
746 | } | ||
747 | } | ||
748 | |||
749 | // authorInclude.or = true | ||
750 | } else { | ||
751 | query.where[field] = { | ||
752 | $like: '%' + value + '%' | ||
753 | } | ||
754 | } | ||
755 | |||
756 | query.include = [ | ||
757 | authorInclude, tagInclude | ||
758 | ] | ||
759 | |||
760 | if (tagInclude.where) { | ||
761 | // query.include.push([ this.sequelize.models.Tag ]) | ||
762 | } | ||
763 | |||
764 | return this.findAndCountAll(query).asCallback(function (err, result) { | ||
765 | if (err) return callback(err) | ||
766 | |||
767 | return callback(null, result.rows, result.count) | ||
768 | }) | ||
769 | } | ||
770 | |||
771 | // --------------------------------------------------------------------------- | ||
772 | |||
773 | function createBaseVideosWhere () { | ||
774 | return { | ||
775 | id: { | ||
776 | $notIn: this.sequelize.literal( | ||
777 | '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")' | ||
778 | ) | ||
779 | } | ||
780 | } | ||
781 | } | ||
782 | |||
783 | function removeThumbnail (video, callback) { | ||
784 | const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()) | ||
785 | fs.unlink(thumbnailPath, callback) | ||
786 | } | ||
787 | |||
788 | function removeFile (video, callback) { | ||
789 | const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename()) | ||
790 | fs.unlink(filePath, callback) | ||
791 | } | ||
792 | |||
793 | function removeTorrent (video, callback) { | ||
794 | const torrenPath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName()) | ||
795 | fs.unlink(torrenPath, callback) | ||
796 | } | ||
797 | |||
798 | function removePreview (video, callback) { | ||
799 | // Same name than video thumnail | ||
800 | fs.unlink(CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName(), callback) | ||
801 | } | ||
802 | |||
803 | function createTorrentFromVideo (video, videoPath, callback) { | ||
804 | const options = { | ||
805 | announceList: [ | ||
806 | [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ] | ||
807 | ], | ||
808 | urlList: [ | ||
809 | CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + video.getVideoFilename() | ||
810 | ] | ||
811 | } | ||
812 | |||
813 | createTorrent(videoPath, options, function (err, torrent) { | ||
814 | if (err) return callback(err) | ||
815 | |||
816 | const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, video.getTorrentName()) | ||
817 | fs.writeFile(filePath, torrent, function (err) { | ||
818 | if (err) return callback(err) | ||
819 | |||
820 | const parsedTorrent = parseTorrent(torrent) | ||
821 | video.set('infoHash', parsedTorrent.infoHash) | ||
822 | video.validate().asCallback(callback) | ||
823 | }) | ||
824 | }) | ||
825 | } | ||
826 | |||
827 | function createPreview (video, videoPath, callback) { | ||
828 | generateImage(video, videoPath, CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), callback) | ||
829 | } | ||
830 | |||
831 | function createThumbnail (video, videoPath, callback) { | ||
832 | generateImage(video, videoPath, CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), THUMBNAILS_SIZE, callback) | ||
833 | } | ||
834 | |||
835 | function generateImage (video, videoPath, folder, imageName, size, callback?) { | ||
836 | const options: any = { | ||
837 | filename: imageName, | ||
838 | count: 1, | ||
839 | folder | ||
840 | } | ||
841 | |||
842 | if (!callback) { | ||
843 | callback = size | ||
844 | } else { | ||
845 | options.size = size | ||
846 | } | ||
847 | |||
848 | ffmpeg(videoPath) | ||
849 | .on('error', callback) | ||
850 | .on('end', function () { | ||
851 | callback(null, imageName) | ||
852 | }) | ||
853 | .thumbnail(options) | ||
854 | } | ||
855 | |||
856 | function removeFromBlacklist (video, callback) { | ||
857 | // Find the blacklisted video | ||
858 | db.BlacklistedVideo.loadByVideoId(video.id, function (err, video) { | ||
859 | // If an error occured, stop here | ||
860 | if (err) { | ||
861 | logger.error('Error when fetching video from blacklist.', { error: err }) | ||
862 | return callback(err) | ||
863 | } | ||
864 | |||
865 | // If we found the video, remove it from the blacklist | ||
866 | if (video) { | ||
867 | video.destroy().asCallback(callback) | ||
868 | } else { | ||
869 | // If haven't found it, simply ignore it and do nothing | ||
870 | return callback() | ||
871 | } | ||
872 | }) | ||
873 | } | ||