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'
52 let Video: Sequelize.Model<VideoInstance, VideoAttributes>
53 let getOriginalFile: VideoMethods.GetOriginalFile
54 let generateMagnetUri: VideoMethods.GenerateMagnetUri
55 let getVideoFilename: VideoMethods.GetVideoFilename
56 let getThumbnailName: VideoMethods.GetThumbnailName
57 let getThumbnailPath: VideoMethods.GetThumbnailPath
58 let getPreviewName: VideoMethods.GetPreviewName
59 let getPreviewPath: VideoMethods.GetPreviewPath
60 let getTorrentFileName: VideoMethods.GetTorrentFileName
61 let isOwned: VideoMethods.IsOwned
62 let toFormattedJSON: VideoMethods.ToFormattedJSON
63 let toAddRemoteJSON: VideoMethods.ToAddRemoteJSON
64 let toUpdateRemoteJSON: VideoMethods.ToUpdateRemoteJSON
65 let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
66 let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
67 let createPreview: VideoMethods.CreatePreview
68 let createThumbnail: VideoMethods.CreateThumbnail
69 let getVideoFilePath: VideoMethods.GetVideoFilePath
70 let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
71 let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
72 let getEmbedPath: VideoMethods.GetEmbedPath
74 let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData
75 let list: VideoMethods.List
76 let listForApi: VideoMethods.ListForApi
77 let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID
78 let listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAndTags
79 let listOwnedByAuthor: VideoMethods.ListOwnedByAuthor
80 let load: VideoMethods.Load
81 let loadByUUID: VideoMethods.LoadByUUID
82 let loadAndPopulateAuthor: VideoMethods.LoadAndPopulateAuthor
83 let loadAndPopulateAuthorAndPodAndTags: VideoMethods.LoadAndPopulateAuthorAndPodAndTags
84 let loadByUUIDAndPopulateAuthorAndPodAndTags: VideoMethods.LoadByUUIDAndPopulateAuthorAndPodAndTags
85 let searchAndPopulateAuthorAndPodAndTags: VideoMethods.SearchAndPopulateAuthorAndPodAndTags
86 let removeThumbnail: VideoMethods.RemoveThumbnail
87 let removePreview: VideoMethods.RemovePreview
88 let removeFile: VideoMethods.RemoveFile
89 let removeTorrent: VideoMethods.RemoveTorrent
91 export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
92 Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
96 defaultValue: DataTypes.UUIDV4,
103 type: DataTypes.STRING,
106 nameValid: value => {
107 const res = isVideoNameValid(value)
108 if (res === false) throw new Error('Video name is not valid.')
113 type: DataTypes.INTEGER,
116 categoryValid: value => {
117 const res = isVideoCategoryValid(value)
118 if (res === false) throw new Error('Video category is not valid.')
123 type: DataTypes.INTEGER,
127 licenceValid: value => {
128 const res = isVideoLicenceValid(value)
129 if (res === false) throw new Error('Video licence is not valid.')
134 type: DataTypes.INTEGER,
137 languageValid: value => {
138 const res = isVideoLanguageValid(value)
139 if (res === false) throw new Error('Video language is not valid.')
144 type: DataTypes.BOOLEAN,
147 nsfwValid: value => {
148 const res = isVideoNSFWValid(value)
149 if (res === false) throw new Error('Video nsfw attribute is not valid.')
154 type: DataTypes.STRING,
157 descriptionValid: value => {
158 const res = isVideoDescriptionValid(value)
159 if (res === false) throw new Error('Video description is not valid.')
164 type: DataTypes.INTEGER,
167 durationValid: value => {
168 const res = isVideoDurationValid(value)
169 if (res === false) throw new Error('Video duration is not valid.')
174 type: DataTypes.INTEGER,
183 type: DataTypes.INTEGER,
192 type: DataTypes.INTEGER,
201 type: DataTypes.BOOLEAN,
209 fields: [ 'authorId' ]
215 fields: [ 'createdAt' ]
218 fields: [ 'duration' ]
236 const classMethods = [
239 generateThumbnailFromData,
242 listOwnedAndPopulateAuthorAndTags,
245 loadAndPopulateAuthor,
246 loadAndPopulateAuthorAndPodAndTags,
249 loadByUUIDAndPopulateAuthorAndPodAndTags,
250 searchAndPopulateAuthorAndPodAndTags
252 const instanceMethods = [
255 createTorrentAndSetInfoHash,
273 optimizeOriginalVideofile,
274 transcodeOriginalVideofile,
275 getOriginalFileHeight,
278 addMethodsToModel(Video, classMethods, instanceMethods)
283 // ------------------------------ METHODS ------------------------------
285 function associate (models) {
286 Video.belongsTo(models.Author, {
294 Video.belongsToMany(models.Tag, {
295 foreignKey: 'videoId',
296 through: models.VideoTag,
300 Video.hasMany(models.VideoAbuse, {
308 Video.hasMany(models.VideoFile, {
317 function afterDestroy (video: VideoInstance, options: { transaction: Sequelize.Transaction }) {
321 video.removeThumbnail()
324 if (video.isOwned()) {
325 const removeVideoToFriendsParams = {
330 video.removePreview(),
331 removeVideoToFriends(removeVideoToFriendsParams, options.transaction)
334 // Remove physical files and torrents
335 video.VideoFiles.forEach(file => {
336 video.removeFile(file),
337 video.removeTorrent(file)
341 return Promise.all(tasks)
344 getOriginalFile = function (this: VideoInstance) {
345 if (Array.isArray(this.VideoFiles) === false) return undefined
347 // The original file is the file that have the higher resolution
348 return maxBy(this.VideoFiles, file => file.resolution)
351 getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
352 return this.uuid + '-' + videoFile.resolution + videoFile.extname
355 getThumbnailName = function (this: VideoInstance) {
356 // We always have a copy of the thumbnail
357 const extension = '.jpg'
358 return this.uuid + extension
361 getPreviewName = function (this: VideoInstance) {
362 const extension = '.jpg'
363 return this.uuid + extension
366 getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
367 const extension = '.torrent'
368 return this.uuid + '-' + videoFile.resolution + extension
371 isOwned = function (this: VideoInstance) {
372 return this.remote === false
375 createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
376 return generateImageFromVideoFile(
377 this.getVideoFilePath(videoFile),
378 CONFIG.STORAGE.PREVIEWS_DIR,
379 this.getPreviewName()
383 createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
384 const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
386 return generateImageFromVideoFile(
387 this.getVideoFilePath(videoFile),
388 CONFIG.STORAGE.THUMBNAILS_DIR,
389 this.getThumbnailName(),
394 getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
395 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
398 createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFileInstance) {
401 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
404 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
408 return createTorrentPromise(this.getVideoFilePath(videoFile), options)
410 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
411 logger.info('Creating torrent %s.', filePath)
413 return writeFilePromise(filePath, torrent).then(() => torrent)
416 const parsedTorrent = parseTorrent(torrent)
418 videoFile.infoHash = parsedTorrent.infoHash
422 generateMagnetUri = function (this: VideoInstance, videoFile: VideoFileInstance) {
426 if (this.isOwned()) {
427 baseUrlHttp = CONFIG.WEBSERVER.URL
428 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
430 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
431 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
434 const xs = baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
435 const announce = [ baseUrlWs + '/tracker/socket' ]
436 const urlList = [ baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
442 infoHash: videoFile.infoHash,
446 return magnetUtil.encode(magnetHash)
449 getEmbedPath = function (this: VideoInstance) {
450 return '/videos/embed/' + this.uuid
453 getThumbnailPath = function (this: VideoInstance) {
454 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
457 getPreviewPath = function (this: VideoInstance) {
458 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
461 toFormattedJSON = function (this: VideoInstance) {
464 if (this.Author.Pod) {
465 podHost = this.Author.Pod.host
467 // It means it's our video
468 podHost = CONFIG.WEBSERVER.HOST
471 // Maybe our pod is not up to date and there are new categories since our version
472 let categoryLabel = VIDEO_CATEGORIES[this.category]
473 if (!categoryLabel) categoryLabel = 'Misc'
475 // Maybe our pod is not up to date and there are new licences since our version
476 let licenceLabel = VIDEO_LICENCES[this.licence]
477 if (!licenceLabel) licenceLabel = 'Unknown'
479 // Language is an optional attribute
480 let languageLabel = VIDEO_LANGUAGES[this.language]
481 if (!languageLabel) languageLabel = 'Unknown'
487 category: this.category,
489 licence: this.licence,
491 language: this.language,
494 description: this.description,
496 isLocal: this.isOwned(),
497 author: this.Author.name,
498 duration: this.duration,
501 dislikes: this.dislikes,
502 tags: map<TagInstance, string>(this.Tags, 'name'),
503 thumbnailPath: this.getThumbnailPath(),
504 previewPath: this.getPreviewPath(),
505 embedPath: this.getEmbedPath(),
506 createdAt: this.createdAt,
507 updatedAt: this.updatedAt,
511 // Format and sort video files
512 json.files = this.VideoFiles
514 let resolutionLabel = videoFile.resolution + 'p'
516 const videoFileJson = {
517 resolution: videoFile.resolution,
519 magnetUri: this.generateMagnetUri(videoFile),
526 if (a.resolution < b.resolution) return 1
527 if (a.resolution === b.resolution) return 0
534 toAddRemoteJSON = function (this: VideoInstance) {
535 // Get thumbnail data to send to the other pod
536 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
538 return readFileBufferPromise(thumbnailPath).then(thumbnailData => {
539 const remoteVideo = {
542 category: this.category,
543 licence: this.licence,
544 language: this.language,
546 description: this.description,
547 author: this.Author.name,
548 duration: this.duration,
549 thumbnailData: thumbnailData.toString('binary'),
550 tags: map<TagInstance, string>(this.Tags, 'name'),
551 createdAt: this.createdAt,
552 updatedAt: this.updatedAt,
555 dislikes: this.dislikes,
559 this.VideoFiles.forEach(videoFile => {
560 remoteVideo.files.push({
561 infoHash: videoFile.infoHash,
562 resolution: videoFile.resolution,
563 extname: videoFile.extname,
572 toUpdateRemoteJSON = function (this: VideoInstance) {
576 category: this.category,
577 licence: this.licence,
578 language: this.language,
580 description: this.description,
581 author: this.Author.name,
582 duration: this.duration,
583 tags: map<TagInstance, string>(this.Tags, 'name'),
584 createdAt: this.createdAt,
585 updatedAt: this.updatedAt,
588 dislikes: this.dislikes,
592 this.VideoFiles.forEach(videoFile => {
594 infoHash: videoFile.infoHash,
595 resolution: videoFile.resolution,
596 extname: videoFile.extname,
604 optimizeOriginalVideofile = function (this: VideoInstance) {
605 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
606 const newExtname = '.mp4'
607 const inputVideoFile = this.getOriginalFile()
608 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
609 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
611 const transcodeOptions = {
612 inputPath: videoInputPath,
613 outputPath: videoOutputPath
616 return transcode(transcodeOptions)
618 return unlinkPromise(videoInputPath)
621 // Important to do this before getVideoFilename() to take in account the new file extension
622 inputVideoFile.set('extname', newExtname)
624 return renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
627 return statPromise(this.getVideoFilePath(inputVideoFile))
630 return inputVideoFile.set('size', stats.size)
633 return this.createTorrentAndSetInfoHash(inputVideoFile)
636 return inputVideoFile.save()
642 // Auto destruction...
643 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
649 transcodeOriginalVideofile = function (this: VideoInstance, resolution: VideoResolution) {
650 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
651 const extname = '.mp4'
653 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
654 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
656 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
662 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
664 const transcodeOptions = {
665 inputPath: videoInputPath,
666 outputPath: videoOutputPath,
669 return transcode(transcodeOptions)
671 return statPromise(videoOutputPath)
674 newVideoFile.set('size', stats.size)
679 return this.createTorrentAndSetInfoHash(newVideoFile)
682 return newVideoFile.save()
685 return this.VideoFiles.push(newVideoFile)
687 .then(() => undefined)
690 getOriginalFileHeight = function (this: VideoInstance) {
691 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
693 return getVideoFileHeight(originalFilePath)
696 removeThumbnail = function (this: VideoInstance) {
697 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
698 return unlinkPromise(thumbnailPath)
701 removePreview = function (this: VideoInstance) {
702 // Same name than video thumbnail
703 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
706 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
707 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
708 return unlinkPromise(filePath)
711 removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
712 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
713 return unlinkPromise(torrentPath)
716 // ------------------------------ STATICS ------------------------------
718 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
719 // Creating the thumbnail for a remote video
721 const thumbnailName = video.getThumbnailName()
722 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
723 return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
730 include: [ Video['sequelize'].models.VideoFile ]
733 return Video.findAll(query)
736 listForApi = function (start: number, count: number, sort: string) {
737 // Exclude blacklisted videos from the list
742 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
745 model: Video['sequelize'].models.Author,
746 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
748 Video['sequelize'].models.Tag,
749 Video['sequelize'].models.VideoFile
751 where: createBaseVideosWhere()
754 return Video.findAndCountAll(query).then(({ rows, count }) => {
762 loadByHostAndUUID = function (fromHost: string, uuid: string) {
769 model: Video['sequelize'].models.VideoFile
772 model: Video['sequelize'].models.Author,
775 model: Video['sequelize'].models.Pod,
786 return Video.findOne(query)
789 listOwnedAndPopulateAuthorAndTags = function () {
795 Video['sequelize'].models.VideoFile,
796 Video['sequelize'].models.Author,
797 Video['sequelize'].models.Tag
801 return Video.findAll(query)
804 listOwnedByAuthor = function (author: string) {
811 model: Video['sequelize'].models.VideoFile
814 model: Video['sequelize'].models.Author,
822 return Video.findAll(query)
825 load = function (id: number) {
826 return Video.findById(id)
829 loadByUUID = function (uuid: string) {
834 include: [ Video['sequelize'].models.VideoFile ]
836 return Video.findOne(query)
839 loadAndPopulateAuthor = function (id: number) {
841 include: [ Video['sequelize'].models.VideoFile, Video['sequelize'].models.Author ]
844 return Video.findById(id, options)
847 loadAndPopulateAuthorAndPodAndTags = function (id: number) {
851 model: Video['sequelize'].models.Author,
852 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
854 Video['sequelize'].models.Tag,
855 Video['sequelize'].models.VideoFile
859 return Video.findById(id, options)
862 loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
869 model: Video['sequelize'].models.Author,
870 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
872 Video['sequelize'].models.Tag,
873 Video['sequelize'].models.VideoFile
877 return Video.findOne(options)
880 searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
881 const podInclude: Sequelize.IncludeOptions = {
882 model: Video['sequelize'].models.Pod,
886 const authorInclude: Sequelize.IncludeOptions = {
887 model: Video['sequelize'].models.Author,
893 const tagInclude: Sequelize.IncludeOptions = {
894 model: Video['sequelize'].models.Tag
897 const videoFileInclude: Sequelize.IncludeOptions = {
898 model: Video['sequelize'].models.VideoFile
901 const query: Sequelize.FindOptions<VideoAttributes> = {
903 where: createBaseVideosWhere(),
906 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
909 // Make an exact search with the magnet
910 if (field === 'magnetUri') {
911 videoFileInclude.where = {
912 infoHash: magnetUtil.decode(value).infoHash
914 } else if (field === 'tags') {
915 const escapedValue = Video['sequelize'].escape('%' + value + '%')
916 query.where['id'].$in = Video['sequelize'].literal(
917 `(SELECT "VideoTags"."videoId"
919 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
920 WHERE name ILIKE ${escapedValue}
923 } else if (field === 'host') {
924 // FIXME: Include our pod? (not stored in the database)
927 $iLike: '%' + value + '%'
930 podInclude.required = true
931 } else if (field === 'author') {
932 authorInclude.where = {
934 $iLike: '%' + value + '%'
938 // authorInclude.or = true
940 query.where[field] = {
941 $iLike: '%' + value + '%'
946 authorInclude, tagInclude, videoFileInclude
949 return Video.findAndCountAll(query).then(({ rows, count }) => {
957 // ---------------------------------------------------------------------------
959 function createBaseVideosWhere () {
962 $notIn: Video['sequelize'].literal(
963 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'