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 tasks.push(video.removeFile(file))
338 tasks.push(video.removeTorrent(file))
342 return Promise.all(tasks)
344 logger.error('Some errors when removing files of video %d in after destroy hook.', video.uuid, err)
348 getOriginalFile = function (this: VideoInstance) {
349 if (Array.isArray(this.VideoFiles) === false) return undefined
351 // The original file is the file that have the higher resolution
352 return maxBy(this.VideoFiles, file => file.resolution)
355 getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
356 return this.uuid + '-' + videoFile.resolution + videoFile.extname
359 getThumbnailName = function (this: VideoInstance) {
360 // We always have a copy of the thumbnail
361 const extension = '.jpg'
362 return this.uuid + extension
365 getPreviewName = function (this: VideoInstance) {
366 const extension = '.jpg'
367 return this.uuid + extension
370 getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
371 const extension = '.torrent'
372 return this.uuid + '-' + videoFile.resolution + extension
375 isOwned = function (this: VideoInstance) {
376 return this.remote === false
379 createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
380 const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
382 return generateImageFromVideoFile(
383 this.getVideoFilePath(videoFile),
384 CONFIG.STORAGE.PREVIEWS_DIR,
385 this.getPreviewName(),
390 createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
391 const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
393 return generateImageFromVideoFile(
394 this.getVideoFilePath(videoFile),
395 CONFIG.STORAGE.THUMBNAILS_DIR,
396 this.getThumbnailName(),
401 getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
402 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
405 createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFileInstance) {
408 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
411 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
415 return createTorrentPromise(this.getVideoFilePath(videoFile), options)
417 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
418 logger.info('Creating torrent %s.', filePath)
420 return writeFilePromise(filePath, torrent).then(() => torrent)
423 const parsedTorrent = parseTorrent(torrent)
425 videoFile.infoHash = parsedTorrent.infoHash
429 generateMagnetUri = function (this: VideoInstance, videoFile: VideoFileInstance) {
433 if (this.isOwned()) {
434 baseUrlHttp = CONFIG.WEBSERVER.URL
435 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
437 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
438 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
441 const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
442 const announce = [ baseUrlWs + '/tracker/socket' ]
443 const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
449 infoHash: videoFile.infoHash,
453 return magnetUtil.encode(magnetHash)
456 getEmbedPath = function (this: VideoInstance) {
457 return '/videos/embed/' + this.uuid
460 getThumbnailPath = function (this: VideoInstance) {
461 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
464 getPreviewPath = function (this: VideoInstance) {
465 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
468 toFormattedJSON = function (this: VideoInstance) {
471 if (this.Author.Pod) {
472 podHost = this.Author.Pod.host
474 // It means it's our video
475 podHost = CONFIG.WEBSERVER.HOST
478 // Maybe our pod is not up to date and there are new categories since our version
479 let categoryLabel = VIDEO_CATEGORIES[this.category]
480 if (!categoryLabel) categoryLabel = 'Misc'
482 // Maybe our pod is not up to date and there are new licences since our version
483 let licenceLabel = VIDEO_LICENCES[this.licence]
484 if (!licenceLabel) licenceLabel = 'Unknown'
486 // Language is an optional attribute
487 let languageLabel = VIDEO_LANGUAGES[this.language]
488 if (!languageLabel) languageLabel = 'Unknown'
494 category: this.category,
496 licence: this.licence,
498 language: this.language,
501 description: this.description,
503 isLocal: this.isOwned(),
504 author: this.Author.name,
505 duration: this.duration,
508 dislikes: this.dislikes,
509 tags: map<TagInstance, string>(this.Tags, 'name'),
510 thumbnailPath: this.getThumbnailPath(),
511 previewPath: this.getPreviewPath(),
512 embedPath: this.getEmbedPath(),
513 createdAt: this.createdAt,
514 updatedAt: this.updatedAt,
518 // Format and sort video files
519 json.files = this.VideoFiles
521 let resolutionLabel = videoFile.resolution + 'p'
523 const videoFileJson = {
524 resolution: videoFile.resolution,
526 magnetUri: this.generateMagnetUri(videoFile),
533 if (a.resolution < b.resolution) return 1
534 if (a.resolution === b.resolution) return 0
541 toAddRemoteJSON = function (this: VideoInstance) {
542 // Get thumbnail data to send to the other pod
543 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
545 return readFileBufferPromise(thumbnailPath).then(thumbnailData => {
546 const remoteVideo = {
549 category: this.category,
550 licence: this.licence,
551 language: this.language,
553 description: this.description,
554 author: this.Author.name,
555 duration: this.duration,
556 thumbnailData: thumbnailData.toString('binary'),
557 tags: map<TagInstance, string>(this.Tags, 'name'),
558 createdAt: this.createdAt,
559 updatedAt: this.updatedAt,
562 dislikes: this.dislikes,
566 this.VideoFiles.forEach(videoFile => {
567 remoteVideo.files.push({
568 infoHash: videoFile.infoHash,
569 resolution: videoFile.resolution,
570 extname: videoFile.extname,
579 toUpdateRemoteJSON = function (this: VideoInstance) {
583 category: this.category,
584 licence: this.licence,
585 language: this.language,
587 description: this.description,
588 author: this.Author.name,
589 duration: this.duration,
590 tags: map<TagInstance, string>(this.Tags, 'name'),
591 createdAt: this.createdAt,
592 updatedAt: this.updatedAt,
595 dislikes: this.dislikes,
599 this.VideoFiles.forEach(videoFile => {
601 infoHash: videoFile.infoHash,
602 resolution: videoFile.resolution,
603 extname: videoFile.extname,
611 optimizeOriginalVideofile = function (this: VideoInstance) {
612 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
613 const newExtname = '.mp4'
614 const inputVideoFile = this.getOriginalFile()
615 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
616 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
618 const transcodeOptions = {
619 inputPath: videoInputPath,
620 outputPath: videoOutputPath
623 return transcode(transcodeOptions)
625 return unlinkPromise(videoInputPath)
628 // Important to do this before getVideoFilename() to take in account the new file extension
629 inputVideoFile.set('extname', newExtname)
631 return renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
634 return statPromise(this.getVideoFilePath(inputVideoFile))
637 return inputVideoFile.set('size', stats.size)
640 return this.createTorrentAndSetInfoHash(inputVideoFile)
643 return inputVideoFile.save()
649 // Auto destruction...
650 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
656 transcodeOriginalVideofile = function (this: VideoInstance, resolution: VideoResolution) {
657 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
658 const extname = '.mp4'
660 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
661 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
663 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
669 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
671 const transcodeOptions = {
672 inputPath: videoInputPath,
673 outputPath: videoOutputPath,
676 return transcode(transcodeOptions)
678 return statPromise(videoOutputPath)
681 newVideoFile.set('size', stats.size)
686 return this.createTorrentAndSetInfoHash(newVideoFile)
689 return newVideoFile.save()
692 return this.VideoFiles.push(newVideoFile)
694 .then(() => undefined)
697 getOriginalFileHeight = function (this: VideoInstance) {
698 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
700 return getVideoFileHeight(originalFilePath)
703 removeThumbnail = function (this: VideoInstance) {
704 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
705 return unlinkPromise(thumbnailPath)
708 removePreview = function (this: VideoInstance) {
709 // Same name than video thumbnail
710 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
713 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
714 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
715 return unlinkPromise(filePath)
718 removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
719 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
720 return unlinkPromise(torrentPath)
723 // ------------------------------ STATICS ------------------------------
725 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
726 // Creating the thumbnail for a remote video
728 const thumbnailName = video.getThumbnailName()
729 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
730 return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
737 include: [ Video['sequelize'].models.VideoFile ]
740 return Video.findAll(query)
743 listForApi = function (start: number, count: number, sort: string) {
744 // Exclude blacklisted videos from the list
749 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
752 model: Video['sequelize'].models.Author,
753 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
755 Video['sequelize'].models.Tag,
756 Video['sequelize'].models.VideoFile
758 where: createBaseVideosWhere()
761 return Video.findAndCountAll(query).then(({ rows, count }) => {
769 loadByHostAndUUID = function (fromHost: string, uuid: string) {
776 model: Video['sequelize'].models.VideoFile
779 model: Video['sequelize'].models.Author,
782 model: Video['sequelize'].models.Pod,
793 return Video.findOne(query)
796 listOwnedAndPopulateAuthorAndTags = function () {
802 Video['sequelize'].models.VideoFile,
803 Video['sequelize'].models.Author,
804 Video['sequelize'].models.Tag
808 return Video.findAll(query)
811 listOwnedByAuthor = function (author: string) {
818 model: Video['sequelize'].models.VideoFile
821 model: Video['sequelize'].models.Author,
829 return Video.findAll(query)
832 load = function (id: number) {
833 return Video.findById(id)
836 loadByUUID = function (uuid: string) {
841 include: [ Video['sequelize'].models.VideoFile ]
843 return Video.findOne(query)
846 loadAndPopulateAuthor = function (id: number) {
848 include: [ Video['sequelize'].models.VideoFile, Video['sequelize'].models.Author ]
851 return Video.findById(id, options)
854 loadAndPopulateAuthorAndPodAndTags = function (id: number) {
858 model: Video['sequelize'].models.Author,
859 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
861 Video['sequelize'].models.Tag,
862 Video['sequelize'].models.VideoFile
866 return Video.findById(id, options)
869 loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
876 model: Video['sequelize'].models.Author,
877 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
879 Video['sequelize'].models.Tag,
880 Video['sequelize'].models.VideoFile
884 return Video.findOne(options)
887 searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
888 const podInclude: Sequelize.IncludeOptions = {
889 model: Video['sequelize'].models.Pod,
893 const authorInclude: Sequelize.IncludeOptions = {
894 model: Video['sequelize'].models.Author,
900 const tagInclude: Sequelize.IncludeOptions = {
901 model: Video['sequelize'].models.Tag
904 const videoFileInclude: Sequelize.IncludeOptions = {
905 model: Video['sequelize'].models.VideoFile
908 const query: Sequelize.FindOptions<VideoAttributes> = {
910 where: createBaseVideosWhere(),
913 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
916 // Make an exact search with the magnet
917 if (field === 'magnetUri') {
918 videoFileInclude.where = {
919 infoHash: magnetUtil.decode(value).infoHash
921 } else if (field === 'tags') {
922 const escapedValue = Video['sequelize'].escape('%' + value + '%')
923 query.where['id'].$in = Video['sequelize'].literal(
924 `(SELECT "VideoTags"."videoId"
926 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
927 WHERE name ILIKE ${escapedValue}
930 } else if (field === 'host') {
931 // FIXME: Include our pod? (not stored in the database)
934 $iLike: '%' + value + '%'
937 podInclude.required = true
938 } else if (field === 'author') {
939 authorInclude.where = {
941 $iLike: '%' + value + '%'
945 // authorInclude.or = true
947 query.where[field] = {
948 $iLike: '%' + value + '%'
953 authorInclude, tagInclude, videoFileInclude
956 return Video.findAndCountAll(query).then(({ rows, count }) => {
964 // ---------------------------------------------------------------------------
966 function createBaseVideosWhere () {
969 $notIn: Video['sequelize'].literal(
970 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'