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,
27 } from '../../helpers'
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'
42 import { addMethodsToModel, getSort } from '../utils'
48 } from './video-interface'
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
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
87 export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
88 Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
92 defaultValue: DataTypes.UUIDV4,
99 type: DataTypes.STRING,
102 nameValid: value => {
103 const res = isVideoNameValid(value)
104 if (res === false) throw new Error('Video name is not valid.')
109 type: DataTypes.INTEGER,
112 categoryValid: value => {
113 const res = isVideoCategoryValid(value)
114 if (res === false) throw new Error('Video category is not valid.')
119 type: DataTypes.INTEGER,
123 licenceValid: value => {
124 const res = isVideoLicenceValid(value)
125 if (res === false) throw new Error('Video licence is not valid.')
130 type: DataTypes.INTEGER,
133 languageValid: value => {
134 const res = isVideoLanguageValid(value)
135 if (res === false) throw new Error('Video language is not valid.')
140 type: DataTypes.BOOLEAN,
143 nsfwValid: value => {
144 const res = isVideoNSFWValid(value)
145 if (res === false) throw new Error('Video nsfw attribute is not valid.')
150 type: DataTypes.STRING,
153 descriptionValid: value => {
154 const res = isVideoDescriptionValid(value)
155 if (res === false) throw new Error('Video description is not valid.')
160 type: DataTypes.INTEGER,
163 durationValid: value => {
164 const res = isVideoDurationValid(value)
165 if (res === false) throw new Error('Video duration is not valid.')
170 type: DataTypes.INTEGER,
179 type: DataTypes.INTEGER,
188 type: DataTypes.INTEGER,
197 type: DataTypes.BOOLEAN,
205 fields: [ 'authorId' ]
211 fields: [ 'createdAt' ]
214 fields: [ 'duration' ]
232 const classMethods = [
235 generateThumbnailFromData,
239 listOwnedAndPopulateAuthorAndTags,
242 loadAndPopulateAuthor,
243 loadAndPopulateAuthorAndPodAndTags,
246 loadByUUIDAndPopulateAuthorAndPodAndTags,
247 searchAndPopulateAuthorAndPodAndTags
249 const instanceMethods = [
252 createTorrentAndSetInfoHash,
268 optimizeOriginalVideofile,
269 transcodeOriginalVideofile,
270 getOriginalFileHeight
272 addMethodsToModel(Video, classMethods, instanceMethods)
277 // ------------------------------ METHODS ------------------------------
279 function associate (models) {
280 Video.belongsTo(models.Author, {
288 Video.belongsToMany(models.Tag, {
289 foreignKey: 'videoId',
290 through: models.VideoTag,
294 Video.hasMany(models.VideoAbuse, {
302 Video.hasMany(models.VideoFile, {
311 function afterDestroy (video: VideoInstance, options: { transaction: Sequelize.Transaction }) {
315 video.removeThumbnail()
318 if (video.isOwned()) {
319 const removeVideoToFriendsParams = {
324 video.removePreview(),
325 removeVideoToFriends(removeVideoToFriendsParams, options.transaction)
328 // Remove physical files and torrents
329 video.VideoFiles.forEach(file => {
330 video.removeFile(file),
331 video.removeTorrent(file)
335 return Promise.all(tasks)
338 getOriginalFile = function (this: VideoInstance) {
339 if (Array.isArray(this.VideoFiles) === false) return undefined
341 return this.VideoFiles.find(file => file.resolution === VideoResolution.ORIGINAL)
344 getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
345 return this.uuid + '-' + VIDEO_FILE_RESOLUTIONS[videoFile.resolution] + videoFile.extname
348 getThumbnailName = function (this: VideoInstance) {
349 // We always have a copy of the thumbnail
350 const extension = '.jpg'
351 return this.uuid + extension
354 getPreviewName = function (this: VideoInstance) {
355 const extension = '.jpg'
356 return this.uuid + extension
359 getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
360 const extension = '.torrent'
361 return this.uuid + '-' + VIDEO_FILE_RESOLUTIONS[videoFile.resolution] + extension
364 isOwned = function (this: VideoInstance) {
365 return this.remote === false
368 createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
369 return generateImage(this, this.getVideoFilePath(videoFile), CONFIG.STORAGE.PREVIEWS_DIR, this.getPreviewName(), null)
372 createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
373 return generateImage(this, this.getVideoFilePath(videoFile), CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName(), THUMBNAILS_SIZE)
376 getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
377 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
380 createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFileInstance) {
383 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
386 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
390 return createTorrentPromise(this.getVideoFilePath(videoFile), options)
392 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
393 logger.info('Creating torrent %s.', filePath)
395 return writeFilePromise(filePath, torrent).then(() => torrent)
398 const parsedTorrent = parseTorrent(torrent)
400 videoFile.infoHash = parsedTorrent.infoHash
404 generateMagnetUri = function (this: VideoInstance, videoFile: VideoFileInstance) {
408 if (this.isOwned()) {
409 baseUrlHttp = CONFIG.WEBSERVER.URL
410 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
412 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
413 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
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) ]
424 infoHash: videoFile.infoHash,
428 return magnetUtil.encode(magnetHash)
431 toFormattedJSON = function (this: VideoInstance) {
434 if (this.Author.Pod) {
435 podHost = this.Author.Pod.host
437 // It means it's our video
438 podHost = CONFIG.WEBSERVER.HOST
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'
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'
449 // Language is an optional attribute
450 let languageLabel = VIDEO_LANGUAGES[this.language]
451 if (!languageLabel) languageLabel = 'Unknown'
457 category: this.category,
459 licence: this.licence,
461 language: this.language,
464 description: this.description,
466 isLocal: this.isOwned(),
467 author: this.Author.name,
468 duration: this.duration,
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,
480 this.VideoFiles.forEach(videoFile => {
481 let resolutionLabel = VIDEO_FILE_RESOLUTIONS[videoFile.resolution]
482 if (!resolutionLabel) resolutionLabel = 'Unknown'
484 const videoFileJson = {
485 resolution: videoFile.resolution,
487 magnetUri: this.generateMagnetUri(videoFile),
491 json.files.push(videoFileJson)
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())
501 return readFileBufferPromise(thumbnailPath).then(thumbnailData => {
502 const remoteVideo = {
505 category: this.category,
506 licence: this.licence,
507 language: this.language,
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,
518 dislikes: this.dislikes,
522 this.VideoFiles.forEach(videoFile => {
523 remoteVideo.files.push({
524 infoHash: videoFile.infoHash,
525 resolution: videoFile.resolution,
526 extname: videoFile.extname,
535 toUpdateRemoteJSON = function (this: VideoInstance) {
539 category: this.category,
540 licence: this.licence,
541 language: this.language,
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,
551 dislikes: this.dislikes,
555 this.VideoFiles.forEach(videoFile => {
557 infoHash: videoFile.infoHash,
558 resolution: videoFile.resolution,
559 extname: videoFile.extname,
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)
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')
583 return unlinkPromise(videoInputPath)
585 // Important to do this before getVideoFilename() to take in account the new file extension
586 inputVideoFile.set('extname', newExtname)
588 return renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
591 return statPromise(this.getVideoFilePath(inputVideoFile))
594 return inputVideoFile.set('size', stats.size)
597 return this.createTorrentAndSetInfoHash(inputVideoFile)
600 return inputVideoFile.save()
606 // Auto destruction...
607 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
616 transcodeOriginalVideofile = function (this: VideoInstance, resolution: VideoResolution) {
617 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
618 const extname = '.mp4'
620 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
621 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
623 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
629 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
630 const resolutionWidthSizes = {
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')
647 return statPromise(videoOutputPath)
649 newVideoFile.set('size', stats.size)
654 return this.createTorrentAndSetInfoHash(newVideoFile)
657 return newVideoFile.save()
660 return this.VideoFiles.push(newVideoFile)
671 getOriginalFileHeight = function (this: VideoInstance) {
672 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
674 return new Promise<number>((res, rej) => {
675 ffmpeg.ffprobe(originalFilePath, (err, metadata) => {
676 if (err) return rej(err)
678 const videoStream = metadata.streams.find(s => s.codec_type === 'video')
679 return res(videoStream.height)
684 removeThumbnail = function (this: VideoInstance) {
685 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
686 return unlinkPromise(thumbnailPath)
689 removePreview = function (this: VideoInstance) {
690 // Same name than video thumbnail
691 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
694 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
695 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
696 return unlinkPromise(filePath)
699 removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
700 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
701 return unlinkPromise(torrentPath)
704 // ------------------------------ STATICS ------------------------------
706 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
707 // Creating the thumbnail for a remote video
709 const thumbnailName = video.getThumbnailName()
710 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
711 return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
716 getDurationFromFile = function (videoPath: string) {
717 return new Promise<number>((res, rej) => {
718 ffmpeg.ffprobe(videoPath, (err, metadata) => {
719 if (err) return rej(err)
721 return res(Math.floor(metadata.format.duration))
728 include: [ Video['sequelize'].models.VideoFile ]
731 return Video.findAll(query)
734 listForApi = function (start: number, count: number, sort: string) {
735 // Exclude blacklisted videos from the list
740 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
743 model: Video['sequelize'].models.Author,
744 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
746 Video['sequelize'].models.Tag,
747 Video['sequelize'].models.VideoFile
749 where: createBaseVideosWhere()
752 return Video.findAndCountAll(query).then(({ rows, count }) => {
760 loadByHostAndUUID = function (fromHost: string, uuid: string) {
767 model: Video['sequelize'].models.VideoFile
770 model: Video['sequelize'].models.Author,
773 model: Video['sequelize'].models.Pod,
784 return Video.findOne(query)
787 listOwnedAndPopulateAuthorAndTags = function () {
793 Video['sequelize'].models.VideoFile,
794 Video['sequelize'].models.Author,
795 Video['sequelize'].models.Tag
799 return Video.findAll(query)
802 listOwnedByAuthor = function (author: string) {
809 model: Video['sequelize'].models.VideoFile
812 model: Video['sequelize'].models.Author,
820 return Video.findAll(query)
823 load = function (id: number) {
824 return Video.findById(id)
827 loadByUUID = function (uuid: string) {
832 include: [ Video['sequelize'].models.VideoFile ]
834 return Video.findOne(query)
837 loadAndPopulateAuthor = function (id: number) {
839 include: [ Video['sequelize'].models.VideoFile, Video['sequelize'].models.Author ]
842 return Video.findById(id, options)
845 loadAndPopulateAuthorAndPodAndTags = function (id: number) {
849 model: Video['sequelize'].models.Author,
850 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
852 Video['sequelize'].models.Tag,
853 Video['sequelize'].models.VideoFile
857 return Video.findById(id, options)
860 loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
867 model: Video['sequelize'].models.Author,
868 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
870 Video['sequelize'].models.Tag,
871 Video['sequelize'].models.VideoFile
875 return Video.findOne(options)
878 searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
879 const podInclude: Sequelize.IncludeOptions = {
880 model: Video['sequelize'].models.Pod,
884 const authorInclude: Sequelize.IncludeOptions = {
885 model: Video['sequelize'].models.Author,
891 const tagInclude: Sequelize.IncludeOptions = {
892 model: Video['sequelize'].models.Tag
895 const videoFileInclude: Sequelize.IncludeOptions = {
896 model: Video['sequelize'].models.VideoFile
899 const query: Sequelize.FindOptions<VideoAttributes> = {
901 where: createBaseVideosWhere(),
904 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
907 // Make an exact search with the magnet
908 if (field === 'magnetUri') {
909 videoFileInclude.where = {
910 infoHash: magnetUtil.decode(value).infoHash
912 } else if (field === 'tags') {
913 const escapedValue = Video['sequelize'].escape('%' + value + '%')
914 query.where['id'].$in = Video['sequelize'].literal(
915 `(SELECT "VideoTags"."videoId"
917 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
918 WHERE name ILIKE ${escapedValue}
921 } else if (field === 'host') {
922 // FIXME: Include our pod? (not stored in the database)
925 $iLike: '%' + value + '%'
928 podInclude.required = true
929 } else if (field === 'author') {
930 authorInclude.where = {
932 $iLike: '%' + value + '%'
936 // authorInclude.or = true
938 query.where[field] = {
939 $iLike: '%' + value + '%'
944 authorInclude, tagInclude, videoFileInclude
947 return Video.findAndCountAll(query).then(({ rows, count }) => {
955 // ---------------------------------------------------------------------------
957 function createBaseVideosWhere () {
960 $notIn: Video['sequelize'].literal(
961 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
967 function generateImage (video: VideoInstance, videoPath: string, folder: string, imageName: string, size: string) {
975 options['size'] = size
978 return new Promise<string>((res, rej) => {
981 .on('end', () => res(imageName))