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 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,
272 optimizeOriginalVideofile,
273 transcodeOriginalVideofile,
274 getOriginalFileHeight,
277 addMethodsToModel(Video, classMethods, instanceMethods)
282 // ------------------------------ METHODS ------------------------------
284 function associate (models) {
285 Video.belongsTo(models.Author, {
293 Video.belongsToMany(models.Tag, {
294 foreignKey: 'videoId',
295 through: models.VideoTag,
299 Video.hasMany(models.VideoAbuse, {
307 Video.hasMany(models.VideoFile, {
316 function afterDestroy (video: VideoInstance, options: { transaction: Sequelize.Transaction }) {
320 video.removeThumbnail()
323 if (video.isOwned()) {
324 const removeVideoToFriendsParams = {
329 video.removePreview(),
330 removeVideoToFriends(removeVideoToFriendsParams, options.transaction)
333 // Remove physical files and torrents
334 video.VideoFiles.forEach(file => {
335 tasks.push(video.removeFile(file))
336 tasks.push(video.removeTorrent(file))
340 return Promise.all(tasks)
342 logger.error('Some errors when removing files of video %d in after destroy hook.', video.uuid, err)
346 getOriginalFile = function (this: VideoInstance) {
347 if (Array.isArray(this.VideoFiles) === false) return undefined
349 // The original file is the file that have the higher resolution
350 return maxBy(this.VideoFiles, file => file.resolution)
353 getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) {
354 return this.uuid + '-' + videoFile.resolution + videoFile.extname
357 getThumbnailName = function (this: VideoInstance) {
358 // We always have a copy of the thumbnail
359 const extension = '.jpg'
360 return this.uuid + extension
363 getPreviewName = function (this: VideoInstance) {
364 const extension = '.jpg'
365 return this.uuid + extension
368 getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) {
369 const extension = '.torrent'
370 return this.uuid + '-' + videoFile.resolution + extension
373 isOwned = function (this: VideoInstance) {
374 return this.remote === false
377 createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) {
378 const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
380 return generateImageFromVideoFile(
381 this.getVideoFilePath(videoFile),
382 CONFIG.STORAGE.PREVIEWS_DIR,
383 this.getPreviewName(),
388 createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) {
389 const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
391 return generateImageFromVideoFile(
392 this.getVideoFilePath(videoFile),
393 CONFIG.STORAGE.THUMBNAILS_DIR,
394 this.getThumbnailName(),
399 getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) {
400 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
403 createTorrentAndSetInfoHash = function (this: VideoInstance, videoFile: VideoFileInstance) {
406 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
409 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
413 return createTorrentPromise(this.getVideoFilePath(videoFile), options)
415 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
416 logger.info('Creating torrent %s.', filePath)
418 return writeFilePromise(filePath, torrent).then(() => torrent)
421 const parsedTorrent = parseTorrent(torrent)
423 videoFile.infoHash = parsedTorrent.infoHash
427 getEmbedPath = function (this: VideoInstance) {
428 return '/videos/embed/' + this.uuid
431 getThumbnailPath = function (this: VideoInstance) {
432 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
435 getPreviewPath = function (this: VideoInstance) {
436 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
439 toFormattedJSON = function (this: VideoInstance) {
442 if (this.Author.Pod) {
443 podHost = this.Author.Pod.host
445 // It means it's our video
446 podHost = CONFIG.WEBSERVER.HOST
449 // Maybe our pod is not up to date and there are new categories since our version
450 let categoryLabel = VIDEO_CATEGORIES[this.category]
451 if (!categoryLabel) categoryLabel = 'Misc'
453 // Maybe our pod is not up to date and there are new licences since our version
454 let licenceLabel = VIDEO_LICENCES[this.licence]
455 if (!licenceLabel) licenceLabel = 'Unknown'
457 // Language is an optional attribute
458 let languageLabel = VIDEO_LANGUAGES[this.language]
459 if (!languageLabel) languageLabel = 'Unknown'
465 category: this.category,
467 licence: this.licence,
469 language: this.language,
472 description: this.description,
474 isLocal: this.isOwned(),
475 author: this.Author.name,
476 duration: this.duration,
479 dislikes: this.dislikes,
480 tags: map<TagInstance, string>(this.Tags, 'name'),
481 thumbnailPath: this.getThumbnailPath(),
482 previewPath: this.getPreviewPath(),
483 embedPath: this.getEmbedPath(),
484 createdAt: this.createdAt,
485 updatedAt: this.updatedAt,
489 // Format and sort video files
490 const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
491 json.files = this.VideoFiles
493 let resolutionLabel = videoFile.resolution + 'p'
495 const videoFileJson = {
496 resolution: videoFile.resolution,
498 magnetUri: generateMagnetUri(this, videoFile, baseUrlHttp, baseUrlWs),
499 size: videoFile.size,
500 torrentUrl: getTorrentUrl(this, videoFile, baseUrlHttp),
501 fileUrl: getVideoFileUrl(this, videoFile, baseUrlHttp)
507 if (a.resolution < b.resolution) return 1
508 if (a.resolution === b.resolution) return 0
515 toAddRemoteJSON = function (this: VideoInstance) {
516 // Get thumbnail data to send to the other pod
517 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
519 return readFileBufferPromise(thumbnailPath).then(thumbnailData => {
520 const remoteVideo = {
523 category: this.category,
524 licence: this.licence,
525 language: this.language,
527 description: this.description,
528 author: this.Author.name,
529 duration: this.duration,
530 thumbnailData: thumbnailData.toString('binary'),
531 tags: map<TagInstance, string>(this.Tags, 'name'),
532 createdAt: this.createdAt,
533 updatedAt: this.updatedAt,
536 dislikes: this.dislikes,
540 this.VideoFiles.forEach(videoFile => {
541 remoteVideo.files.push({
542 infoHash: videoFile.infoHash,
543 resolution: videoFile.resolution,
544 extname: videoFile.extname,
553 toUpdateRemoteJSON = function (this: VideoInstance) {
557 category: this.category,
558 licence: this.licence,
559 language: this.language,
561 description: this.description,
562 author: this.Author.name,
563 duration: this.duration,
564 tags: map<TagInstance, string>(this.Tags, 'name'),
565 createdAt: this.createdAt,
566 updatedAt: this.updatedAt,
569 dislikes: this.dislikes,
573 this.VideoFiles.forEach(videoFile => {
575 infoHash: videoFile.infoHash,
576 resolution: videoFile.resolution,
577 extname: videoFile.extname,
585 optimizeOriginalVideofile = function (this: VideoInstance) {
586 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
587 const newExtname = '.mp4'
588 const inputVideoFile = this.getOriginalFile()
589 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
590 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
592 const transcodeOptions = {
593 inputPath: videoInputPath,
594 outputPath: videoOutputPath
597 return transcode(transcodeOptions)
599 return unlinkPromise(videoInputPath)
602 // Important to do this before getVideoFilename() to take in account the new file extension
603 inputVideoFile.set('extname', newExtname)
605 return renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
608 return statPromise(this.getVideoFilePath(inputVideoFile))
611 return inputVideoFile.set('size', stats.size)
614 return this.createTorrentAndSetInfoHash(inputVideoFile)
617 return inputVideoFile.save()
623 // Auto destruction...
624 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
630 transcodeOriginalVideofile = function (this: VideoInstance, resolution: VideoResolution) {
631 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
632 const extname = '.mp4'
634 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
635 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
637 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({
643 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
645 const transcodeOptions = {
646 inputPath: videoInputPath,
647 outputPath: videoOutputPath,
650 return transcode(transcodeOptions)
652 return statPromise(videoOutputPath)
655 newVideoFile.set('size', stats.size)
660 return this.createTorrentAndSetInfoHash(newVideoFile)
663 return newVideoFile.save()
666 return this.VideoFiles.push(newVideoFile)
668 .then(() => undefined)
671 getOriginalFileHeight = function (this: VideoInstance) {
672 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
674 return getVideoFileHeight(originalFilePath)
677 removeThumbnail = function (this: VideoInstance) {
678 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
679 return unlinkPromise(thumbnailPath)
682 removePreview = function (this: VideoInstance) {
683 // Same name than video thumbnail
684 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
687 removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) {
688 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
689 return unlinkPromise(filePath)
692 removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) {
693 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
694 return unlinkPromise(torrentPath)
697 // ------------------------------ STATICS ------------------------------
699 generateThumbnailFromData = function (video: VideoInstance, thumbnailData: string) {
700 // Creating the thumbnail for a remote video
702 const thumbnailName = video.getThumbnailName()
703 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
704 return writeFilePromise(thumbnailPath, Buffer.from(thumbnailData, 'binary')).then(() => {
711 include: [ Video['sequelize'].models.VideoFile ]
714 return Video.findAll(query)
717 listForApi = function (start: number, count: number, sort: string) {
718 // Exclude blacklisted videos from the list
723 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
726 model: Video['sequelize'].models.Author,
727 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
729 Video['sequelize'].models.Tag,
730 Video['sequelize'].models.VideoFile
732 where: createBaseVideosWhere()
735 return Video.findAndCountAll(query).then(({ rows, count }) => {
743 loadByHostAndUUID = function (fromHost: string, uuid: string) {
750 model: Video['sequelize'].models.VideoFile
753 model: Video['sequelize'].models.Author,
756 model: Video['sequelize'].models.Pod,
767 return Video.findOne(query)
770 listOwnedAndPopulateAuthorAndTags = function () {
776 Video['sequelize'].models.VideoFile,
777 Video['sequelize'].models.Author,
778 Video['sequelize'].models.Tag
782 return Video.findAll(query)
785 listOwnedByAuthor = function (author: string) {
792 model: Video['sequelize'].models.VideoFile
795 model: Video['sequelize'].models.Author,
803 return Video.findAll(query)
806 load = function (id: number) {
807 return Video.findById(id)
810 loadByUUID = function (uuid: string) {
815 include: [ Video['sequelize'].models.VideoFile ]
817 return Video.findOne(query)
820 loadAndPopulateAuthor = function (id: number) {
822 include: [ Video['sequelize'].models.VideoFile, Video['sequelize'].models.Author ]
825 return Video.findById(id, options)
828 loadAndPopulateAuthorAndPodAndTags = function (id: number) {
832 model: Video['sequelize'].models.Author,
833 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
835 Video['sequelize'].models.Tag,
836 Video['sequelize'].models.VideoFile
840 return Video.findById(id, options)
843 loadByUUIDAndPopulateAuthorAndPodAndTags = function (uuid: string) {
850 model: Video['sequelize'].models.Author,
851 include: [ { model: Video['sequelize'].models.Pod, required: false } ]
853 Video['sequelize'].models.Tag,
854 Video['sequelize'].models.VideoFile
858 return Video.findOne(options)
861 searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, start: number, count: number, sort: string) {
862 const podInclude: Sequelize.IncludeOptions = {
863 model: Video['sequelize'].models.Pod,
867 const authorInclude: Sequelize.IncludeOptions = {
868 model: Video['sequelize'].models.Author,
874 const tagInclude: Sequelize.IncludeOptions = {
875 model: Video['sequelize'].models.Tag
878 const videoFileInclude: Sequelize.IncludeOptions = {
879 model: Video['sequelize'].models.VideoFile
882 const query: Sequelize.FindOptions<VideoAttributes> = {
884 where: createBaseVideosWhere(),
887 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
890 // Make an exact search with the magnet
891 if (field === 'magnetUri') {
892 videoFileInclude.where = {
893 infoHash: magnetUtil.decode(value).infoHash
895 } else if (field === 'tags') {
896 const escapedValue = Video['sequelize'].escape('%' + value + '%')
897 query.where['id'].$in = Video['sequelize'].literal(
898 `(SELECT "VideoTags"."videoId"
900 INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
901 WHERE name ILIKE ${escapedValue}
904 } else if (field === 'host') {
905 // FIXME: Include our pod? (not stored in the database)
908 $iLike: '%' + value + '%'
911 podInclude.required = true
912 } else if (field === 'author') {
913 authorInclude.where = {
915 $iLike: '%' + value + '%'
919 // authorInclude.or = true
921 query.where[field] = {
922 $iLike: '%' + value + '%'
927 authorInclude, tagInclude, videoFileInclude
930 return Video.findAndCountAll(query).then(({ rows, count }) => {
938 // ---------------------------------------------------------------------------
940 function createBaseVideosWhere () {
943 $notIn: Video['sequelize'].literal(
944 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
950 function getBaseUrls (video: VideoInstance) {
954 if (video.isOwned()) {
955 baseUrlHttp = CONFIG.WEBSERVER.URL
956 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
958 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.Author.Pod.host
959 baseUrlWs = REMOTE_SCHEME.WS + '://' + video.Author.Pod.host
962 return { baseUrlHttp, baseUrlWs }
965 function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
966 return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile)
969 function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) {
970 return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile)
973 function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) {
974 const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
975 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
976 const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
982 infoHash: videoFile.infoHash,
986 return magnetUtil.encode(magnetHash)