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