1 import * as safeBuffer from 'safe-buffer'
2 const Buffer = safeBuffer.Buffer
3 import * as magnetUtil from 'magnet-uri'
4 import { map } from 'lodash'
5 import * as parseTorrent from 'parse-torrent'
6 import { join } from 'path'
7 import * as Sequelize from 'sequelize'
8 import * as Promise from 'bluebird'
9 import { maxBy } from 'lodash'
11 import { TagInstance } from './tag-interface'
19 isVideoDescriptionValid,
21 readFileBufferPromise,
27 generateImageFromVideoFile,
30 } from '../../helpers'
39 } from '../../initializers'
40 import { removeVideoToFriends } from '../../lib'
41 import { VideoResolution } from '../../../shared'
42 import { VideoFileInstance, VideoFileModel } from './video-file-interface'
44 import { addMethodsToModel, getSort } from '../utils'
50 } from './video-interface'
51 import { PREVIEWS_SIZE } from '../../initializers/constants'
53 let Video: Sequelize.Model<VideoInstance, VideoAttributes>
54 let getOriginalFile: VideoMethods.GetOriginalFile
55 let generateMagnetUri: VideoMethods.GenerateMagnetUri
56 let getVideoFilename: VideoMethods.GetVideoFilename
57 let getThumbnailName: VideoMethods.GetThumbnailName
58 let getThumbnailPath: VideoMethods.GetThumbnailPath
59 let getPreviewName: VideoMethods.GetPreviewName
60 let getPreviewPath: VideoMethods.GetPreviewPath
61 let getTorrentFileName: VideoMethods.GetTorrentFileName
62 let isOwned: VideoMethods.IsOwned
63 let toFormattedJSON: VideoMethods.ToFormattedJSON
64 let toAddRemoteJSON: VideoMethods.ToAddRemoteJSON
65 let toUpdateRemoteJSON: VideoMethods.ToUpdateRemoteJSON
66 let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
67 let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
68 let createPreview: VideoMethods.CreatePreview
69 let createThumbnail: VideoMethods.CreateThumbnail
70 let getVideoFilePath: VideoMethods.GetVideoFilePath
71 let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
72 let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
73 let getEmbedPath: VideoMethods.GetEmbedPath
75 let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
76 let list: VideoMethods.List
77 let listForApi: VideoMethods.ListForApi
78 let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID
79 let listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAndTags
80 let listOwnedByAuthor: VideoMethods.ListOwnedByAuthor
81 let load: VideoMethods.Load
82 let loadByUUID: VideoMethods.LoadByUUID
83 let loadAndPopulateAuthor: VideoMethods.LoadAndPopulateAuthor
84 let loadAndPopulateAuthorAndPodAndTags: VideoMethods.LoadAndPopulateAuthorAndPodAndTags
85 let loadByUUIDAndPopulateAuthorAndPodAndTags: VideoMethods.LoadByUUIDAndPopulateAuthorAndPodAndTags
86 let searchAndPopulateAuthorAndPodAndTags: VideoMethods.SearchAndPopulateAuthorAndPodAndTags
87 let removeThumbnail: VideoMethods.RemoveThumbnail
88 let removePreview: VideoMethods.RemovePreview
89 let removeFile: VideoMethods.RemoveFile
90 let removeTorrent: VideoMethods.RemoveTorrent
92 export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
93 Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
97 defaultValue: DataTypes.UUIDV4,
104 type: DataTypes.STRING,
107 nameValid: value => {
108 const res = isVideoNameValid(value)
109 if (res === false) throw new Error('Video name is not valid.')
114 type: DataTypes.INTEGER,
117 categoryValid: value => {
118 const res = isVideoCategoryValid(value)
119 if (res === false) throw new Error('Video category is not valid.')
124 type: DataTypes.INTEGER,
128 licenceValid: value => {
129 const res = isVideoLicenceValid(value)
130 if (res === false) throw new Error('Video licence is not valid.')
135 type: DataTypes.INTEGER,
138 languageValid: value => {
139 const res = isVideoLanguageValid(value)
140 if (res === false) throw new Error('Video language is not valid.')
145 type: DataTypes.BOOLEAN,
148 nsfwValid: value => {
149 const res = isVideoNSFWValid(value)
150 if (res === false) throw new Error('Video nsfw attribute is not valid.')
155 type: DataTypes.STRING,
158 descriptionValid: value => {
159 const res = isVideoDescriptionValid(value)
160 if (res === false) throw new Error('Video description is not valid.')
165 type: DataTypes.INTEGER,
168 durationValid: value => {
169 const res = isVideoDurationValid(value)
170 if (res === false) throw new Error('Video duration is not valid.')
175 type: DataTypes.INTEGER,
184 type: DataTypes.INTEGER,
193 type: DataTypes.INTEGER,
202 type: DataTypes.BOOLEAN,
210 fields: [ 'authorId' ]
216 fields: [ 'createdAt' ]
219 fields: [ 'duration' ]
237 const classMethods = [
240 generateThumbnailFromData,
243 listOwnedAndPopulateAuthorAndTags,
246 loadAndPopulateAuthor,
247 loadAndPopulateAuthorAndPodAndTags,
250 loadByUUIDAndPopulateAuthorAndPodAndTags,
251 searchAndPopulateAuthorAndPodAndTags
253 const instanceMethods = [
256 createTorrentAndSetInfoHash,
274 optimizeOriginalVideofile,
275 transcodeOriginalVideofile,
276 getOriginalFileHeight,
279 addMethodsToModel(Video, classMethods, instanceMethods)
284 // ------------------------------ METHODS ------------------------------
286 function associate (models) {
287 Video.belongsTo(models.Author, {
295 Video.belongsToMany(models.Tag, {
296 foreignKey: 'videoId',
297 through: models.VideoTag,
301 Video.hasMany(models.VideoAbuse, {
309 Video.hasMany(models.VideoFile, {
318 function afterDestroy (video: VideoInstance, options: { transaction: Sequelize.Transaction }) {
322 video.removeThumbnail()
325 if (video.isOwned()) {
326 const removeVideoToFriendsParams = {
331 video.removePreview(),
332 removeVideoToFriends(removeVideoToFriendsParams, options.transaction)
335 // Remove physical files and torrents
336 video.VideoFiles.forEach(file => {
337 video.removeFile(file),
338 video.removeTorrent(file)
342 return Promise.all(tasks)
345 getOriginalFile = function (this: VideoInstance) {
346 if (Array.isArray(this.VideoFiles) === false) return undefined
348 // The original file is the file that have the higher resolution
349 return maxBy(this.VideoFiles, file => file.resolution)
352 getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
353 return this.uuid + '-' + videoFile.resolution + videoFile.extname
356 getThumbnailName = function (this: VideoInstance) {
357 // We always have a copy of the thumbnail
358 const extension = '.jpg'
359 return this.uuid + extension
362 getPreviewName = function (this: VideoInstance) {
363 const extension = '.jpg'
364 return this.uuid + extension
367 getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
368 const extension = '.torrent'
369 return this.uuid + '-' + videoFile.resolution + extension
372 isOwned = function (this: VideoInstance) {
373 return this.remote === false
376 createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
377 const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
379 return generateImageFromVideoFile(
380 this.getVideoFilePath(videoFile),
381 CONFIG.STORAGE.PREVIEWS_DIR,
382 this.getPreviewName(),
387 createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
388 const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
390 return generateImageFromVideoFile(
391 this.getVideoFilePath(videoFile),
392 CONFIG.STORAGE.THUMBNAILS_DIR,
393 this.getThumbnailName(),
398 getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
399 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
402 createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFileInstance) {
405 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
408 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
412 return createTorrentPromise(this.getVideoFilePath(videoFile), options)
414 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
415 logger.info('Creating torrent %s.', filePath)
417 return writeFilePromise(filePath, torrent).then(() => torrent)
420 const parsedTorrent = parseTorrent(torrent)
422 videoFile.infoHash = parsedTorrent.infoHash
426 generateMagnetUri = function (this: VideoInstance, videoFile: VideoFileInstance) {
430 if (this.isOwned()) {
431 baseUrlHttp = CONFIG.WEBSERVER.URL
432 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
434 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
435 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
438 const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
439 const announce = [ baseUrlWs + '/tracker/socket' ]
440 const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
446 infoHash: videoFile.infoHash,
450 return magnetUtil.encode(magnetHash)
453 getEmbedPath = function (this: VideoInstance) {
454 return '/videos/embed/' + this.uuid
457 getThumbnailPath = function (this: VideoInstance) {
458 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
461 getPreviewPath = function (this: VideoInstance) {
462 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
465 toFormattedJSON = function (this: VideoInstance) {
468 if (this.Author.Pod) {
469 podHost = this.Author.Pod.host
471 // It means it's our video
472 podHost = CONFIG.WEBSERVER.HOST
475 // Maybe our pod is not up to date and there are new categories since our version
476 let categoryLabel = VIDEO_CATEGORIES[this.category]
477 if (!categoryLabel) categoryLabel = 'Misc'
479 // Maybe our pod is not up to date and there are new licences since our version
480 let licenceLabel = VIDEO_LICENCES[this.licence]
481 if (!licenceLabel) licenceLabel = 'Unknown'
483 // Language is an optional attribute
484 let languageLabel = VIDEO_LANGUAGES[this.language]
485 if (!languageLabel) languageLabel = 'Unknown'
491 category: this.category,
493 licence: this.licence,
495 language: this.language,
498 description: this.description,
500 isLocal: this.isOwned(),
501 author: this.Author.name,
502 duration: this.duration,
505 dislikes: this.dislikes,
506 tags: map<TagInstance, string>(this.Tags, 'name'),
507 thumbnailPath: this.getThumbnailPath(),
508 previewPath: this.getPreviewPath(),
509 embedPath: this.getEmbedPath(),
510 createdAt: this.createdAt,
511 updatedAt: this.updatedAt,
515 // Format and sort video files
516 json.files = this.VideoFiles
518 let resolutionLabel = videoFile.resolution + 'p'
520 const videoFileJson = {
521 resolution: videoFile.resolution,
523 magnetUri: this.generateMagnetUri(videoFile),
530 if (a.resolution < b.resolution) return 1
531 if (a.resolution === b.resolution) return 0
538 toAddRemoteJSON = function (this: VideoInstance) {
539 // Get thumbnail data to send to the other pod
540 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
542 return readFileBufferPromise(thumbnailPath).then(thumbnailData => {
543 const remoteVideo = {
546 category: this.category,
547 licence: this.licence,
548 language: this.language,
550 description: this.description,
551 author: this.Author.name,
552 duration: this.duration,
553 thumbnailData: thumbnailData.toString('binary'),
554 tags: map<TagInstance, string>(this.Tags, 'name'),
555 createdAt: this.createdAt,
556 updatedAt: this.updatedAt,
559 dislikes: this.dislikes,
563 this.VideoFiles.forEach(videoFile => {
564 remoteVideo.files.push({
565 infoHash: videoFile.infoHash,
566 resolution: videoFile.resolution,
567 extname: videoFile.extname,
576 toUpdateRemoteJSON = function (this: VideoInstance) {
580 category: this.category,
581 licence: this.licence,
582 language: this.language,
584 description: this.description,
585 author: this.Author.name,
586 duration: this.duration,
587 tags: map<TagInstance, string>(this.Tags, 'name'),
588 createdAt: this.createdAt,
589 updatedAt: this.updatedAt,
592 dislikes: this.dislikes,
596 this.VideoFiles.forEach(videoFile => {
598 infoHash: videoFile.infoHash,
599 resolution: videoFile.resolution,
600 extname: videoFile.extname,
608 optimizeOriginalVideofile = function (this: VideoInstance) {
609 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
610 const newExtname = '.mp4'
611 const inputVideoFile = this.getOriginalFile()
612 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
613 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
615 const transcodeOptions = {
616 inputPath: videoInputPath,
617 outputPath: videoOutputPath
620 return transcode(transcodeOptions)
622 return unlinkPromise(videoInputPath)
625 // Important to do this before getVideoFilename() to take in account the new file extension
626 inputVideoFile.set('extname', newExtname)
628 return renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
631 return statPromise(this.getVideoFilePath(inputVideoFile))
634 return inputVideoFile.set('size', stats.size)
637 return this.createTorrentAndSetInfoHash(inputVideoFile)
640 return inputVideoFile.save()
646 // Auto destruction...
647 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
653 transcodeOriginalVideofile = function (this: VideoInstance, resolution: VideoResolution) {
654 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
655 const extname = '.mp4'
657 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
658 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
660 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
666 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
668 const transcodeOptions = {
669 inputPath: videoInputPath,
670 outputPath: videoOutputPath,
673 return transcode(transcodeOptions)
675 return statPromise(videoOutputPath)
678 newVideoFile.set('size', stats.size)
683 return this.createTorrentAndSetInfoHash(newVideoFile)
686 return newVideoFile.save()
689 return this.VideoFiles.push(newVideoFile)
691 .then(() => undefined)
694 getOriginalFileHeight = function (this: VideoInstance) {
695 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
697 return getVideoFileHeight(originalFilePath)
700 removeThumbnail = function (this: VideoInstance) {
701 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
702 return unlinkPromise(thumbnailPath)
705 removePreview = function (this: VideoInstance) {
706 // Same name than video thumbnail
707 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
710 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
711 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
712 return unlinkPromise(filePath)
715 removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
716 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
717 return unlinkPromise(torrentPath)
720 // ------------------------------ STATICS ------------------------------
722 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
723 // Creating the thumbnail for a remote video
725 const thumbnailName = video.getThumbnailName()
726 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
727 return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
734 include: [ Video['sequelize'].models.VideoFile ]
737 return Video.findAll(query)
740 listForApi = function (start: number, count: number, sort: string) {
741 // Exclude blacklisted videos from the list
746 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
749 model: Video['sequelize'].models.Author,
750 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
752 Video['sequelize'].models.Tag,
753 Video['sequelize'].models.VideoFile
755 where: createBaseVideosWhere()
758 return Video.findAndCountAll(query).then(({ rows, count }) => {
766 loadByHostAndUUID = function (fromHost: string, uuid: string) {
773 model: Video['sequelize'].models.VideoFile
776 model: Video['sequelize'].models.Author,
779 model: Video['sequelize'].models.Pod,
790 return Video.findOne(query)
793 listOwnedAndPopulateAuthorAndTags = function () {
799 Video['sequelize'].models.VideoFile,
800 Video['sequelize'].models.Author,
801 Video['sequelize'].models.Tag
805 return Video.findAll(query)
808 listOwnedByAuthor = function (author: string) {
815 model: Video['sequelize'].models.VideoFile
818 model: Video['sequelize'].models.Author,
826 return Video.findAll(query)
829 load = function (id: number) {
830 return Video.findById(id)
833 loadByUUID = function (uuid: string) {
838 include: [ Video['sequelize'].models.VideoFile ]
840 return Video.findOne(query)
843 loadAndPopulateAuthor = function (id: number) {
845 include: [ Video['sequelize'].models.VideoFile, Video['sequelize'].models.Author ]
848 return Video.findById(id, options)
851 loadAndPopulateAuthorAndPodAndTags = function (id: number) {
855 model: Video['sequelize'].models.Author,
856 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
858 Video['sequelize'].models.Tag,
859 Video['sequelize'].models.VideoFile
863 return Video.findById(id, options)
866 loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
873 model: Video['sequelize'].models.Author,
874 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
876 Video['sequelize'].models.Tag,
877 Video['sequelize'].models.VideoFile
881 return Video.findOne(options)
884 searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
885 const podInclude: Sequelize.IncludeOptions = {
886 model: Video['sequelize'].models.Pod,
890 const authorInclude: Sequelize.IncludeOptions = {
891 model: Video['sequelize'].models.Author,
897 const tagInclude: Sequelize.IncludeOptions = {
898 model: Video['sequelize'].models.Tag
901 const videoFileInclude: Sequelize.IncludeOptions = {
902 model: Video['sequelize'].models.VideoFile
905 const query: Sequelize.FindOptions<VideoAttributes> = {
907 where: createBaseVideosWhere(),
910 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
913 // Make an exact search with the magnet
914 if (field === 'magnetUri') {
915 videoFileInclude.where = {
916 infoHash: magnetUtil.decode(value).infoHash
918 } else if (field === 'tags') {
919 const escapedValue = Video['sequelize'].escape('%' + value + '%')
920 query.where['id'].$in = Video['sequelize'].literal(
921 `(SELECT "VideoTags"."videoId"
923 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
924 WHERE name ILIKE ${escapedValue}
927 } else if (field === 'host') {
928 // FIXME: Include our pod? (not stored in the database)
931 $iLike: '%' + value + '%'
934 podInclude.required = true
935 } else if (field === 'author') {
936 authorInclude.where = {
938 $iLike: '%' + value + '%'
942 // authorInclude.or = true
944 query.where[field] = {
945 $iLike: '%' + value + '%'
950 authorInclude, tagInclude, videoFileInclude
953 return Video.findAndCountAll(query).then(({ rows, count }) => {
961 // ---------------------------------------------------------------------------
963 function createBaseVideosWhere () {
966 $notIn: Video['sequelize'].literal(
967 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'