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 resolutionOption = `${resolution}x?` // '720x?' for example
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')
641 return statPromise(videoOutputPath)
643 newVideoFile.set('size', stats.size)
648 return this.createTorrentAndSetInfoHash(newVideoFile)
651 return newVideoFile.save()
654 return this.VideoFiles.push(newVideoFile)
665 getOriginalFileHeight = function (this: VideoInstance) {
666 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
668 return new Promise<number>((res, rej) => {
669 ffmpeg.ffprobe(originalFilePath, (err, metadata) => {
670 if (err) return rej(err)
672 const videoStream = metadata.streams.find(s => s.codec_type === 'video')
673 return res(videoStream.height)
678 removeThumbnail = function (this: VideoInstance) {
679 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
680 return unlinkPromise(thumbnailPath)
683 removePreview = function (this: VideoInstance) {
684 // Same name than video thumbnail
685 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
688 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
689 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
690 return unlinkPromise(filePath)
693 removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
694 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
695 return unlinkPromise(torrentPath)
698 // ------------------------------ STATICS ------------------------------
700 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
701 // Creating the thumbnail for a remote video
703 const thumbnailName = video.getThumbnailName()
704 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
705 return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
710 getDurationFromFile = function (videoPath: string) {
711 return new Promise<number>((res, rej) => {
712 ffmpeg.ffprobe(videoPath, (err, metadata) => {
713 if (err) return rej(err)
715 return res(Math.floor(metadata.format.duration))
722 include: [ Video['sequelize'].models.VideoFile ]
725 return Video.findAll(query)
728 listForApi = function (start: number, count: number, sort: string) {
729 // Exclude blacklisted videos from the list
734 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
737 model: Video['sequelize'].models.Author,
738 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
740 Video['sequelize'].models.Tag,
741 Video['sequelize'].models.VideoFile
743 where: createBaseVideosWhere()
746 return Video.findAndCountAll(query).then(({ rows, count }) => {
754 loadByHostAndUUID = function (fromHost: string, uuid: string) {
761 model: Video['sequelize'].models.VideoFile
764 model: Video['sequelize'].models.Author,
767 model: Video['sequelize'].models.Pod,
778 return Video.findOne(query)
781 listOwnedAndPopulateAuthorAndTags = function () {
787 Video['sequelize'].models.VideoFile,
788 Video['sequelize'].models.Author,
789 Video['sequelize'].models.Tag
793 return Video.findAll(query)
796 listOwnedByAuthor = function (author: string) {
803 model: Video['sequelize'].models.VideoFile
806 model: Video['sequelize'].models.Author,
814 return Video.findAll(query)
817 load = function (id: number) {
818 return Video.findById(id)
821 loadByUUID = function (uuid: string) {
826 include: [ Video['sequelize'].models.VideoFile ]
828 return Video.findOne(query)
831 loadAndPopulateAuthor = function (id: number) {
833 include: [ Video['sequelize'].models.VideoFile, Video['sequelize'].models.Author ]
836 return Video.findById(id, options)
839 loadAndPopulateAuthorAndPodAndTags = function (id: number) {
843 model: Video['sequelize'].models.Author,
844 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
846 Video['sequelize'].models.Tag,
847 Video['sequelize'].models.VideoFile
851 return Video.findById(id, options)
854 loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
861 model: Video['sequelize'].models.Author,
862 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
864 Video['sequelize'].models.Tag,
865 Video['sequelize'].models.VideoFile
869 return Video.findOne(options)
872 searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
873 const podInclude: Sequelize.IncludeOptions = {
874 model: Video['sequelize'].models.Pod,
878 const authorInclude: Sequelize.IncludeOptions = {
879 model: Video['sequelize'].models.Author,
885 const tagInclude: Sequelize.IncludeOptions = {
886 model: Video['sequelize'].models.Tag
889 const videoFileInclude: Sequelize.IncludeOptions = {
890 model: Video['sequelize'].models.VideoFile
893 const query: Sequelize.FindOptions<VideoAttributes> = {
895 where: createBaseVideosWhere(),
898 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
901 // Make an exact search with the magnet
902 if (field === 'magnetUri') {
903 videoFileInclude.where = {
904 infoHash: magnetUtil.decode(value).infoHash
906 } else if (field === 'tags') {
907 const escapedValue = Video['sequelize'].escape('%' + value + '%')
908 query.where['id'].$in = Video['sequelize'].literal(
909 `(SELECT "VideoTags"."videoId"
911 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
912 WHERE name ILIKE ${escapedValue}
915 } else if (field === 'host') {
916 // FIXME: Include our pod? (not stored in the database)
919 $iLike: '%' + value + '%'
922 podInclude.required = true
923 } else if (field === 'author') {
924 authorInclude.where = {
926 $iLike: '%' + value + '%'
930 // authorInclude.or = true
932 query.where[field] = {
933 $iLike: '%' + value + '%'
938 authorInclude, tagInclude, videoFileInclude
941 return Video.findAndCountAll(query).then(({ rows, count }) => {
949 // ---------------------------------------------------------------------------
951 function createBaseVideosWhere () {
954 $notIn: Video['sequelize'].literal(
955 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
961 function generateImage (video: VideoInstance, videoPath: string, folder: string, imageName: string, size: string) {
969 options['size'] = size
972 return new Promise<string>((res, rej) => {
975 .on('end', () => res(imageName))