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