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) {
307 video.removeThumbnail()
310 if (video.isOwned()) {
311 const removeVideoToFriendsParams = {
316 video.removePreview(),
317 removeVideoToFriends(removeVideoToFriendsParams)
320 // TODO: check files is populated
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 return writeFilePromise(filePath, torrent).then(() => torrent)
384 const parsedTorrent = parseTorrent(torrent)
386 videoFile.infoHash = parsedTorrent.infoHash
390 generateMagnetUri = function (this: VideoInstance, videoFile: VideoFileInstance) {
394 if (this.isOwned()) {
395 baseUrlHttp = CONFIG.WEBSERVER.URL
396 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
398 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
399 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
402 const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
403 const announce = [ baseUrlWs + '/tracker/socket' ]
404 const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
410 infoHash: videoFile.infoHash,
414 return magnetUtil.encode(magnetHash)
417 toFormattedJSON = function (this: VideoInstance) {
420 if (this.Author.Pod) {
421 podHost = this.Author.Pod.host
423 // It means it's our video
424 podHost = CONFIG.WEBSERVER.HOST
427 // Maybe our pod is not up to date and there are new categories since our version
428 let categoryLabel = VIDEO_CATEGORIES[this.category]
429 if (!categoryLabel) categoryLabel = 'Misc'
431 // Maybe our pod is not up to date and there are new licences since our version
432 let licenceLabel = VIDEO_LICENCES[this.licence]
433 if (!licenceLabel) licenceLabel = 'Unknown'
435 // Language is an optional attribute
436 let languageLabel = VIDEO_LANGUAGES[this.language]
437 if (!languageLabel) languageLabel = 'Unknown'
443 category: this.category,
445 licence: this.licence,
447 language: this.language,
450 description: this.description,
452 isLocal: this.isOwned(),
453 author: this.Author.name,
454 duration: this.duration,
457 dislikes: this.dislikes,
458 tags: map<TagInstance, string>(this.Tags, 'name'),
459 thumbnailPath: join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()),
460 previewPath: join(STATIC_PATHS.PREVIEWS, this.getPreviewName()),
461 createdAt: this.createdAt,
462 updatedAt: this.updatedAt,
466 this.VideoFiles.forEach(videoFile => {
467 let resolutionLabel = VIDEO_FILE_RESOLUTIONS[videoFile.resolution]
468 if (!resolutionLabel) resolutionLabel = 'Unknown'
470 const videoFileJson = {
471 resolution: videoFile.resolution,
473 magnetUri: this.generateMagnetUri(videoFile),
477 json.files.push(videoFileJson)
483 toAddRemoteJSON = function (this: VideoInstance) {
484 // Get thumbnail data to send to the other pod
485 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
487 return readFileBufferPromise(thumbnailPath).then(thumbnailData => {
488 const remoteVideo = {
491 category: this.category,
492 licence: this.licence,
493 language: this.language,
495 description: this.description,
496 author: this.Author.name,
497 duration: this.duration,
498 thumbnailData: thumbnailData.toString('binary'),
499 tags: map<TagInstance, string>(this.Tags, 'name'),
500 createdAt: this.createdAt,
501 updatedAt: this.updatedAt,
504 dislikes: this.dislikes,
508 this.VideoFiles.forEach(videoFile => {
509 remoteVideo.files.push({
510 infoHash: videoFile.infoHash,
511 resolution: videoFile.resolution,
512 extname: videoFile.extname,
521 toUpdateRemoteJSON = function (this: VideoInstance) {
525 category: this.category,
526 licence: this.licence,
527 language: this.language,
529 description: this.description,
530 author: this.Author.name,
531 duration: this.duration,
532 tags: map<TagInstance, string>(this.Tags, 'name'),
533 createdAt: this.createdAt,
534 updatedAt: this.updatedAt,
537 dislikes: this.dislikes,
541 this.VideoFiles.forEach(videoFile => {
543 infoHash: videoFile.infoHash,
544 resolution: videoFile.resolution,
545 extname: videoFile.extname,
553 transcodeVideofile = function (this: VideoInstance, inputVideoFile: VideoFileInstance) {
554 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
555 const newExtname = '.mp4'
556 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
557 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
559 return new Promise<void>((res, rej) => {
560 ffmpeg(videoInputPath)
561 .output(videoOutputPath)
562 .videoCodec('libx264')
563 .outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
564 .outputOption('-movflags faststart')
568 return unlinkPromise(videoInputPath)
570 // Important to do this before getVideoFilename() to take in account the new file extension
571 inputVideoFile.set('extname', newExtname)
573 return renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
576 return this.createTorrentAndSetInfoHash(inputVideoFile)
579 return inputVideoFile.save()
585 // Auto destruction...
586 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
595 removeThumbnail = function (this: VideoInstance) {
596 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
597 return unlinkPromise(thumbnailPath)
600 removePreview = function (this: VideoInstance) {
601 // Same name than video thumbnail
602 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
605 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
606 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
607 return unlinkPromise(filePath)
610 removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
611 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
612 return unlinkPromise(torrentPath)
615 // ------------------------------ STATICS ------------------------------
617 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
618 // Creating the thumbnail for a remote video
620 const thumbnailName = video.getThumbnailName()
621 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
622 return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
627 getDurationFromFile = function (videoPath: string) {
628 return new Promise<number>((res, rej) => {
629 ffmpeg.ffprobe(videoPath, (err, metadata) => {
630 if (err) return rej(err)
632 return res(Math.floor(metadata.format.duration))
639 include: [ Video['sequelize'].models.VideoFile ]
642 return Video.findAll(query)
645 listForApi = function (start: number, count: number, sort: string) {
646 // Exclude blacklisted videos from the list
651 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
654 model: Video['sequelize'].models.Author,
655 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
657 Video['sequelize'].models.Tag,
658 Video['sequelize'].models.VideoFile
660 where: createBaseVideosWhere()
663 return Video.findAndCountAll(query).then(({ rows, count }) => {
671 loadByHostAndUUID = function (fromHost: string, uuid: string) {
678 model: Video['sequelize'].models.VideoFile
681 model: Video['sequelize'].models.Author,
684 model: Video['sequelize'].models.Pod,
695 return Video.findOne(query)
698 listOwnedAndPopulateAuthorAndTags = function () {
704 Video['sequelize'].models.VideoFile,
705 Video['sequelize'].models.Author,
706 Video['sequelize'].models.Tag
710 return Video.findAll(query)
713 listOwnedByAuthor = function (author: string) {
720 model: Video['sequelize'].models.VideoFile
723 model: Video['sequelize'].models.Author,
731 return Video.findAll(query)
734 load = function (id: number) {
735 return Video.findById(id)
738 loadByUUID = function (uuid: string) {
743 include: [ Video['sequelize'].models.VideoFile ]
745 return Video.findOne(query)
748 loadAndPopulateAuthor = function (id: number) {
750 include: [ Video['sequelize'].models.VideoFile, Video['sequelize'].models.Author ]
753 return Video.findById(id, options)
756 loadAndPopulateAuthorAndPodAndTags = function (id: number) {
760 model: Video['sequelize'].models.Author,
761 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
763 Video['sequelize'].models.Tag,
764 Video['sequelize'].models.VideoFile
768 return Video.findById(id, options)
771 loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
778 model: Video['sequelize'].models.Author,
779 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
781 Video['sequelize'].models.Tag,
782 Video['sequelize'].models.VideoFile
786 return Video.findOne(options)
789 searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
790 const podInclude: Sequelize.IncludeOptions = {
791 model: Video['sequelize'].models.Pod,
795 const authorInclude: Sequelize.IncludeOptions = {
796 model: Video['sequelize'].models.Author,
802 const tagInclude: Sequelize.IncludeOptions = {
803 model: Video['sequelize'].models.Tag
806 const videoFileInclude: Sequelize.IncludeOptions = {
807 model: Video['sequelize'].models.VideoFile
810 const query: Sequelize.FindOptions<VideoAttributes> = {
812 where: createBaseVideosWhere(),
815 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
818 // Make an exact search with the magnet
819 if (field === 'magnetUri') {
820 videoFileInclude.where = {
821 infoHash: magnetUtil.decode(value).infoHash
823 } else if (field === 'tags') {
824 const escapedValue = Video['sequelize'].escape('%' + value + '%')
825 query.where['id'].$in = Video['sequelize'].literal(
826 `(SELECT "VideoTags"."videoId"
828 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
829 WHERE name ILIKE ${escapedValue}
832 } else if (field === 'host') {
833 // FIXME: Include our pod? (not stored in the database)
836 $iLike: '%' + value + '%'
839 podInclude.required = true
840 } else if (field === 'author') {
841 authorInclude.where = {
843 $iLike: '%' + value + '%'
847 // authorInclude.or = true
849 query.where[field] = {
850 $iLike: '%' + value + '%'
855 authorInclude, tagInclude, videoFileInclude
858 return Video.findAndCountAll(query).then(({ rows, count }) => {
866 // ---------------------------------------------------------------------------
868 function createBaseVideosWhere () {
871 $notIn: Video['sequelize'].literal(
872 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
878 function generateImage (video: VideoInstance, videoPath: string, folder: string, imageName: string, size: string) {
886 options['size'] = size
889 return new Promise<string>((res, rej) => {
892 .on('end', () => res(imageName))