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'
11 import { TagInstance } from './tag-interface'
19 isVideoDescriptionValid,
21 readFileBufferPromise,
26 } from '../../helpers'
35 VIDEO_FILE_RESOLUTIONS
36 } from '../../initializers'
37 import { removeVideoToFriends } from '../../lib'
38 import { VideoFileInstance } from './video-file-interface'
40 import { addMethodsToModel, getSort } from '../utils'
46 } from './video-interface'
48 let Video: Sequelize.Model<VideoInstance, VideoAttributes>
49 let generateMagnetUri: VideoMethods.GenerateMagnetUri
50 let getVideoFilename: VideoMethods.GetVideoFilename
51 let getThumbnailName: VideoMethods.GetThumbnailName
52 let getPreviewName: VideoMethods.GetPreviewName
53 let getTorrentFileName: VideoMethods.GetTorrentFileName
54 let isOwned: VideoMethods.IsOwned
55 let toFormattedJSON: VideoMethods.ToFormattedJSON
56 let toAddRemoteJSON: VideoMethods.ToAddRemoteJSON
57 let toUpdateRemoteJSON: VideoMethods.ToUpdateRemoteJSON
58 let transcodeVideofile: VideoMethods.TranscodeVideofile
59 let createPreview: VideoMethods.CreatePreview
60 let createThumbnail: VideoMethods.CreateThumbnail
61 let getVideoFilePath: VideoMethods.GetVideoFilePath
62 let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
64 let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
65 let getDurationFromFile: VideoMethods.GetDurationFromFile
66 let list: VideoMethods.List
67 let listForApi: VideoMethods.ListForApi
68 let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID
69 let listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAndTags
70 let listOwnedByAuthor: VideoMethods.ListOwnedByAuthor
71 let load: VideoMethods.Load
72 let loadByUUID: VideoMethods.LoadByUUID
73 let loadAndPopulateAuthor: VideoMethods.LoadAndPopulateAuthor
74 let loadAndPopulateAuthorAndPodAndTags: VideoMethods.LoadAndPopulateAuthorAndPodAndTags
75 let loadByUUIDAndPopulateAuthorAndPodAndTags: VideoMethods.LoadByUUIDAndPopulateAuthorAndPodAndTags
76 let searchAndPopulateAuthorAndPodAndTags: VideoMethods.SearchAndPopulateAuthorAndPodAndTags
77 let removeThumbnail: VideoMethods.RemoveThumbnail
78 let removePreview: VideoMethods.RemovePreview
79 let removeFile: VideoMethods.RemoveFile
80 let removeTorrent: VideoMethods.RemoveTorrent
82 export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
83 Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
87 defaultValue: DataTypes.UUIDV4,
94 type: DataTypes.STRING,
98 const res = isVideoNameValid(value)
99 if (res === false) throw new Error('Video name is not valid.')
104 type: DataTypes.INTEGER,
107 categoryValid: value => {
108 const res = isVideoCategoryValid(value)
109 if (res === false) throw new Error('Video category is not valid.')
114 type: DataTypes.INTEGER,
118 licenceValid: value => {
119 const res = isVideoLicenceValid(value)
120 if (res === false) throw new Error('Video licence is not valid.')
125 type: DataTypes.INTEGER,
128 languageValid: value => {
129 const res = isVideoLanguageValid(value)
130 if (res === false) throw new Error('Video language is not valid.')
135 type: DataTypes.BOOLEAN,
138 nsfwValid: value => {
139 const res = isVideoNSFWValid(value)
140 if (res === false) throw new Error('Video nsfw attribute is not valid.')
145 type: DataTypes.STRING,
148 descriptionValid: value => {
149 const res = isVideoDescriptionValid(value)
150 if (res === false) throw new Error('Video description is not valid.')
155 type: DataTypes.INTEGER,
158 durationValid: value => {
159 const res = isVideoDurationValid(value)
160 if (res === false) throw new Error('Video duration is not valid.')
165 type: DataTypes.INTEGER,
174 type: DataTypes.INTEGER,
183 type: DataTypes.INTEGER,
192 type: DataTypes.BOOLEAN,
200 fields: [ 'authorId' ]
206 fields: [ 'createdAt' ]
209 fields: [ 'duration' ]
227 const classMethods = [
230 generateThumbnailFromData,
234 listOwnedAndPopulateAuthorAndTags,
237 loadAndPopulateAuthor,
238 loadAndPopulateAuthorAndPodAndTags,
241 loadByUUIDAndPopulateAuthorAndPodAndTags,
242 searchAndPopulateAuthorAndPodAndTags
244 const instanceMethods = [
247 createTorrentAndSetInfoHash,
264 addMethodsToModel(Video, classMethods, instanceMethods)
269 // ------------------------------ METHODS ------------------------------
271 function associate (models) {
272 Video.belongsTo(models.Author, {
280 Video.belongsToMany(models.Tag, {
281 foreignKey: 'videoId',
282 through: models.VideoTag,
286 Video.hasMany(models.VideoAbuse, {
294 Video.hasMany(models.VideoFile, {
303 function afterDestroy (video: VideoInstance, options: { transaction: Sequelize.Transaction }) {
307 video.removeThumbnail()
310 if (video.isOwned()) {
311 const removeVideoToFriendsParams = {
316 video.removePreview(),
317 removeVideoToFriends(removeVideoToFriendsParams, options.transaction)
320 // Remove physical files and torrents
321 video.VideoFiles.forEach(file => {
322 video.removeFile(file),
323 video.removeTorrent(file)
327 return Promise.all(tasks)
330 getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
331 // return this.uuid + '-' + VIDEO_FILE_RESOLUTIONS[videoFile.resolution] + videoFile.extname
332 return this.uuid + videoFile.extname
335 getThumbnailName = function (this: VideoInstance) {
336 // We always have a copy of the thumbnail
337 const extension = '.jpg'
338 return this.uuid + extension
341 getPreviewName = function (this: VideoInstance) {
342 const extension = '.jpg'
343 return this.uuid + extension
346 getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
347 const extension = '.torrent'
348 // return this.uuid + '-' + VIDEO_FILE_RESOLUTIONS[videoFile.resolution] + extension
349 return this.uuid + extension
352 isOwned = function (this: VideoInstance) {
353 return this.remote === false
356 createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
357 return generateImage(this, this.getVideoFilePath(videoFile), CONFIG.STORAGE.PREVIEWS_DIR, this.getPreviewName(), null)
360 createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
361 return generateImage(this, this.getVideoFilePath(videoFile), CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName(), THUMBNAILS_SIZE)
364 getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
365 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
368 createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFileInstance) {
371 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
374 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
378 return createTorrentPromise(this.getVideoFilePath(videoFile), options)
380 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
381 logger.info('Creating torrent %s.', filePath)
383 return writeFilePromise(filePath, torrent).then(() => torrent)
386 const parsedTorrent = parseTorrent(torrent)
388 videoFile.infoHash = parsedTorrent.infoHash
392 generateMagnetUri = function (this: VideoInstance, videoFile: VideoFileInstance) {
396 if (this.isOwned()) {
397 baseUrlHttp = CONFIG.WEBSERVER.URL
398 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
400 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
401 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
404 const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
405 const announce = [ baseUrlWs + '/tracker/socket' ]
406 const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
412 infoHash: videoFile.infoHash,
416 return magnetUtil.encode(magnetHash)
419 toFormattedJSON = function (this: VideoInstance) {
422 if (this.Author.Pod) {
423 podHost = this.Author.Pod.host
425 // It means it's our video
426 podHost = CONFIG.WEBSERVER.HOST
429 // Maybe our pod is not up to date and there are new categories since our version
430 let categoryLabel = VIDEO_CATEGORIES[this.category]
431 if (!categoryLabel) categoryLabel = 'Misc'
433 // Maybe our pod is not up to date and there are new licences since our version
434 let licenceLabel = VIDEO_LICENCES[this.licence]
435 if (!licenceLabel) licenceLabel = 'Unknown'
437 // Language is an optional attribute
438 let languageLabel = VIDEO_LANGUAGES[this.language]
439 if (!languageLabel) languageLabel = 'Unknown'
445 category: this.category,
447 licence: this.licence,
449 language: this.language,
452 description: this.description,
454 isLocal: this.isOwned(),
455 author: this.Author.name,
456 duration: this.duration,
459 dislikes: this.dislikes,
460 tags: map<TagInstance, string>(this.Tags, 'name'),
461 thumbnailPath: join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()),
462 previewPath: join(STATIC_PATHS.PREVIEWS, this.getPreviewName()),
463 createdAt: this.createdAt,
464 updatedAt: this.updatedAt,
468 this.VideoFiles.forEach(videoFile => {
469 let resolutionLabel = VIDEO_FILE_RESOLUTIONS[videoFile.resolution]
470 if (!resolutionLabel) resolutionLabel = 'Unknown'
472 const videoFileJson = {
473 resolution: videoFile.resolution,
475 magnetUri: this.generateMagnetUri(videoFile),
479 json.files.push(videoFileJson)
485 toAddRemoteJSON = function (this: VideoInstance) {
486 // Get thumbnail data to send to the other pod
487 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
489 return readFileBufferPromise(thumbnailPath).then(thumbnailData => {
490 const remoteVideo = {
493 category: this.category,
494 licence: this.licence,
495 language: this.language,
497 description: this.description,
498 author: this.Author.name,
499 duration: this.duration,
500 thumbnailData: thumbnailData.toString('binary'),
501 tags: map<TagInstance, string>(this.Tags, 'name'),
502 createdAt: this.createdAt,
503 updatedAt: this.updatedAt,
506 dislikes: this.dislikes,
510 this.VideoFiles.forEach(videoFile => {
511 remoteVideo.files.push({
512 infoHash: videoFile.infoHash,
513 resolution: videoFile.resolution,
514 extname: videoFile.extname,
523 toUpdateRemoteJSON = function (this: VideoInstance) {
527 category: this.category,
528 licence: this.licence,
529 language: this.language,
531 description: this.description,
532 author: this.Author.name,
533 duration: this.duration,
534 tags: map<TagInstance, string>(this.Tags, 'name'),
535 createdAt: this.createdAt,
536 updatedAt: this.updatedAt,
539 dislikes: this.dislikes,
543 this.VideoFiles.forEach(videoFile => {
545 infoHash: videoFile.infoHash,
546 resolution: videoFile.resolution,
547 extname: videoFile.extname,
555 transcodeVideofile = function (this: VideoInstance, inputVideoFile: VideoFileInstance) {
556 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
557 const newExtname = '.mp4'
558 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
559 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
561 return new Promise<void>((res, rej) => {
562 ffmpeg(videoInputPath)
563 .output(videoOutputPath)
564 .videoCodec('libx264')
565 .outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
566 .outputOption('-movflags faststart')
570 return unlinkPromise(videoInputPath)
572 // Important to do this before getVideoFilename() to take in account the new file extension
573 inputVideoFile.set('extname', newExtname)
575 return renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
578 return this.createTorrentAndSetInfoHash(inputVideoFile)
581 return inputVideoFile.save()
587 // Auto destruction...
588 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
597 removeThumbnail = function (this: VideoInstance) {
598 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
599 return unlinkPromise(thumbnailPath)
602 removePreview = function (this: VideoInstance) {
603 // Same name than video thumbnail
604 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
607 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
608 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
609 return unlinkPromise(filePath)
612 removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
613 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
614 return unlinkPromise(torrentPath)
617 // ------------------------------ STATICS ------------------------------
619 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
620 // Creating the thumbnail for a remote video
622 const thumbnailName = video.getThumbnailName()
623 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
624 return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
629 getDurationFromFile = function (videoPath: string) {
630 return new Promise<number>((res, rej) => {
631 ffmpeg.ffprobe(videoPath, (err, metadata) => {
632 if (err) return rej(err)
634 return res(Math.floor(metadata.format.duration))
641 include: [ Video['sequelize'].models.VideoFile ]
644 return Video.findAll(query)
647 listForApi = function (start: number, count: number, sort: string) {
648 // Exclude blacklisted videos from the list
653 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
656 model: Video['sequelize'].models.Author,
657 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
659 Video['sequelize'].models.Tag,
660 Video['sequelize'].models.VideoFile
662 where: createBaseVideosWhere()
665 return Video.findAndCountAll(query).then(({ rows, count }) => {
673 loadByHostAndUUID = function (fromHost: string, uuid: string) {
680 model: Video['sequelize'].models.VideoFile
683 model: Video['sequelize'].models.Author,
686 model: Video['sequelize'].models.Pod,
697 return Video.findOne(query)
700 listOwnedAndPopulateAuthorAndTags = function () {
706 Video['sequelize'].models.VideoFile,
707 Video['sequelize'].models.Author,
708 Video['sequelize'].models.Tag
712 return Video.findAll(query)
715 listOwnedByAuthor = function (author: string) {
722 model: Video['sequelize'].models.VideoFile
725 model: Video['sequelize'].models.Author,
733 return Video.findAll(query)
736 load = function (id: number) {
737 return Video.findById(id)
740 loadByUUID = function (uuid: string) {
745 include: [ Video['sequelize'].models.VideoFile ]
747 return Video.findOne(query)
750 loadAndPopulateAuthor = function (id: number) {
752 include: [ Video['sequelize'].models.VideoFile, Video['sequelize'].models.Author ]
755 return Video.findById(id, options)
758 loadAndPopulateAuthorAndPodAndTags = function (id: number) {
762 model: Video['sequelize'].models.Author,
763 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
765 Video['sequelize'].models.Tag,
766 Video['sequelize'].models.VideoFile
770 return Video.findById(id, options)
773 loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
780 model: Video['sequelize'].models.Author,
781 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
783 Video['sequelize'].models.Tag,
784 Video['sequelize'].models.VideoFile
788 return Video.findOne(options)
791 searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
792 const podInclude: Sequelize.IncludeOptions = {
793 model: Video['sequelize'].models.Pod,
797 const authorInclude: Sequelize.IncludeOptions = {
798 model: Video['sequelize'].models.Author,
804 const tagInclude: Sequelize.IncludeOptions = {
805 model: Video['sequelize'].models.Tag
808 const videoFileInclude: Sequelize.IncludeOptions = {
809 model: Video['sequelize'].models.VideoFile
812 const query: Sequelize.FindOptions<VideoAttributes> = {
814 where: createBaseVideosWhere(),
817 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
820 // Make an exact search with the magnet
821 if (field === 'magnetUri') {
822 videoFileInclude.where = {
823 infoHash: magnetUtil.decode(value).infoHash
825 } else if (field === 'tags') {
826 const escapedValue = Video['sequelize'].escape('%' + value + '%')
827 query.where['id'].$in = Video['sequelize'].literal(
828 `(SELECT "VideoTags"."videoId"
830 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
831 WHERE name ILIKE ${escapedValue}
834 } else if (field === 'host') {
835 // FIXME: Include our pod? (not stored in the database)
838 $iLike: '%' + value + '%'
841 podInclude.required = true
842 } else if (field === 'author') {
843 authorInclude.where = {
845 $iLike: '%' + value + '%'
849 // authorInclude.or = true
851 query.where[field] = {
852 $iLike: '%' + value + '%'
857 authorInclude, tagInclude, videoFileInclude
860 return Video.findAndCountAll(query).then(({ rows, count }) => {
868 // ---------------------------------------------------------------------------
870 function createBaseVideosWhere () {
873 $notIn: Video['sequelize'].literal(
874 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
880 function generateImage (video: VideoInstance, videoPath: string, folder: string, imageName: string, size: string) {
888 options['size'] = size
891 return new Promise<string>((res, rej) => {
894 .on('end', () => res(imageName))