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