]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video.ts
Implement video transcoding on server side
[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 resolutionWidthSizes = {
631 1: '240x?',
632 2: '360x?',
633 3: '480x?',
634 4: '720x?',
635 5: '1080x?'
636 }
637
638 return new Promise<void>((res, rej) => {
639 ffmpeg(videoInputPath)
640 .output(videoOutputPath)
641 .videoCodec('libx264')
642 .size(resolutionWidthSizes[resolution])
643 .outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
644 .outputOption('-movflags faststart')
645 .on('error', rej)
646 .on('end', () => {
647 return statPromise(videoOutputPath)
648 .then(stats => {
649 newVideoFile.set('size', stats.size)
650
651 return undefined
652 })
653 .then(() => {
654 return this.createTorrentAndSetInfoHash(newVideoFile)
655 })
656 .then(() => {
657 return newVideoFile.save()
658 })
659 .then(() => {
660 return this.VideoFiles.push(newVideoFile)
661 })
662 .then(() => {
663 return res()
664 })
665 .catch(rej)
666 })
667 .run()
668 })
669 }
670
671 getOriginalFileHeight = function (this: VideoInstance) {
672 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
673
674 return new Promise<number>((res, rej) => {
675 ffmpeg.ffprobe(originalFilePath, (err, metadata) => {
676 if (err) return rej(err)
677
678 const videoStream = metadata.streams.find(s => s.codec_type === 'video')
679 return res(videoStream.height)
680 })
681 })
682 }
683
684 removeThumbnail = function (this: VideoInstance) {
685 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
686 return unlinkPromise(thumbnailPath)
687 }
688
689 removePreview = function (this: VideoInstance) {
690 // Same name than video thumbnail
691 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
692 }
693
694 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
695 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
696 return unlinkPromise(filePath)
697 }
698
699 removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
700 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
701 return unlinkPromise(torrentPath)
702 }
703
704 // ------------------------------ STATICS ------------------------------
705
706 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
707 // Creating the thumbnail for a remote video
708
709 const thumbnailName = video.getThumbnailName()
710 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
711 return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
712 return thumbnailName
713 })
714 }
715
716 getDurationFromFile = function (videoPath: string) {
717 return new Promise<number>((res, rej) => {
718 ffmpeg.ffprobe(videoPath, (err, metadata) => {
719 if (err) return rej(err)
720
721 return res(Math.floor(metadata.format.duration))
722 })
723 })
724 }
725
726 list = function () {
727 const query = {
728 include: [ Video['sequelize'].models.VideoFile ]
729 }
730
731 return Video.findAll(query)
732 }
733
734 listForApi = function (start: number, count: number, sort: string) {
735 // Exclude blacklisted videos from the list
736 const query = {
737 distinct: true,
738 offset: start,
739 limit: count,
740 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
741 include: [
742 {
743 model: Video['sequelize'].models.Author,
744 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
745 },
746 Video['sequelize'].models.Tag,
747 Video['sequelize'].models.VideoFile
748 ],
749 where: createBaseVideosWhere()
750 }
751
752 return Video.findAndCountAll(query).then(({ rows, count }) => {
753 return {
754 data: rows,
755 total: count
756 }
757 })
758 }
759
760 loadByHostAndUUID = function (fromHost: string, uuid: string) {
761 const query = {
762 where: {
763 uuid
764 },
765 include: [
766 {
767 model: Video['sequelize'].models.VideoFile
768 },
769 {
770 model: Video['sequelize'].models.Author,
771 include: [
772 {
773 model: Video['sequelize'].models.Pod,
774 required: true,
775 where: {
776 host: fromHost
777 }
778 }
779 ]
780 }
781 ]
782 }
783
784 return Video.findOne(query)
785 }
786
787 listOwnedAndPopulateAuthorAndTags = function () {
788 const query = {
789 where: {
790 remote: false
791 },
792 include: [
793 Video['sequelize'].models.VideoFile,
794 Video['sequelize'].models.Author,
795 Video['sequelize'].models.Tag
796 ]
797 }
798
799 return Video.findAll(query)
800 }
801
802 listOwnedByAuthor = function (author: string) {
803 const query = {
804 where: {
805 remote: false
806 },
807 include: [
808 {
809 model: Video['sequelize'].models.VideoFile
810 },
811 {
812 model: Video['sequelize'].models.Author,
813 where: {
814 name: author
815 }
816 }
817 ]
818 }
819
820 return Video.findAll(query)
821 }
822
823 load = function (id: number) {
824 return Video.findById(id)
825 }
826
827 loadByUUID = function (uuid: string) {
828 const query = {
829 where: {
830 uuid
831 },
832 include: [ Video['sequelize'].models.VideoFile ]
833 }
834 return Video.findOne(query)
835 }
836
837 loadAndPopulateAuthor = function (id: number) {
838 const options = {
839 include: [ Video['sequelize'].models.VideoFile, Video['sequelize'].models.Author ]
840 }
841
842 return Video.findById(id, options)
843 }
844
845 loadAndPopulateAuthorAndPodAndTags = function (id: number) {
846 const options = {
847 include: [
848 {
849 model: Video['sequelize'].models.Author,
850 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
851 },
852 Video['sequelize'].models.Tag,
853 Video['sequelize'].models.VideoFile
854 ]
855 }
856
857 return Video.findById(id, options)
858 }
859
860 loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
861 const options = {
862 where: {
863 uuid
864 },
865 include: [
866 {
867 model: Video['sequelize'].models.Author,
868 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
869 },
870 Video['sequelize'].models.Tag,
871 Video['sequelize'].models.VideoFile
872 ]
873 }
874
875 return Video.findOne(options)
876 }
877
878 searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
879 const podInclude: Sequelize.IncludeOptions = {
880 model: Video['sequelize'].models.Pod,
881 required: false
882 }
883
884 const authorInclude: Sequelize.IncludeOptions = {
885 model: Video['sequelize'].models.Author,
886 include: [
887 podInclude
888 ]
889 }
890
891 const tagInclude: Sequelize.IncludeOptions = {
892 model: Video['sequelize'].models.Tag
893 }
894
895 const videoFileInclude: Sequelize.IncludeOptions = {
896 model: Video['sequelize'].models.VideoFile
897 }
898
899 const query: Sequelize.FindOptions<VideoAttributes> = {
900 distinct: true,
901 where: createBaseVideosWhere(),
902 offset: start,
903 limit: count,
904 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
905 }
906
907 // Make an exact search with the magnet
908 if (field === 'magnetUri') {
909 videoFileInclude.where = {
910 infoHash: magnetUtil.decode(value).infoHash
911 }
912 } else if (field === 'tags') {
913 const escapedValue = Video['sequelize'].escape('%' + value + '%')
914 query.where['id'].$in = Video['sequelize'].literal(
915 `(SELECT "VideoTags"."videoId"
916 FROM "Tags"
917 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
918 WHERE name ILIKE ${escapedValue}
919 )`
920 )
921 } else if (field === 'host') {
922 // FIXME: Include our pod? (not stored in the database)
923 podInclude.where = {
924 host: {
925 $iLike: '%' + value + '%'
926 }
927 }
928 podInclude.required = true
929 } else if (field === 'author') {
930 authorInclude.where = {
931 name: {
932 $iLike: '%' + value + '%'
933 }
934 }
935
936 // authorInclude.or = true
937 } else {
938 query.where[field] = {
939 $iLike: '%' + value + '%'
940 }
941 }
942
943 query.include = [
944 authorInclude, tagInclude, videoFileInclude
945 ]
946
947 return Video.findAndCountAll(query).then(({ rows, count }) => {
948 return {
949 data: rows,
950 total: count
951 }
952 })
953 }
954
955 // ---------------------------------------------------------------------------
956
957 function createBaseVideosWhere () {
958 return {
959 id: {
960 $notIn: Video['sequelize'].literal(
961 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
962 )
963 }
964 }
965 }
966
967 function generateImage (video: VideoInstance, videoPath: string, folder: string, imageName: string, size: string) {
968 const options = {
969 filename: imageName,
970 count: 1,
971 folder
972 }
973
974 if (size) {
975 options['size'] = size
976 }
977
978 return new Promise<string>((res, rej) => {
979 ffmpeg(videoPath)
980 .on('error', rej)
981 .on('end', () => res(imageName))
982 .thumbnail(options)
983 })
984 }