import * as Bluebird from 'bluebird' import { map, maxBy, truncate } from 'lodash' import * as magnetUtil from 'magnet-uri' import * as parseTorrent from 'parse-torrent' import { join } from 'path' import * as Sequelize from 'sequelize' import { VideoPrivacy, VideoResolution } from '../../../shared' import { VideoTorrentObject } from '../../../shared/models/activitypub/objects/video-torrent-object' import { activityPubCollection } from '../../helpers/activitypub' import { isVideoCategoryValid, isVideoLanguageValid, isVideoPrivacyValid, isVideoUrlValid } from '../../helpers/custom-validators/videos' import { API_VERSION, CONFIG, CONSTRAINTS_FIELDS, PREVIEWS_SIZE, REMOTE_SCHEME, STATIC_PATHS, THUMBNAILS_SIZE, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../initializers/constants' import { sendDeleteVideo } from '../../lib/index' import { addMethodsToModel, getSort } from '../utils' import { TagInstance } from './tag-interface' import { VideoFileInstance, VideoFileModel } from './video-file-interface' import { VideoAttributes, VideoInstance, VideoMethods } from './video-interface' import { isVideoNameValid, isVideoLicenceValid, isVideoNSFWValid, isVideoDescriptionValid, isVideoDurationValid } from '../../helpers/index' import { logger } from '../../helpers/logger' import { generateImageFromVideoFile, transcode, getVideoFileHeight } from '../../helpers/ffmpeg-utils' import { createTorrentPromise, writeFilePromise, unlinkPromise, renamePromise, statPromise } from '../../helpers/core-utils' let Video: Sequelize.Model let getOriginalFile: VideoMethods.GetOriginalFile let getVideoFilename: VideoMethods.GetVideoFilename let getThumbnailName: VideoMethods.GetThumbnailName let getThumbnailPath: VideoMethods.GetThumbnailPath let getPreviewName: VideoMethods.GetPreviewName let getPreviewPath: VideoMethods.GetPreviewPath let getTorrentFileName: VideoMethods.GetTorrentFileName let isOwned: VideoMethods.IsOwned let toFormattedJSON: VideoMethods.ToFormattedJSON let toFormattedDetailsJSON: VideoMethods.ToFormattedDetailsJSON let toActivityPubObject: VideoMethods.ToActivityPubObject let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile let createPreview: VideoMethods.CreatePreview let createThumbnail: VideoMethods.CreateThumbnail let getVideoFilePath: VideoMethods.GetVideoFilePath let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight let getEmbedPath: VideoMethods.GetEmbedPath let getDescriptionPath: VideoMethods.GetDescriptionPath let getTruncatedDescription: VideoMethods.GetTruncatedDescription let getCategoryLabel: VideoMethods.GetCategoryLabel let getLicenceLabel: VideoMethods.GetLicenceLabel let getLanguageLabel: VideoMethods.GetLanguageLabel let list: VideoMethods.List let listForApi: VideoMethods.ListForApi let listAllAndSharedByAccountForOutbox: VideoMethods.ListAllAndSharedByAccountForOutbox let listUserVideosForApi: VideoMethods.ListUserVideosForApi let load: VideoMethods.Load let loadByUrlAndPopulateAccount: VideoMethods.LoadByUrlAndPopulateAccount let loadByUUID: VideoMethods.LoadByUUID let loadByUUIDOrURL: VideoMethods.LoadByUUIDOrURL let loadAndPopulateAccountAndServerAndTags: VideoMethods.LoadAndPopulateAccountAndServerAndTags let loadByUUIDAndPopulateAccountAndServerAndTags: VideoMethods.LoadByUUIDAndPopulateAccountAndServerAndTags let searchAndPopulateAccountAndServerAndTags: VideoMethods.SearchAndPopulateAccountAndServerAndTags let removeThumbnail: VideoMethods.RemoveThumbnail let removePreview: VideoMethods.RemovePreview let removeFile: VideoMethods.RemoveFile let removeTorrent: VideoMethods.RemoveTorrent export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) { Video = sequelize.define('Video', { uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, validate: { isUUID: 4 } }, name: { type: DataTypes.STRING, allowNull: false, validate: { nameValid: value => { const res = isVideoNameValid(value) if (res === false) throw new Error('Video name is not valid.') } } }, category: { type: DataTypes.INTEGER, allowNull: false, validate: { categoryValid: value => { const res = isVideoCategoryValid(value) if (res === false) throw new Error('Video category is not valid.') } } }, licence: { type: DataTypes.INTEGER, allowNull: false, defaultValue: null, validate: { licenceValid: value => { const res = isVideoLicenceValid(value) if (res === false) throw new Error('Video licence is not valid.') } } }, language: { type: DataTypes.INTEGER, allowNull: true, validate: { languageValid: value => { const res = isVideoLanguageValid(value) if (res === false) throw new Error('Video language is not valid.') } } }, privacy: { type: DataTypes.INTEGER, allowNull: false, validate: { privacyValid: value => { const res = isVideoPrivacyValid(value) if (res === false) throw new Error('Video privacy is not valid.') } } }, nsfw: { type: DataTypes.BOOLEAN, allowNull: false, validate: { nsfwValid: value => { const res = isVideoNSFWValid(value) if (res === false) throw new Error('Video nsfw attribute is not valid.') } } }, description: { type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max), allowNull: false, validate: { descriptionValid: value => { const res = isVideoDescriptionValid(value) if (res === false) throw new Error('Video description is not valid.') } } }, duration: { type: DataTypes.INTEGER, allowNull: false, validate: { durationValid: value => { const res = isVideoDurationValid(value) if (res === false) throw new Error('Video duration is not valid.') } } }, views: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, validate: { min: 0, isInt: true } }, likes: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, validate: { min: 0, isInt: true } }, dislikes: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, validate: { min: 0, isInt: true } }, remote: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, url: { type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max), allowNull: false, validate: { urlValid: value => { const res = isVideoUrlValid(value) if (res === false) throw new Error('Video URL is not valid.') } } } }, { indexes: [ { fields: [ 'name' ] }, { fields: [ 'createdAt' ] }, { fields: [ 'duration' ] }, { fields: [ 'views' ] }, { fields: [ 'likes' ] }, { fields: [ 'uuid' ] }, { fields: [ 'channelId' ] } ], hooks: { afterDestroy } } ) const classMethods = [ associate, list, listAllAndSharedByAccountForOutbox, listForApi, listUserVideosForApi, load, loadByUrlAndPopulateAccount, loadAndPopulateAccountAndServerAndTags, loadByUUIDOrURL, loadByUUID, loadByUUIDAndPopulateAccountAndServerAndTags, searchAndPopulateAccountAndServerAndTags ] const instanceMethods = [ createPreview, createThumbnail, createTorrentAndSetInfoHash, getPreviewName, getPreviewPath, getThumbnailName, getThumbnailPath, getTorrentFileName, getVideoFilename, getVideoFilePath, getOriginalFile, isOwned, removeFile, removePreview, removeThumbnail, removeTorrent, toActivityPubObject, toFormattedJSON, toFormattedDetailsJSON, optimizeOriginalVideofile, transcodeOriginalVideofile, getOriginalFileHeight, getEmbedPath, getTruncatedDescription, getDescriptionPath, getCategoryLabel, getLicenceLabel, getLanguageLabel ] addMethodsToModel(Video, classMethods, instanceMethods) return Video } // ------------------------------ METHODS ------------------------------ function associate (models) { Video.belongsTo(models.VideoChannel, { foreignKey: { name: 'channelId', allowNull: false }, onDelete: 'cascade' }) Video.belongsToMany(models.Tag, { foreignKey: 'videoId', through: models.VideoTag, onDelete: 'cascade' }) Video.hasMany(models.VideoAbuse, { foreignKey: { name: 'videoId', allowNull: false }, onDelete: 'cascade' }) Video.hasMany(models.VideoFile, { foreignKey: { name: 'videoId', allowNull: false }, onDelete: 'cascade' }) Video.hasMany(models.VideoShare, { foreignKey: { name: 'videoId', allowNull: false }, onDelete: 'cascade' }) Video.hasMany(models.AccountVideoRate, { foreignKey: { name: 'videoId', allowNull: false }, onDelete: 'cascade' }) } function afterDestroy (video: VideoInstance) { const tasks = [] tasks.push( video.removeThumbnail() ) if (video.isOwned()) { tasks.push( video.removePreview(), sendDeleteVideo(video, undefined) ) // Remove physical files and torrents video.VideoFiles.forEach(file => { tasks.push(video.removeFile(file)) tasks.push(video.removeTorrent(file)) }) } return Promise.all(tasks) .catch(err => { logger.error('Some errors when removing files of video %s in after destroy hook.', video.uuid, err) }) } getOriginalFile = function (this: VideoInstance) { if (Array.isArray(this.VideoFiles) === false) return undefined // The original file is the file that have the higher resolution return maxBy(this.VideoFiles, file => file.resolution) } getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) { return this.uuid + '-' + videoFile.resolution + videoFile.extname } getThumbnailName = function (this: VideoInstance) { // We always have a copy of the thumbnail const extension = '.jpg' return this.uuid + extension } getPreviewName = function (this: VideoInstance) { const extension = '.jpg' return this.uuid + extension } getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) { const extension = '.torrent' return this.uuid + '-' + videoFile.resolution + extension } isOwned = function (this: VideoInstance) { return this.remote === false } createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) { const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height return generateImageFromVideoFile( this.getVideoFilePath(videoFile), CONFIG.STORAGE.PREVIEWS_DIR, this.getPreviewName(), imageSize ) } createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) { const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height return generateImageFromVideoFile( this.getVideoFilePath(videoFile), CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName(), imageSize ) } getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) { return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile)) } createTorrentAndSetInfoHash = async function (this: VideoInstance, videoFile: VideoFileInstance) { const options = { announceList: [ [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ] ], urlList: [ CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ] } const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options) const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile)) logger.info('Creating torrent %s.', filePath) await writeFilePromise(filePath, torrent) const parsedTorrent = parseTorrent(torrent) videoFile.infoHash = parsedTorrent.infoHash } getEmbedPath = function (this: VideoInstance) { return '/videos/embed/' + this.uuid } getThumbnailPath = function (this: VideoInstance) { return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) } getPreviewPath = function (this: VideoInstance) { return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) } toFormattedJSON = function (this: VideoInstance) { let serverHost if (this.VideoChannel.Account.Server) { serverHost = this.VideoChannel.Account.Server.host } else { // It means it's our video serverHost = CONFIG.WEBSERVER.HOST } const json = { id: this.id, uuid: this.uuid, name: this.name, category: this.category, categoryLabel: this.getCategoryLabel(), licence: this.licence, licenceLabel: this.getLicenceLabel(), language: this.language, languageLabel: this.getLanguageLabel(), nsfw: this.nsfw, description: this.getTruncatedDescription(), serverHost, isLocal: this.isOwned(), account: this.VideoChannel.Account.name, duration: this.duration, views: this.views, likes: this.likes, dislikes: this.dislikes, tags: map(this.Tags, 'name'), thumbnailPath: this.getThumbnailPath(), previewPath: this.getPreviewPath(), embedPath: this.getEmbedPath(), createdAt: this.createdAt, updatedAt: this.updatedAt } return json } toFormattedDetailsJSON = function (this: VideoInstance) { const formattedJson = this.toFormattedJSON() // Maybe our server is not up to date and there are new privacy settings since our version let privacyLabel = VIDEO_PRIVACIES[this.privacy] if (!privacyLabel) privacyLabel = 'Unknown' const detailsJson = { privacyLabel, privacy: this.privacy, descriptionPath: this.getDescriptionPath(), channel: this.VideoChannel.toFormattedJSON(), files: [] } // Format and sort video files const { baseUrlHttp, baseUrlWs } = getBaseUrls(this) detailsJson.files = this.VideoFiles .map(videoFile => { let resolutionLabel = videoFile.resolution + 'p' const videoFileJson = { resolution: videoFile.resolution, resolutionLabel, magnetUri: generateMagnetUri(this, videoFile, baseUrlHttp, baseUrlWs), size: videoFile.size, torrentUrl: getTorrentUrl(this, videoFile, baseUrlHttp), fileUrl: getVideoFileUrl(this, videoFile, baseUrlHttp) } return videoFileJson }) .sort((a, b) => { if (a.resolution < b.resolution) return 1 if (a.resolution === b.resolution) return 0 return -1 }) return Object.assign(formattedJson, detailsJson) } toActivityPubObject = function (this: VideoInstance) { const { baseUrlHttp, baseUrlWs } = getBaseUrls(this) if (!this.Tags) this.Tags = [] const tag = this.Tags.map(t => ({ type: 'Hashtag' as 'Hashtag', name: t.name })) let language if (this.language) { language = { identifier: this.language + '', name: this.getLanguageLabel() } } let likesObject let dislikesObject if (Array.isArray(this.AccountVideoRates)) { const likes: string[] = [] const dislikes: string[] = [] for (const rate of this.AccountVideoRates) { if (rate.type === 'like') { likes.push(rate.Account.url) } else if (rate.type === 'dislike') { dislikes.push(rate.Account.url) } } likesObject = activityPubCollection(likes) dislikesObject = activityPubCollection(dislikes) } const url = [] for (const file of this.VideoFiles) { url.push({ type: 'Link', mimeType: 'video/' + file.extname.replace('.', ''), url: getVideoFileUrl(this, file, baseUrlHttp), width: file.resolution, size: file.size }) url.push({ type: 'Link', mimeType: 'application/x-bittorrent', url: getTorrentUrl(this, file, baseUrlHttp), width: file.resolution }) url.push({ type: 'Link', mimeType: 'application/x-bittorrent;x-scheme-handler/magnet', url: generateMagnetUri(this, file, baseUrlHttp, baseUrlWs), width: file.resolution }) } const videoObject: VideoTorrentObject = { type: 'Video' as 'Video', id: this.url, name: this.name, // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration duration: 'PT' + this.duration + 'S', uuid: this.uuid, tag, category: { identifier: this.category + '', name: this.getCategoryLabel() }, licence: { identifier: this.licence + '', name: this.getLicenceLabel() }, language, views: this.views, nsfw: this.nsfw, published: this.createdAt.toISOString(), updated: this.updatedAt.toISOString(), mediaType: 'text/markdown', content: this.getTruncatedDescription(), icon: { type: 'Image', url: getThumbnailUrl(this, baseUrlHttp), mediaType: 'image/jpeg', width: THUMBNAILS_SIZE.width, height: THUMBNAILS_SIZE.height }, url, likes: likesObject, dislikes: dislikesObject } return videoObject } getTruncatedDescription = function (this: VideoInstance) { const options = { length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max } return truncate(this.description, options) } optimizeOriginalVideofile = async function (this: VideoInstance) { const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR const newExtname = '.mp4' const inputVideoFile = this.getOriginalFile() const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile)) const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname) const transcodeOptions = { inputPath: videoInputPath, outputPath: videoOutputPath } try { // Could be very long! await transcode(transcodeOptions) await unlinkPromise(videoInputPath) // Important to do this before getVideoFilename() to take in account the new file extension inputVideoFile.set('extname', newExtname) await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile)) const stats = await statPromise(this.getVideoFilePath(inputVideoFile)) inputVideoFile.set('size', stats.size) await this.createTorrentAndSetInfoHash(inputVideoFile) await inputVideoFile.save() } catch (err) { // Auto destruction... this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err)) throw err } } transcodeOriginalVideofile = async function (this: VideoInstance, resolution: VideoResolution) { const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR const extname = '.mp4' // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile())) const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({ resolution, extname, size: 0, videoId: this.id }) const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile)) const transcodeOptions = { inputPath: videoInputPath, outputPath: videoOutputPath, resolution } await transcode(transcodeOptions) const stats = await statPromise(videoOutputPath) newVideoFile.set('size', stats.size) await this.createTorrentAndSetInfoHash(newVideoFile) await newVideoFile.save() this.VideoFiles.push(newVideoFile) } getOriginalFileHeight = function (this: VideoInstance) { const originalFilePath = this.getVideoFilePath(this.getOriginalFile()) return getVideoFileHeight(originalFilePath) } getDescriptionPath = function (this: VideoInstance) { return `/api/${API_VERSION}/videos/${this.uuid}/description` } getCategoryLabel = function (this: VideoInstance) { let categoryLabel = VIDEO_CATEGORIES[this.category] // Maybe our server is not up to date and there are new categories since our version if (!categoryLabel) categoryLabel = 'Misc' return categoryLabel } getLicenceLabel = function (this: VideoInstance) { let licenceLabel = VIDEO_LICENCES[this.licence] // Maybe our server is not up to date and there are new licences since our version if (!licenceLabel) licenceLabel = 'Unknown' return licenceLabel } getLanguageLabel = function (this: VideoInstance) { // Language is an optional attribute let languageLabel = VIDEO_LANGUAGES[this.language] if (!languageLabel) languageLabel = 'Unknown' return languageLabel } removeThumbnail = function (this: VideoInstance) { const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) return unlinkPromise(thumbnailPath) } removePreview = function (this: VideoInstance) { // Same name than video thumbnail return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName()) } removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) { const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile)) return unlinkPromise(filePath) } removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) { const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile)) return unlinkPromise(torrentPath) } // ------------------------------ STATICS ------------------------------ list = function () { const query = { include: [ Video['sequelize'].models.VideoFile ] } return Video.findAll(query) } listAllAndSharedByAccountForOutbox = function (accountId: number, start: number, count: number) { function getRawQuery (select: string) { const queryVideo = 'SELECT ' + select + ' FROM "Videos" AS "Video" ' + 'INNER JOIN "VideoChannels" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' + 'WHERE "VideoChannel"."accountId" = ' + accountId const queryVideoShare = 'SELECT ' + select + ' FROM "VideoShares" AS "VideoShare" ' + 'INNER JOIN "Videos" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' + 'WHERE "VideoShare"."accountId" = ' + accountId let rawQuery = `(${queryVideo}) UNION (${queryVideoShare})` return rawQuery } const rawQuery = getRawQuery('"Video"."id"') const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"') const query = { distinct: true, offset: start, limit: count, order: [ getSort('createdAt'), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ], where: { id: { [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')') } }, include: [ { model: Video['sequelize'].models.VideoShare, required: false, where: { [Sequelize.Op.and]: [ { id: { [Sequelize.Op.not]: null } }, { accountId } ] } }, { model: Video['sequelize'].models.VideoChannel, required: true, include: [ { model: Video['sequelize'].models.Account, required: true } ] }, { model: Video['sequelize'].models.AccountVideoRate, include: [ Video['sequelize'].models.Account ] }, Video['sequelize'].models.VideoFile, Video['sequelize'].models.Tag ] } return Bluebird.all([ Video.findAll(query), Video['sequelize'].query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT }) ]).then(([ rows, totals ]) => { // totals: totalVideos + totalVideoShares let totalVideos = 0 let totalVideoShares = 0 if (totals[0]) totalVideos = parseInt(totals[0].total, 10) if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10) const total = totalVideos + totalVideoShares return { data: rows, total: total } }) } listUserVideosForApi = function (userId: number, start: number, count: number, sort: string) { const query = { distinct: true, offset: start, limit: count, order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ], include: [ { model: Video['sequelize'].models.VideoChannel, required: true, include: [ { model: Video['sequelize'].models.Account, where: { userId }, required: true } ] }, Video['sequelize'].models.Tag ] } return Video.findAndCountAll(query).then(({ rows, count }) => { return { data: rows, total: count } }) } listForApi = function (start: number, count: number, sort: string) { const query = { distinct: true, offset: start, limit: count, order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ], include: [ { model: Video['sequelize'].models.VideoChannel, required: true, include: [ { model: Video['sequelize'].models.Account, required: true, include: [ { model: Video['sequelize'].models.Server, required: false } ] } ] }, Video['sequelize'].models.Tag ], where: createBaseVideosWhere() } return Video.findAndCountAll(query).then(({ rows, count }) => { return { data: rows, total: count } }) } load = function (id: number) { return Video.findById(id) } loadByUUID = function (uuid: string, t?: Sequelize.Transaction) { const query: Sequelize.FindOptions = { where: { uuid }, include: [ Video['sequelize'].models.VideoFile ] } if (t !== undefined) query.transaction = t return Video.findOne(query) } loadByUrlAndPopulateAccount = function (url: string, t?: Sequelize.Transaction) { const query: Sequelize.FindOptions = { where: { url }, include: [ Video['sequelize'].models.VideoFile, { model: Video['sequelize'].models.VideoChannel, include: [ Video['sequelize'].models.Account ] } ] } if (t !== undefined) query.transaction = t return Video.findOne(query) } loadByUUIDOrURL = function (uuid: string, url: string, t?: Sequelize.Transaction) { const query: Sequelize.FindOptions = { where: { [Sequelize.Op.or]: [ { uuid }, { url } ] }, include: [ Video['sequelize'].models.VideoFile ] } if (t !== undefined) query.transaction = t return Video.findOne(query) } loadAndPopulateAccountAndServerAndTags = function (id: number) { const options = { order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ], include: [ { model: Video['sequelize'].models.VideoChannel, include: [ { model: Video['sequelize'].models.Account, include: [ { model: Video['sequelize'].models.Server, required: false } ] } ] }, { model: Video['sequelize'].models.AccountVideoRate, include: [ Video['sequelize'].models.Account ] }, { model: Video['sequelize'].models.VideoShare, include: [ Video['sequelize'].models.Account ] }, Video['sequelize'].models.Tag, Video['sequelize'].models.VideoFile ] } return Video.findById(id, options) } loadByUUIDAndPopulateAccountAndServerAndTags = function (uuid: string) { const options = { order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ], where: { uuid }, include: [ { model: Video['sequelize'].models.VideoChannel, include: [ { model: Video['sequelize'].models.Account, include: [ { model: Video['sequelize'].models.Server, required: false } ] } ] }, { model: Video['sequelize'].models.AccountVideoRate, include: [ Video['sequelize'].models.Account ] }, { model: Video['sequelize'].models.VideoShare, include: [ Video['sequelize'].models.Account ] }, Video['sequelize'].models.Tag, Video['sequelize'].models.VideoFile ] } return Video.findOne(options) } searchAndPopulateAccountAndServerAndTags = function (value: string, field: string, start: number, count: number, sort: string) { const serverInclude: Sequelize.IncludeOptions = { model: Video['sequelize'].models.Server, required: false } const accountInclude: Sequelize.IncludeOptions = { model: Video['sequelize'].models.Account, include: [ serverInclude ] } const videoChannelInclude: Sequelize.IncludeOptions = { model: Video['sequelize'].models.VideoChannel, include: [ accountInclude ], required: true } const tagInclude: Sequelize.IncludeOptions = { model: Video['sequelize'].models.Tag } const query: Sequelize.FindOptions = { distinct: true, where: createBaseVideosWhere(), offset: start, limit: count, order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ] } if (field === 'tags') { const escapedValue = Video['sequelize'].escape('%' + value + '%') query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal( `(SELECT "VideoTags"."videoId" FROM "Tags" INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId" WHERE name ILIKE ${escapedValue} )` ) } else if (field === 'host') { // FIXME: Include our server? (not stored in the database) serverInclude.where = { host: { [Sequelize.Op.iLike]: '%' + value + '%' } } serverInclude.required = true } else if (field === 'account') { accountInclude.where = { name: { [Sequelize.Op.iLike]: '%' + value + '%' } } } else { query.where[field] = { [Sequelize.Op.iLike]: '%' + value + '%' } } query.include = [ videoChannelInclude, tagInclude ] return Video.findAndCountAll(query).then(({ rows, count }) => { return { data: rows, total: count } }) } // --------------------------------------------------------------------------- function createBaseVideosWhere () { return { id: { [Sequelize.Op.notIn]: Video['sequelize'].literal( '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")' ) }, privacy: VideoPrivacy.PUBLIC } } function getBaseUrls (video: VideoInstance) { let baseUrlHttp let baseUrlWs if (video.isOwned()) { baseUrlHttp = CONFIG.WEBSERVER.URL baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT } else { baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Account.Server.host baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Account.Server.host } return { baseUrlHttp, baseUrlWs } } function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) { return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName() } function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) { return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile) } function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) { return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile) } function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) { const xs = getTorrentUrl(video, videoFile, baseUrlHttp) const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ] const magnetHash = { xs, announce, urlList, infoHash: videoFile.infoHash, name: video.name } return magnetUtil.encode(magnetHash) }