X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Fmodels%2Fvideo%2Fvideo.ts;h=05d625fc18a54e8ab7a560ec656fa6f027bfd286;hb=c3edc5b074aa4bb1861ed0a94d3713808e87170f;hp=74a3a5d05a939d4961e6e437b91f8a21c7da4499;hpb=40e87e9ecc54e3513fb586928330a7855eb192c6;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 74a3a5d05..e5077487a 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -1,9 +1,8 @@ -import * as Bluebird from 'bluebird' -import { map, maxBy } from 'lodash' -import * as magnetUtil from 'magnet-uri' -import * as parseTorrent from 'parse-torrent' -import { extname, join } from 'path' -import * as Sequelize from 'sequelize' +import Bluebird from 'bluebird' +import { remove } from 'fs-extra' +import { maxBy, minBy } from 'lodash' +import { join } from 'path' +import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' import { AllowNull, BeforeDestroy, @@ -16,7 +15,6 @@ import { ForeignKey, HasMany, HasOne, - IFindOptions, Is, IsInt, IsUUID, @@ -26,245 +24,223 @@ import { Table, UpdatedAt } from 'sequelize-typescript' -import { VideoPrivacy, VideoResolution, VideoState } from '../../../shared' -import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' -import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' -import { VideoFilter } from '../../../shared/models/videos/video-query.type' +import { buildNSFWFilter } from '@server/helpers/express-utils' +import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video' +import { LiveManager } from '@server/lib/live/live-manager' +import { removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage' +import { getHLSDirectory, getHLSRedundancyDirectory } from '@server/lib/paths' +import { VideoPathManager } from '@server/lib/video-path-manager' +import { getServerActor } from '@server/models/application/application' +import { ModelCache } from '@server/models/model-cache' +import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils' +import { uuidToShort } from '@shared/extra-utils' import { - copyFilePromise, - createTorrentPromise, - peertubeTruncate, - renamePromise, - statPromise, - unlinkPromise, - writeFilePromise -} from '../../helpers/core-utils' + ResultList, + ThumbnailType, + UserRight, + Video, + VideoDetails, + VideoFile, + VideoInclude, + VideoObject, + VideoPrivacy, + VideoRateType, + VideoState, + VideoStorage, + VideoStreamingPlaylistType +} from '@shared/models' +import { AttributesOnly } from '@shared/typescript-utils' +import { peertubeTruncate } from '../../helpers/core-utils' import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' -import { isBooleanValid } from '../../helpers/custom-validators/misc' +import { exists, isBooleanValid } from '../../helpers/custom-validators/misc' import { - isVideoCategoryValid, isVideoDescriptionValid, isVideoDurationValid, - isVideoLanguageValid, - isVideoLicenceValid, isVideoNameValid, isVideoPrivacyValid, isVideoStateValid, isVideoSupportValid } from '../../helpers/custom-validators/videos' -import { generateImageFromVideoFile, getVideoFileFPS, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils' +import { getVideoFileResolution } from '../../helpers/ffprobe-utils' import { logger } from '../../helpers/logger' -import { getServerActor } from '../../helpers/utils' -import { - API_VERSION, - CONFIG, - CONSTRAINTS_FIELDS, - PREVIEWS_SIZE, - REMOTE_SCHEME, - STATIC_DOWNLOAD_PATHS, - STATIC_PATHS, - THUMBNAILS_SIZE, - VIDEO_CATEGORIES, - VIDEO_EXT_MIMETYPE, - VIDEO_LANGUAGES, - VIDEO_LICENCES, - VIDEO_PRIVACIES, - VIDEO_STATES -} from '../../initializers' -import { - getVideoCommentsActivityPubUrl, - getVideoDislikesActivityPubUrl, - getVideoLikesActivityPubUrl, - getVideoSharesActivityPubUrl -} from '../../lib/activitypub' +import { CONFIG } from '../../initializers/config' +import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants' import { sendDeleteVideo } from '../../lib/activitypub/send' +import { + MChannel, + MChannelAccountDefault, + MChannelId, + MStreamingPlaylist, + MStreamingPlaylistFilesVideo, + MUserAccountId, + MUserId, + MVideo, + MVideoAccountLight, + MVideoAccountLightBlacklistAllFiles, + MVideoAP, + MVideoDetails, + MVideoFileVideo, + MVideoFormattable, + MVideoFormattableDetails, + MVideoForUser, + MVideoFullLight, + MVideoId, + MVideoImmutable, + MVideoThumbnail, + MVideoThumbnailBlacklist, + MVideoWithAllFiles, + MVideoWithFile +} from '../../types/models' +import { MThumbnail } from '../../types/models/video/thumbnail' +import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file' +import { VideoAbuseModel } from '../abuse/video-abuse' import { AccountModel } from '../account/account' import { AccountVideoRateModel } from '../account/account-video-rate' -import { ActorModel } from '../activitypub/actor' -import { AvatarModel } from '../avatar/avatar' +import { ActorModel } from '../actor/actor' +import { ActorImageModel } from '../actor/actor-image' +import { VideoRedundancyModel } from '../redundancy/video-redundancy' import { ServerModel } from '../server/server' -import { getSort, throwIfNotValid } from '../utils' +import { TrackerModel } from '../server/tracker' +import { VideoTrackerModel } from '../server/video-tracker' +import { setAsUpdated } from '../shared' +import { UserModel } from '../user/user' +import { UserVideoHistoryModel } from '../user/user-video-history' +import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils' +import { + videoFilesModelToFormattedJSON, + VideoFormattingJSONOptions, + videoModelToActivityPubObject, + videoModelToFormattedDetailsJSON, + videoModelToFormattedJSON +} from './formatter/video-format-utils' +import { ScheduleVideoUpdateModel } from './schedule-video-update' +import { VideoModelGetQueryBuilder } from './sql/video-model-get-query-builder' +import { BuildVideosListQueryOptions, DisplayOnlyForFollowerOptions, VideosIdListQueryBuilder } from './sql/videos-id-list-query-builder' +import { VideosModelListQueryBuilder } from './sql/videos-model-list-query-builder' import { TagModel } from './tag' -import { VideoAbuseModel } from './video-abuse' -import { VideoChannelModel } from './video-channel' +import { ThumbnailModel } from './thumbnail' +import { VideoBlacklistModel } from './video-blacklist' +import { VideoCaptionModel } from './video-caption' +import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' import { VideoCommentModel } from './video-comment' import { VideoFileModel } from './video-file' +import { VideoImportModel } from './video-import' +import { VideoJobInfoModel } from './video-job-info' +import { VideoLiveModel } from './video-live' +import { VideoPlaylistElementModel } from './video-playlist-element' import { VideoShareModel } from './video-share' +import { VideoStreamingPlaylistModel } from './video-streaming-playlist' import { VideoTagModel } from './video-tag' -import { ScheduleVideoUpdateModel } from './schedule-video-update' -import { VideoCaptionModel } from './video-caption' +import { VideoViewModel } from './video-view' export enum ScopeNames { - AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', + FOR_API = 'FOR_API', WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS', WITH_TAGS = 'WITH_TAGS', - WITH_FILES = 'WITH_FILES', - WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE' + WITH_WEBTORRENT_FILES = 'WITH_WEBTORRENT_FILES', + WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', + WITH_BLACKLISTED = 'WITH_BLACKLISTED', + WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS', + WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES', + WITH_USER_HISTORY = 'WITH_USER_HISTORY', + WITH_THUMBNAILS = 'WITH_THUMBNAILS' } -@Scopes({ - [ScopeNames.AVAILABLE_FOR_LIST]: (options: { - actorId: number, - hideNSFW: boolean, - filter?: VideoFilter, - category?: number, - withFiles?: boolean, - accountId?: number, - videoChannelId?: number - }) => { - const accountInclude = { - attributes: [ 'id', 'name' ], - model: AccountModel.unscoped(), - required: true, - where: {}, - include: [ - { - attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], - model: ActorModel.unscoped(), - required: true, - where: VideoModel.buildActorWhereWithFilter(options.filter), - include: [ - { - attributes: [ 'host' ], - model: ServerModel.unscoped(), - required: false - }, - { - model: AvatarModel.unscoped(), - required: false - } - ] - } - ] - } +export type ForAPIOptions = { + ids?: number[] - const videoChannelInclude = { - attributes: [ 'name', 'description', 'id' ], - model: VideoChannelModel.unscoped(), - required: true, - where: {}, - include: [ - { - attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], - model: ActorModel.unscoped(), - required: true, - include: [ - { - attributes: [ 'host' ], - model: ServerModel.unscoped(), - required: false - }, - { - model: AvatarModel.unscoped(), - required: false - } - ] - }, - accountInclude - ] - } + videoPlaylistId?: number - // Force actorId to be a number to avoid SQL injections - const actorIdNumber = parseInt(options.actorId.toString(), 10) - const query: IFindOptions = { - where: { - id: { - [Sequelize.Op.notIn]: Sequelize.literal( - '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")' - ), - [ Sequelize.Op.in ]: Sequelize.literal( - '(' + - 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' + - 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + - 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + - ' UNION ' + - 'SELECT "video"."id" AS "id" FROM "video" ' + - 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + - 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' + - 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' + - 'LEFT JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' + - 'WHERE "actor"."serverId" IS NULL OR "actorFollow"."actorId" = ' + actorIdNumber + - ')' - ) - }, - // Always list public videos - privacy: VideoPrivacy.PUBLIC, - // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding - [ Sequelize.Op.or ]: [ - { - state: VideoState.PUBLISHED - }, - { - [ Sequelize.Op.and ]: { - state: VideoState.TO_TRANSCODE, - waitTranscoding: false - } - } - ] - }, - include: [ videoChannelInclude ] - } + withAccountBlockerIds?: number[] +} - if (options.withFiles === true) { - query.include.push({ - model: VideoFileModel.unscoped(), +@Scopes(() => ({ + [ScopeNames.WITH_IMMUTABLE_ATTRIBUTES]: { + attributes: [ 'id', 'url', 'uuid', 'remote' ] + }, + [ScopeNames.FOR_API]: (options: ForAPIOptions) => { + const include: Includeable[] = [ + { + model: VideoChannelModel.scope({ + method: [ + VideoChannelScopeNames.SUMMARY, { + withAccount: true, + withAccountBlockerIds: options.withAccountBlockerIds + } as SummaryOptions + ] + }), required: true - }) - } - - // Hide nsfw videos? - if (options.hideNSFW === true) { - query.where['nsfw'] = false - } + }, + { + attributes: [ 'type', 'filename' ], + model: ThumbnailModel, + required: false + } + ] - if (options.category) { - query.where['category'] = options.category - } + const query: FindOptions = {} - if (options.accountId) { - accountInclude.where = { - id: options.accountId + if (options.ids) { + query.where = { + id: { + [Op.in]: options.ids + } } } - if (options.videoChannelId) { - videoChannelInclude.where = { - id: options.videoChannelId - } + if (options.videoPlaylistId) { + include.push({ + model: VideoPlaylistElementModel.unscoped(), + required: true, + where: { + videoPlaylistId: options.videoPlaylistId + } + }) } + query.include = include + return query }, + [ScopeNames.WITH_THUMBNAILS]: { + include: [ + { + model: ThumbnailModel, + required: false + } + ] + }, [ScopeNames.WITH_ACCOUNT_DETAILS]: { include: [ { - model: () => VideoChannelModel.unscoped(), + model: VideoChannelModel.unscoped(), required: true, include: [ { attributes: { exclude: [ 'privateKey', 'publicKey' ] }, - model: () => ActorModel.unscoped(), + model: ActorModel.unscoped(), required: true, include: [ { attributes: [ 'host' ], - model: () => ServerModel.unscoped(), + model: ServerModel.unscoped(), required: false }, { - model: () => AvatarModel.unscoped(), + model: ActorImageModel.unscoped(), + as: 'Avatar', required: false } ] }, { - model: () => AccountModel.unscoped(), + model: AccountModel.unscoped(), required: true, include: [ { - model: () => ActorModel.unscoped(), + model: ActorModel.unscoped(), attributes: { exclude: [ 'privateKey', 'publicKey' ] }, @@ -272,11 +248,12 @@ export enum ScopeNames { include: [ { attributes: [ 'host' ], - model: () => ServerModel.unscoped(), + model: ServerModel.unscoped(), required: false }, { - model: () => AvatarModel.unscoped(), + model: ActorImageModel.unscoped(), + as: 'Avatar', required: false } ] @@ -288,59 +265,166 @@ export enum ScopeNames { ] }, [ScopeNames.WITH_TAGS]: { - include: [ () => TagModel ] + include: [ TagModel ] }, - [ScopeNames.WITH_FILES]: { + [ScopeNames.WITH_BLACKLISTED]: { include: [ { - model: () => VideoFileModel.unscoped(), - required: true + attributes: [ 'id', 'reason', 'unfederated' ], + model: VideoBlacklistModel, + required: false + } + ] + }, + [ScopeNames.WITH_WEBTORRENT_FILES]: (withRedundancies = false) => { + let subInclude: any[] = [] + + if (withRedundancies === true) { + subInclude = [ + { + attributes: [ 'fileUrl' ], + model: VideoRedundancyModel.unscoped(), + required: false + } + ] + } + + return { + include: [ + { + model: VideoFileModel, + separate: true, + required: false, + include: subInclude + } + ] + } + }, + [ScopeNames.WITH_STREAMING_PLAYLISTS]: (withRedundancies = false) => { + const subInclude: IncludeOptions[] = [ + { + model: VideoFileModel, + required: false } ] + + if (withRedundancies === true) { + subInclude.push({ + attributes: [ 'fileUrl' ], + model: VideoRedundancyModel.unscoped(), + required: false + }) + } + + return { + include: [ + { + model: VideoStreamingPlaylistModel.unscoped(), + required: false, + separate: true, + include: subInclude + } + ] + } }, [ScopeNames.WITH_SCHEDULED_UPDATE]: { include: [ { - model: () => ScheduleVideoUpdateModel.unscoped(), + model: ScheduleVideoUpdateModel.unscoped(), required: false } ] + }, + [ScopeNames.WITH_USER_HISTORY]: (userId: number) => { + return { + include: [ + { + attributes: [ 'currentTime' ], + model: UserVideoHistoryModel.unscoped(), + required: false, + where: { + userId + } + } + ] + } } -}) +})) @Table({ tableName: 'video', indexes: [ + buildTrigramSearchIndex('video_name_trigram', 'name'), + + { fields: [ 'createdAt' ] }, + { + fields: [ + { name: 'publishedAt', order: 'DESC' }, + { name: 'id', order: 'ASC' } + ] + }, + { fields: [ 'duration' ] }, { - fields: [ 'name' ] + fields: [ + { name: 'views', order: 'DESC' }, + { name: 'id', order: 'ASC' } + ] }, + { fields: [ 'channelId' ] }, { - fields: [ 'createdAt' ] + fields: [ 'originallyPublishedAt' ], + where: { + originallyPublishedAt: { + [Op.ne]: null + } + } }, { - fields: [ 'duration' ] + fields: [ 'category' ], // We don't care videos with an unknown category + where: { + category: { + [Op.ne]: null + } + } }, { - fields: [ 'views' ] + fields: [ 'licence' ], // We don't care videos with an unknown licence + where: { + licence: { + [Op.ne]: null + } + } }, { - fields: [ 'likes' ] + fields: [ 'language' ], // We don't care videos with an unknown language + where: { + language: { + [Op.ne]: null + } + } }, { - fields: [ 'uuid' ] + fields: [ 'nsfw' ], // Most of the videos are not NSFW + where: { + nsfw: true + } }, { - fields: [ 'channelId' ] + fields: [ 'remote' ], // Only index local videos + where: { + remote: false + } }, { - fields: [ 'id', 'privacy', 'state', 'waitTranscoding' ] + fields: [ 'uuid' ], + unique: true }, { - fields: [ 'url'], + fields: [ 'url' ], unique: true } ] }) -export class VideoModel extends Model { +export class VideoModel extends Model>> { @AllowNull(false) @Default(DataType.UUIDV4) @@ -355,26 +439,23 @@ export class VideoModel extends Model { @AllowNull(true) @Default(null) - @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category')) @Column category: number @AllowNull(true) @Default(null) - @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence')) @Column licence: number @AllowNull(true) @Default(null) - @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language')) @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max)) language: string @AllowNull(false) @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy')) @Column - privacy: number + privacy: VideoPrivacy @AllowNull(false) @Is('VideoNSFW', value => throwIfNotValid(value, isBooleanValid, 'NSFW boolean')) @@ -383,13 +464,13 @@ export class VideoModel extends Model { @AllowNull(true) @Default(null) - @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description')) + @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description', true)) @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max)) description: string @AllowNull(true) @Default(null) - @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support')) + @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support', true)) @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max)) support: string @@ -423,6 +504,11 @@ export class VideoModel extends Model { @Column remote: boolean + @AllowNull(false) + @Default(false) + @Column + isLive: boolean + @AllowNull(false) @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max)) @@ -432,6 +518,10 @@ export class VideoModel extends Model { @Column commentsEnabled: boolean + @AllowNull(false) + @Column + downloadEnabled: boolean + @AllowNull(false) @Column waitTranscoding: boolean @@ -449,10 +539,15 @@ export class VideoModel extends Model { updatedAt: Date @AllowNull(false) - @Default(Sequelize.NOW) + @Default(DataType.NOW) @Column publishedAt: Date + @AllowNull(true) + @Default(null) + @Column + originallyPublishedAt: Date + @ForeignKey(() => VideoChannelModel) @Column channelId: number @@ -461,7 +556,7 @@ export class VideoModel extends Model { foreignKey: { allowNull: true }, - hooks: true + onDelete: 'cascade' }) VideoChannel: VideoChannelModel @@ -472,24 +567,61 @@ export class VideoModel extends Model { }) Tags: TagModel[] - @HasMany(() => VideoAbuseModel, { + @BelongsToMany(() => TrackerModel, { + foreignKey: 'videoId', + through: () => VideoTrackerModel, + onDelete: 'CASCADE' + }) + Trackers: TrackerModel[] + + @HasMany(() => ThumbnailModel, { foreignKey: { name: 'videoId', - allowNull: false + allowNull: true }, + hooks: true, onDelete: 'cascade' }) + Thumbnails: ThumbnailModel[] + + @HasMany(() => VideoPlaylistElementModel, { + foreignKey: { + name: 'videoId', + allowNull: true + }, + onDelete: 'set null' + }) + VideoPlaylistElements: VideoPlaylistElementModel[] + + @HasMany(() => VideoAbuseModel, { + foreignKey: { + name: 'videoId', + allowNull: true + }, + onDelete: 'set null' + }) VideoAbuses: VideoAbuseModel[] @HasMany(() => VideoFileModel, { foreignKey: { name: 'videoId', - allowNull: false + allowNull: true }, + hooks: true, onDelete: 'cascade' }) VideoFiles: VideoFileModel[] + @HasMany(() => VideoStreamingPlaylistModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + hooks: true, + onDelete: 'cascade' + }) + VideoStreamingPlaylists: VideoStreamingPlaylistModel[] + @HasMany(() => VideoShareModel, { foreignKey: { name: 'videoId', @@ -518,6 +650,24 @@ export class VideoModel extends Model { }) VideoComments: VideoCommentModel[] + @HasMany(() => VideoViewModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + VideoViews: VideoViewModel[] + + @HasMany(() => UserVideoHistoryModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + UserVideoHistories: UserVideoHistoryModel[] + @HasOne(() => ScheduleVideoUpdateModel, { foreignKey: { name: 'videoId', @@ -527,6 +677,33 @@ export class VideoModel extends Model { }) ScheduleVideoUpdate: ScheduleVideoUpdateModel + @HasOne(() => VideoBlacklistModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + VideoBlacklist: VideoBlacklistModel + + @HasOne(() => VideoLiveModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + VideoLive: VideoLiveModel + + @HasOne(() => VideoImportModel, { + foreignKey: { + name: 'videoId', + allowNull: true + }, + onDelete: 'set null' + }) + VideoImport: VideoImportModel + @HasMany(() => VideoCaptionModel, { foreignKey: { name: 'videoId', @@ -538,75 +715,129 @@ export class VideoModel extends Model { }) VideoCaptions: VideoCaptionModel[] - @BeforeDestroy - static async sendDelete (instance: VideoModel, options) { - if (instance.isOwned()) { - if (!instance.VideoChannel) { - instance.VideoChannel = await instance.$get('VideoChannel', { - include: [ - { - model: AccountModel, - include: [ ActorModel ] - } - ], - transaction: options.transaction - }) as VideoChannelModel - } + @HasOne(() => VideoJobInfoModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + VideoJobInfo: VideoJobInfoModel - logger.debug('Sending delete of video %s.', instance.url) + @BeforeDestroy + static async sendDelete (instance: MVideoAccountLight, options) { + if (!instance.isOwned()) return undefined - return sendDeleteVideo(instance, options.transaction) + // Lazy load channels + if (!instance.VideoChannel) { + instance.VideoChannel = await instance.$get('VideoChannel', { + include: [ + ActorModel, + AccountModel + ], + transaction: options.transaction + }) as MChannelAccountDefault } - return undefined + return sendDeleteVideo(instance, options.transaction) } @BeforeDestroy - static async removeFiles (instance: VideoModel) { + static async removeFiles (instance: VideoModel, options) { const tasks: Promise[] = [] - logger.debug('Removing files of video %s.', instance.url) - - tasks.push(instance.removeThumbnail()) + logger.info('Removing files of video %s.', instance.url) if (instance.isOwned()) { if (!Array.isArray(instance.VideoFiles)) { - instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[] + instance.VideoFiles = await instance.$get('VideoFiles', { transaction: options.transaction }) } - tasks.push(instance.removePreview()) - // Remove physical files and torrents instance.VideoFiles.forEach(file => { - tasks.push(instance.removeFile(file)) - tasks.push(instance.removeTorrent(file)) + tasks.push(instance.removeWebTorrentFileAndTorrent(file)) }) + + // Remove playlists file + if (!Array.isArray(instance.VideoStreamingPlaylists)) { + instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists', { transaction: options.transaction }) + } + + for (const p of instance.VideoStreamingPlaylists) { + tasks.push(instance.removeStreamingPlaylistFiles(p)) + } } // Do not wait video deletion because we could be in a transaction Promise.all(tasks) - .catch(err => { - logger.error('Some errors when removing files of video %s in after destroy hook.', instance.uuid, { err }) - }) + .catch(err => { + logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err }) + }) return undefined } - static list () { - return VideoModel.scope(ScopeNames.WITH_FILES).findAll() - } + @BeforeDestroy + static stopLiveIfNeeded (instance: VideoModel) { + if (!instance.isLive) return - static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { - function getRawQuery (select: string) { - const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' + - 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' + - 'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' + - 'WHERE "Account"."actorId" = ' + actorId - const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' + - 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' + - 'WHERE "VideoShare"."actorId" = ' + actorId + logger.info('Stopping live of video %s after video deletion.', instance.uuid) - return `(${queryVideo}) UNION (${queryVideoShare})` + LiveManager.Instance.stopSessionOf(instance.id) + } + + @BeforeDestroy + static invalidateCache (instance: VideoModel) { + ModelCache.Instance.invalidateCache('video', instance.id) + } + + @BeforeDestroy + static async saveEssentialDataToAbuses (instance: VideoModel, options) { + const tasks: Promise[] = [] + + if (!Array.isArray(instance.VideoAbuses)) { + instance.VideoAbuses = await instance.$get('VideoAbuses', { transaction: options.transaction }) + + if (instance.VideoAbuses.length === 0) return undefined + } + + logger.info('Saving video abuses details of video %s.', instance.url) + + if (!instance.Trackers) instance.Trackers = await instance.$get('Trackers', { transaction: options.transaction }) + const details = instance.toFormattedDetailsJSON() + + for (const abuse of instance.VideoAbuses) { + abuse.deletedVideo = details + tasks.push(abuse.save({ transaction: options.transaction })) + } + + await Promise.all(tasks) + } + + static listLocalIds (): Promise { + const query = { + attributes: [ 'id' ], + raw: true, + where: { + remote: false + } + } + + return VideoModel.findAll(query) + .then(rows => rows.map(r => r.id)) + } + + static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { + function getRawQuery (select: string) { + const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' + + 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' + + 'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' + + 'WHERE "Account"."actorId" = ' + actorId + const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' + + 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' + + 'WHERE "VideoShare"."actorId" = ' + actorId + + return `(${queryVideo}) UNION (${queryVideoShare})` } const rawQuery = getRawQuery('"Video"."id"') @@ -616,19 +847,16 @@ export class VideoModel extends Model { distinct: true, offset: start, limit: count, - order: getSort('createdAt', [ 'Tags', 'name', 'ASC' ]), + order: getVideoSort('-createdAt', [ 'Tags', 'name', 'ASC' ]), where: { id: { - [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')') + [Op.in]: Sequelize.literal('(' + rawQuery + ')') }, - [Sequelize.Op.or]: [ - { privacy: VideoPrivacy.PUBLIC }, - { privacy: VideoPrivacy.UNLISTED } - ] + [Op.or]: getPrivaciesForFederation() }, include: [ { - attributes: [ 'language' ], + attributes: [ 'filename', 'language', 'fileUrl' ], model: VideoCaptionModel.unscoped(), required: false }, @@ -638,10 +866,10 @@ export class VideoModel extends Model { required: false, // We only want videos shared by this actor where: { - [Sequelize.Op.and]: [ + [Op.and]: [ { id: { - [Sequelize.Op.not]: null + [Op.not]: null } }, { @@ -679,15 +907,25 @@ export class VideoModel extends Model { } ] }, + { + model: VideoStreamingPlaylistModel.unscoped(), + required: false, + include: [ + { + model: VideoFileModel, + required: false + } + ] + }, + VideoLiveModel.unscoped(), VideoFileModel, TagModel ] } return Bluebird.all([ - // FIXME: typing issue - VideoModel.findAll(query as any), - VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT }) + VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query), + VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT }) ]).then(([ rows, totals ]) => { // totals: totalVideos + totalVideoShares let totalVideos = 0 @@ -703,46 +941,89 @@ export class VideoModel extends Model { }) } - static listUserVideosForApi (accountId: number, start: number, count: number, sort: string, hideNSFW: boolean, withFiles = false) { - const query: IFindOptions = { - offset: start, - limit: count, - order: getSort(sort), - include: [ - { - model: VideoChannelModel, - required: true, - include: [ - { - model: AccountModel, - where: { - id: accountId - }, - required: true - } - ] - }, - { - model: ScheduleVideoUpdateModel, - required: false - } - ] + static async listPublishedLiveUUIDs () { + const options = { + attributes: [ 'uuid' ], + where: { + isLive: true, + remote: false, + state: VideoState.PUBLISHED + } } - if (withFiles === true) { - query.include.push({ - model: VideoFileModel.unscoped(), - required: true - }) - } + const result = await VideoModel.findAll(options) - if (hideNSFW === true) { - query.where = { - nsfw: false + return result.map(v => v.uuid) + } + + static listUserVideosForApi (options: { + accountId: number + start: number + count: number + sort: string + + channelId?: number + isLive?: boolean + search?: string + }) { + const { accountId, channelId, start, count, sort, search, isLive } = options + + function buildBaseQuery (): FindOptions { + const where: WhereOptions = {} + + if (search) { + where.name = { + [Op.iLike]: '%' + search + '%' + } + } + + if (exists(isLive)) { + where.isLive = isLive + } + + const channelWhere = channelId + ? { id: channelId } + : {} + + const baseQuery = { + offset: start, + limit: count, + where, + order: getVideoSort(sort), + include: [ + { + model: VideoChannelModel, + required: true, + where: channelWhere, + include: [ + { + model: AccountModel, + where: { + id: accountId + }, + required: true + } + ] + } + ] } + + return baseQuery } - return VideoModel.findAndCountAll(query).then(({ rows, count }) => { + const countQuery = buildBaseQuery() + const findQuery = buildBaseQuery() + + const findScopes: (string | ScopeOptions)[] = [ + ScopeNames.WITH_SCHEDULED_UPDATE, + ScopeNames.WITH_BLACKLISTED, + ScopeNames.WITH_THUMBNAILS + ] + + return Promise.all([ + VideoModel.count(countQuery), + VideoModel.scope(findScopes).findAll(findQuery) + ]).then(([ count, rows ]) => { return { data: rows, total: count @@ -751,163 +1032,325 @@ export class VideoModel extends Model { } static async listForApi (options: { - start: number, - count: number, - sort: string, - hideNSFW: boolean, - withFiles: boolean, - category?: number, - filter?: VideoFilter, - accountId?: number, + start: number + count: number + sort: string + + nsfw: boolean + isLive?: boolean + isLocal?: boolean + include?: VideoInclude + + hasFiles?: boolean // default false + hasWebtorrentFiles?: boolean + hasHLSFiles?: boolean + + categoryOneOf?: number[] + licenceOneOf?: number[] + languageOneOf?: string[] + tagsOneOf?: string[] + tagsAllOf?: string[] + privacyOneOf?: VideoPrivacy[] + + accountId?: number videoChannelId?: number + + displayOnlyForFollower: DisplayOnlyForFollowerOptions | null + + videoPlaylistId?: number + + trendingDays?: number + + user?: MUserAccountId + historyOfUser?: MUserId + + countVideos?: boolean + + search?: string }) { - const query = { - offset: options.start, - limit: options.count, - order: getSort(options.sort) + VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user) + VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user) + + const trendingDays = options.sort.endsWith('trending') + ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS + : undefined + + let trendingAlgorithm: string + if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot' + if (options.sort.endsWith('best')) trendingAlgorithm = 'best' + + const serverActor = await getServerActor() + + const queryOptions = { + ...pick(options, [ + 'start', + 'count', + 'sort', + 'nsfw', + 'isLive', + 'categoryOneOf', + 'licenceOneOf', + 'languageOneOf', + 'tagsOneOf', + 'tagsAllOf', + 'privacyOneOf', + 'isLocal', + 'include', + 'displayOnlyForFollower', + 'hasFiles', + 'accountId', + 'videoChannelId', + 'videoPlaylistId', + 'user', + 'historyOfUser', + 'hasHLSFiles', + 'hasWebtorrentFiles', + 'search' + ]), + + serverAccountIdForBlock: serverActor.Account.id, + trendingDays, + trendingAlgorithm } + return VideoModel.getAvailableForApi(queryOptions, options.countVideos) + } + + static async searchAndPopulateAccountAndServer (options: { + start: number + count: number + sort: string + + nsfw?: boolean + isLive?: boolean + isLocal?: boolean + include?: VideoInclude + + categoryOneOf?: number[] + licenceOneOf?: number[] + languageOneOf?: string[] + tagsOneOf?: string[] + tagsAllOf?: string[] + privacyOneOf?: VideoPrivacy[] + + displayOnlyForFollower: DisplayOnlyForFollowerOptions | null + + user?: MUserAccountId + + hasWebtorrentFiles?: boolean + hasHLSFiles?: boolean + + search?: string + + host?: string + startDate?: string // ISO 8601 + endDate?: string // ISO 8601 + originallyPublishedStartDate?: string + originallyPublishedEndDate?: string + + durationMin?: number // seconds + durationMax?: number // seconds + uuids?: string[] + }) { + VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user) + VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user) + const serverActor = await getServerActor() - const scopes = { - method: [ - ScopeNames.AVAILABLE_FOR_LIST, { - actorId: serverActor.id, - hideNSFW: options.hideNSFW, - category: options.category, - filter: options.filter, - withFiles: options.withFiles, - accountId: options.accountId, - videoChannelId: options.videoChannelId - } - ] + + const queryOptions = { + ...pick(options, [ + 'include', + 'nsfw', + 'isLive', + 'categoryOneOf', + 'licenceOneOf', + 'languageOneOf', + 'tagsOneOf', + 'tagsAllOf', + 'privacyOneOf', + 'user', + 'isLocal', + 'host', + 'start', + 'count', + 'sort', + 'startDate', + 'endDate', + 'originallyPublishedStartDate', + 'originallyPublishedEndDate', + 'durationMin', + 'durationMax', + 'hasHLSFiles', + 'hasWebtorrentFiles', + 'uuids', + 'search', + 'displayOnlyForFollower' + ]), + serverAccountIdForBlock: serverActor.Account.id } - return VideoModel.scope(scopes) - .findAndCountAll(query) - .then(({ rows, count }) => { - return { - data: rows, - total: count - } - }) + return VideoModel.getAvailableForApi(queryOptions) } - static async searchAndPopulateAccountAndServer (value: string, start: number, count: number, sort: string, hideNSFW: boolean) { - const query: IFindOptions = { - offset: start, - limit: count, - order: getSort(sort), + static countLocalLives () { + const options = { where: { - [Sequelize.Op.or]: [ - { - name: { - [ Sequelize.Op.iLike ]: '%' + value + '%' + remote: false, + isLive: true, + state: { + [Op.ne]: VideoState.LIVE_ENDED + } + } + } + + return VideoModel.count(options) + } + + static countVideosUploadedByUserSince (userId: number, since: Date) { + const options = { + include: [ + { + model: VideoChannelModel.unscoped(), + required: true, + include: [ + { + model: AccountModel.unscoped(), + required: true, + include: [ + { + model: UserModel.unscoped(), + required: true, + where: { + id: userId + } + } + ] } - }, - { - preferredUsernameChannel: Sequelize.where(Sequelize.col('VideoChannel->Actor.preferredUsername'), { - [ Sequelize.Op.iLike ]: '%' + value + '%' - }) - }, - { - preferredUsernameAccount: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor.preferredUsername'), { - [ Sequelize.Op.iLike ]: '%' + value + '%' - }) - }, - { - host: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor->Server.host'), { - [ Sequelize.Op.iLike ]: '%' + value + '%' - }) - } - ] + ] + } + ], + where: { + createdAt: { + [Op.gte]: since + } } } - const serverActor = await getServerActor() - const scopes = { - method: [ - ScopeNames.AVAILABLE_FOR_LIST, { - actorId: serverActor.id, - hideNSFW + return VideoModel.unscoped().count(options) + } + + static countLivesOfAccount (accountId: number) { + const options = { + where: { + remote: false, + isLive: true, + state: { + [Op.ne]: VideoState.LIVE_ENDED + } + }, + include: [ + { + required: true, + model: VideoChannelModel.unscoped(), + where: { + accountId + } } ] } - return VideoModel.scope(scopes) - .findAndCountAll(query) - .then(({ rows, count }) => { - return { - data: rows, - total: count - } - }) + return VideoModel.count(options) } - static load (id: number) { - return VideoModel.findById(id) + static load (id: number | string, transaction?: Transaction): Promise { + const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize) + + return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' }) } - static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) { - const query: IFindOptions = { - where: { - url + static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise { + const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize) + + return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' }) + } + + static loadImmutableAttributes (id: number | string, t?: Transaction): Promise { + const fun = () => { + const query = { + where: buildWhereIdOrUUID(id), + transaction: t } - } - if (t !== undefined) query.transaction = t + return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query) + } - return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) + return ModelCache.Instance.doCache({ + cacheType: 'load-video-immutable-id', + key: '' + id, + deleteKey: 'video', + fun + }) } - static loadByUUIDOrURLAndPopulateAccount (uuid: string, url: string, t?: Sequelize.Transaction) { - const query: IFindOptions = { - where: { - [Sequelize.Op.or]: [ - { uuid }, - { url } - ] + static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise { + const fun = () => { + const query: FindOptions = { + where: { + url + }, + transaction } + + return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query) } - if (t !== undefined) query.transaction = t + return ModelCache.Instance.doCache({ + cacheType: 'load-video-immutable-url', + key: url, + deleteKey: 'video', + fun + }) + } + + static loadOnlyId (id: number | string, transaction?: Transaction): Promise { + const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize) - return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) + return queryBuilder.queryVideo({ id, transaction, type: 'id' }) } - static loadAndPopulateAccountAndServerAndTags (id: number) { - const options = { - order: [ [ 'Tags', 'name', 'ASC' ] ] - } + static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise { + const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize) - return VideoModel - .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_SCHEDULED_UPDATE ]) - .findById(id, options) + return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging }) } - static loadByUUID (uuid: string) { - const options = { - where: { - uuid - } - } + static loadByUrl (url: string, transaction?: Transaction): Promise { + const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize) - return VideoModel - .scope([ ScopeNames.WITH_FILES ]) - .findOne(options) + return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' }) } - static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string, t?: Sequelize.Transaction) { - const options = { - order: [ [ 'Tags', 'name', 'ASC' ] ], - where: { - uuid - }, - transaction: t - } + static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise { + const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize) + + return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' }) + } + + static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise { + const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize) - return VideoModel - .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_SCHEDULED_UPDATE ]) - .findOne(options) + return queryBuilder.queryVideo({ id, transaction: t, type: 'full-light', userId }) + } + + static loadForGetAPI (parameters: { + id: number | string + transaction?: Transaction + userId?: number + }): Promise { + const { id, transaction, userId } = parameters + const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize) + + return queryBuilder.queryVideo({ id, transaction, type: 'api', userId }) } static async getStats () { @@ -916,16 +1359,29 @@ export class VideoModel extends Model { remote: false } }) - const totalVideos = await VideoModel.count() let totalLocalVideoViews = await VideoModel.sum('views', { where: { remote: false } }) + // Sequelize could return null... if (!totalLocalVideoViews) totalLocalVideoViews = 0 + const serverActor = await getServerActor() + + const { total: totalVideos } = await VideoModel.listForApi({ + start: 0, + count: 0, + sort: '-publishedAt', + nsfw: buildNSFWFilter(), + displayOnlyForFollower: { + actorId: serverActor.id, + orLocalVideos: true + } + }) + return { totalLocalVideos, totalLocalVideoViews, @@ -933,598 +1389,437 @@ export class VideoModel extends Model { } } - private static buildActorWhereWithFilter (filter?: VideoFilter) { - if (filter && filter === 'local') { - return { - serverId: null + static incrementViews (id: number, views: number) { + return VideoModel.increment('views', { + by: views, + where: { + id } - } - - return {} + }) } - private static getCategoryLabel (id: number) { - return VIDEO_CATEGORIES[id] || 'Misc' + static updateRatesOf (videoId: number, type: VideoRateType, t: Transaction) { + const field = type === 'like' + ? 'likes' + : 'dislikes' + + const rawQuery = `UPDATE "video" SET "${field}" = ` + + '(' + + 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' + + ') ' + + 'WHERE "video"."id" = :videoId' + + return AccountVideoRateModel.sequelize.query(rawQuery, { + transaction: t, + replacements: { videoId, rateType: type }, + type: QueryTypes.UPDATE + }) } - private static getLicenceLabel (id: number) { - return VIDEO_LICENCES[id] || 'Unknown' - } + static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) { + // Instances only share videos + const query = 'SELECT 1 FROM "videoShare" ' + + 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + + 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' + + 'LIMIT 1' - private static getLanguageLabel (id: string) { - return VIDEO_LANGUAGES[id] || 'Unknown' - } + const options = { + type: QueryTypes.SELECT as QueryTypes.SELECT, + bind: { followerActorId, videoId }, + raw: true + } - private static getPrivacyLabel (id: number) { - return VIDEO_PRIVACIES[id] || 'Unknown' + return VideoModel.sequelize.query(query, options) + .then(results => results.length === 1) } - private static getStateLabel (id: number) { - return VIDEO_STATES[id] || 'Unknown' + static bulkUpdateSupportField (ofChannel: MChannel, t: Transaction) { + const options = { + where: { + channelId: ofChannel.id + }, + transaction: t + } + + return VideoModel.update({ support: ofChannel.support }, options) } - getOriginalFile () { - if (Array.isArray(this.VideoFiles) === false) return undefined + static getAllIdsFromChannel (videoChannel: MChannelId): Promise { + const query = { + attributes: [ 'id' ], + where: { + channelId: videoChannel.id + } + } - // The original file is the file that have the higher resolution - return maxBy(this.VideoFiles, file => file.resolution) + return VideoModel.findAll(query) + .then(videos => videos.map(v => v.id)) } - getVideoFilename (videoFile: VideoFileModel) { - return this.uuid + '-' + videoFile.resolution + videoFile.extname - } + // threshold corresponds to how many video the field should have to be returned + static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) { + const serverActor = await getServerActor() - getThumbnailName () { - // We always have a copy of the thumbnail - const extension = '.jpg' - return this.uuid + extension - } + const queryOptions: BuildVideosListQueryOptions = { + attributes: [ `"${field}"` ], + group: `GROUP BY "${field}"`, + having: `HAVING COUNT("${field}") >= ${threshold}`, + start: 0, + sort: 'random', + count, + serverAccountIdForBlock: serverActor.Account.id, + displayOnlyForFollower: { + actorId: serverActor.id, + orLocalVideos: true + } + } - getPreviewName () { - const extension = '.jpg' - return this.uuid + extension - } + const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize) - getTorrentFileName (videoFile: VideoFileModel) { - const extension = '.torrent' - return this.uuid + '-' + videoFile.resolution + extension + return queryBuilder.queryVideoIds(queryOptions) + .then(rows => rows.map(r => r[field])) } - isOwned () { - return this.remote === false + static buildTrendingQuery (trendingDays: number) { + return { + attributes: [], + subQuery: false, + model: VideoViewModel, + required: false, + where: { + startDate: { + [Op.gte]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays) + } + } + } } - createPreview (videoFile: VideoFileModel) { - return generateImageFromVideoFile( - this.getVideoFilePath(videoFile), - CONFIG.STORAGE.PREVIEWS_DIR, - this.getPreviewName(), - PREVIEWS_SIZE - ) - } + private static async getAvailableForApi ( + options: BuildVideosListQueryOptions, + countVideos = true + ): Promise> { + function getCount () { + if (countVideos !== true) return Promise.resolve(undefined) - createThumbnail (videoFile: VideoFileModel) { - return generateImageFromVideoFile( - this.getVideoFilePath(videoFile), - CONFIG.STORAGE.THUMBNAILS_DIR, - this.getThumbnailName(), - THUMBNAILS_SIZE - ) - } + const countOptions = Object.assign({}, options, { isCount: true }) + const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize) - getTorrentFilePath (videoFile: VideoFileModel) { - return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile)) - } + return queryBuilder.countVideoIds(countOptions) + } - getVideoFilePath (videoFile: VideoFileModel) { - return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile)) - } + function getModels () { + if (options.count === 0) return Promise.resolve([]) - async createTorrentAndSetInfoHash (videoFile: VideoFileModel) { - const options = { - // Keep the extname, it's used by the client to stream the file inside a web browser - name: `${this.name} ${videoFile.resolution}p${videoFile.extname}`, - createdBy: 'PeerTube', - announceList: [ - [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ], - [ CONFIG.WEBSERVER.URL + '/tracker/announce' ] - ], - urlList: [ - CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) - ] + const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize) + + return queryBuilder.queryVideos(options) } - const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options) + const [ count, rows ] = await Promise.all([ getCount(), getModels() ]) - const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile)) - logger.info('Creating torrent %s.', filePath) + return { + data: rows, + total: count + } + } - await writeFilePromise(filePath, torrent) + private static throwIfPrivateIncludeWithoutUser (include: VideoInclude, user: MUserAccountId) { + if (VideoModel.isPrivateInclude(include) && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) { + throw new Error('Try to filter all-local but user cannot see all videos') + } + } - const parsedTorrent = parseTorrent(torrent) - videoFile.infoHash = parsedTorrent.infoHash + private static throwIfPrivacyOneOfWithoutUser (privacyOneOf: VideoPrivacy[], user: MUserAccountId) { + if (privacyOneOf && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) { + throw new Error('Try to choose video privacies but user cannot see all videos') + } } - getEmbedStaticPath () { - return '/videos/embed/' + this.uuid + private static isPrivateInclude (include: VideoInclude) { + return include & VideoInclude.BLACKLISTED || + include & VideoInclude.BLOCKED_OWNER || + include & VideoInclude.NOT_PUBLISHED_STATE } - getThumbnailStaticPath () { - return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) + isBlacklisted () { + return !!this.VideoBlacklist } - getPreviewStaticPath () { - return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) + isBlocked () { + return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked() } - toFormattedJSON (options?: { - additionalAttributes: { - state?: boolean, - waitTranscoding?: boolean, - scheduledUpdate?: boolean - } - }): Video { - const formattedAccount = this.VideoChannel.Account.toFormattedJSON() - const formattedVideoChannel = this.VideoChannel.toFormattedJSON() - - const videoObject: Video = { - id: this.id, - uuid: this.uuid, - name: this.name, - category: { - id: this.category, - label: VideoModel.getCategoryLabel(this.category) - }, - licence: { - id: this.licence, - label: VideoModel.getLicenceLabel(this.licence) - }, - language: { - id: this.language, - label: VideoModel.getLanguageLabel(this.language) - }, - privacy: { - id: this.privacy, - label: VideoModel.getPrivacyLabel(this.privacy) - }, - nsfw: this.nsfw, - description: this.getTruncatedDescription(), - isLocal: this.isOwned(), - duration: this.duration, - views: this.views, - likes: this.likes, - dislikes: this.dislikes, - thumbnailPath: this.getThumbnailStaticPath(), - previewPath: this.getPreviewStaticPath(), - embedPath: this.getEmbedStaticPath(), - createdAt: this.createdAt, - updatedAt: this.updatedAt, - publishedAt: this.publishedAt, - account: { - id: formattedAccount.id, - uuid: formattedAccount.uuid, - name: formattedAccount.name, - displayName: formattedAccount.displayName, - url: formattedAccount.url, - host: formattedAccount.host, - avatar: formattedAccount.avatar - }, - channel: { - id: formattedVideoChannel.id, - uuid: formattedVideoChannel.uuid, - name: formattedVideoChannel.name, - displayName: formattedVideoChannel.displayName, - url: formattedVideoChannel.url, - host: formattedVideoChannel.host, - avatar: formattedVideoChannel.avatar - } - } + getQualityFileBy (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) { + // We first transcode to WebTorrent format, so try this array first + if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) { + const file = fun(this.VideoFiles, file => file.resolution) - if (options) { - if (options.additionalAttributes.state === true) { - videoObject.state = { - id: this.state, - label: VideoModel.getStateLabel(this.state) - } - } + return Object.assign(file, { Video: this }) + } - if (options.additionalAttributes.waitTranscoding === true) { - videoObject.waitTranscoding = this.waitTranscoding - } + // No webtorrent files, try with streaming playlist files + if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) { + const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this }) - if (options.additionalAttributes.scheduledUpdate === true && this.ScheduleVideoUpdate) { - videoObject.scheduledUpdate = { - updateAt: this.ScheduleVideoUpdate.updateAt, - privacy: this.ScheduleVideoUpdate.privacy || undefined - } - } + const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution) + return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo }) } - return videoObject + return undefined } - toFormattedDetailsJSON (): VideoDetails { - const formattedJson = this.toFormattedJSON({ - additionalAttributes: { - scheduledUpdate: true - } - }) + getMaxQualityFile (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo { + return this.getQualityFileBy(maxBy) + } - const detailsJson = { - support: this.support, - descriptionPath: this.getDescriptionPath(), - channel: this.VideoChannel.toFormattedJSON(), - account: this.VideoChannel.Account.toFormattedJSON(), - tags: map(this.Tags, 'name'), - commentsEnabled: this.commentsEnabled, - waitTranscoding: this.waitTranscoding, - state: { - id: this.state, - label: VideoModel.getStateLabel(this.state) - }, - files: [] - } + getMinQualityFile (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo { + return this.getQualityFileBy(minBy) + } + + getWebTorrentFile (this: T, resolution: number): MVideoFileVideo { + if (Array.isArray(this.VideoFiles) === false) return undefined - // Format and sort video files - detailsJson.files = this.getFormattedVideoFilesJSON() + const file = this.VideoFiles.find(f => f.resolution === resolution) + if (!file) return undefined - return Object.assign(formattedJson, detailsJson) + return Object.assign(file, { Video: this }) } - getFormattedVideoFilesJSON (): VideoFile[] { - const { baseUrlHttp, baseUrlWs } = this.getBaseUrls() + hasWebTorrentFiles () { + return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0 + } - return this.VideoFiles - .map(videoFile => { - let resolutionLabel = videoFile.resolution + 'p' + async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) { + thumbnail.videoId = this.id - return { - resolution: { - id: videoFile.resolution, - label: resolutionLabel - }, - magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs), - size: videoFile.size, - fps: videoFile.fps, - torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp), - torrentDownloadUrl: this.getTorrentDownloadUrl(videoFile, baseUrlHttp), - fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp), - fileDownloadUrl: this.getVideoFileDownloadUrl(videoFile, baseUrlHttp) - } as VideoFile - }) - .sort((a, b) => { - if (a.resolution.id < b.resolution.id) return 1 - if (a.resolution.id === b.resolution.id) return 0 - return -1 - }) - } - - toActivityPubObject (): VideoTorrentObject { - const { baseUrlHttp, baseUrlWs } = this.getBaseUrls() - 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: VideoModel.getLanguageLabel(this.language) - } - } + const savedThumbnail = await thumbnail.save({ transaction }) - let category - if (this.category) { - category = { - identifier: this.category + '', - name: VideoModel.getCategoryLabel(this.category) - } - } + if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = [] - let licence - if (this.licence) { - licence = { - identifier: this.licence + '', - name: VideoModel.getLicenceLabel(this.licence) - } - } - - const url = [] - for (const file of this.VideoFiles) { - url.push({ - type: 'Link', - mimeType: VIDEO_EXT_MIMETYPE[file.extname], - href: this.getVideoFileUrl(file, baseUrlHttp), - width: file.resolution, - size: file.size - }) + this.Thumbnails = this.Thumbnails.filter(t => t.id !== savedThumbnail.id) + this.Thumbnails.push(savedThumbnail) + } - url.push({ - type: 'Link', - mimeType: 'application/x-bittorrent', - href: this.getTorrentUrl(file, baseUrlHttp), - width: file.resolution - }) + getMiniature () { + if (Array.isArray(this.Thumbnails) === false) return undefined - url.push({ - type: 'Link', - mimeType: 'application/x-bittorrent;x-scheme-handler/magnet', - href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs), - width: file.resolution - }) - } + return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE) + } - // Add video url too - url.push({ - type: 'Link', - mimeType: 'text/html', - href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid - }) + hasPreview () { + return !!this.getPreview() + } - const subtitleLanguage = [] - for (const caption of this.VideoCaptions) { - subtitleLanguage.push({ - identifier: caption.language, - name: VideoCaptionModel.getLanguageLabel(caption.language) - }) - } + getPreview () { + if (Array.isArray(this.Thumbnails) === false) return undefined - return { - type: 'Video' as 'Video', - id: this.url, - name: this.name, - duration: this.getActivityStreamDuration(), - uuid: this.uuid, - tag, - category, - licence, - language, - views: this.views, - sensitive: this.nsfw, - waitTranscoding: this.waitTranscoding, - state: this.state, - commentsEnabled: this.commentsEnabled, - published: this.publishedAt.toISOString(), - updated: this.updatedAt.toISOString(), - mediaType: 'text/markdown', - content: this.getTruncatedDescription(), - support: this.support, - subtitleLanguage, - icon: { - type: 'Image', - url: this.getThumbnailUrl(baseUrlHttp), - mediaType: 'image/jpeg', - width: THUMBNAILS_SIZE.width, - height: THUMBNAILS_SIZE.height - }, - url, - likes: getVideoLikesActivityPubUrl(this), - dislikes: getVideoDislikesActivityPubUrl(this), - shares: getVideoSharesActivityPubUrl(this), - comments: getVideoCommentsActivityPubUrl(this), - attributedTo: [ - { - type: 'Person', - id: this.VideoChannel.Account.Actor.url - }, - { - type: 'Group', - id: this.VideoChannel.Actor.url - } - ] - } + return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW) } - getTruncatedDescription () { - if (!this.description) return null + isOwned () { + return this.remote === false + } - const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max - return peertubeTruncate(this.description, maxLength) + getWatchStaticPath () { + return buildVideoWatchPath({ shortUUID: uuidToShort(this.uuid) }) } - async optimizeOriginalVideofile () { - const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR - const newExtname = '.mp4' - const inputVideoFile = this.getOriginalFile() - const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile)) - const videoTranscodedPath = join(videosDirectory, this.id + '-transcoded' + newExtname) + getEmbedStaticPath () { + return buildVideoEmbedPath(this) + } - const transcodeOptions = { - inputPath: videoInputPath, - outputPath: videoTranscodedPath - } + getMiniatureStaticPath () { + const thumbnail = this.getMiniature() + if (!thumbnail) return null - // Could be very long! - await transcode(transcodeOptions) + return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename) + } - try { - await unlinkPromise(videoInputPath) + getPreviewStaticPath () { + const preview = this.getPreview() + if (!preview) return null - // Important to do this before getVideoFilename() to take in account the new file extension - inputVideoFile.set('extname', newExtname) + // We use a local cache, so specify our cache endpoint instead of potential remote URL + return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename) + } - const videoOutputPath = this.getVideoFilePath(inputVideoFile) - await renamePromise(videoTranscodedPath, videoOutputPath) - const stats = await statPromise(videoOutputPath) - const fps = await getVideoFileFPS(videoOutputPath) + toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video { + return videoModelToFormattedJSON(this, options) + } - inputVideoFile.set('size', stats.size) - inputVideoFile.set('fps', fps) + toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails { + return videoModelToFormattedDetailsJSON(this) + } - await this.createTorrentAndSetInfoHash(inputVideoFile) - await inputVideoFile.save() + getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] { + let files: VideoFile[] = [] - } catch (err) { - // Auto destruction... - this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err })) + if (Array.isArray(this.VideoFiles)) { + const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, includeMagnet) + files = files.concat(result) + } - throw err + for (const p of (this.VideoStreamingPlaylists || [])) { + const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, includeMagnet) + files = files.concat(result) } + + return files } - async transcodeOriginalVideofile (resolution: VideoResolution, isPortraitMode: boolean) { - const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR - const extname = '.mp4' + toActivityPubObject (this: MVideoAP): VideoObject { + return videoModelToActivityPubObject(this) + } - // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed - const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile())) + getTruncatedDescription () { + if (!this.description) return null - const newVideoFile = new VideoFileModel({ - resolution, - extname, - size: 0, - videoId: this.id - }) - const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile)) + const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max + return peertubeTruncate(this.description, { length: maxLength }) + } - const transcodeOptions = { - inputPath: videoInputPath, - outputPath: videoOutputPath, - resolution, - isPortraitMode - } + getMaxQualityResolution () { + const file = this.getMaxQualityFile() + const videoOrPlaylist = file.getVideoOrStreamingPlaylist() - await transcode(transcodeOptions) + return VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(videoOrPlaylist), originalFilePath => { + return getVideoFileResolution(originalFilePath) + }) + } - const stats = await statPromise(videoOutputPath) - const fps = await getVideoFileFPS(videoOutputPath) + getDescriptionAPIPath () { + return `/api/${API_VERSION}/videos/${this.uuid}/description` + } - newVideoFile.set('size', stats.size) - newVideoFile.set('fps', fps) + getHLSPlaylist (): MStreamingPlaylistFilesVideo { + if (!this.VideoStreamingPlaylists) return undefined - await this.createTorrentAndSetInfoHash(newVideoFile) + const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) + if (!playlist) return undefined - await newVideoFile.save() + playlist.Video = this - this.VideoFiles.push(newVideoFile) + return playlist } - async importVideoFile (inputFilePath: string) { - const { videoFileResolution } = await getVideoFileResolution(inputFilePath) - const { size } = await statPromise(inputFilePath) - const fps = await getVideoFileFPS(inputFilePath) + setHLSPlaylist (playlist: MStreamingPlaylist) { + const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ] - let updatedVideoFile = new VideoFileModel({ - resolution: videoFileResolution, - extname: extname(inputFilePath), - size, - fps, - videoId: this.id - }) + if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) { + this.VideoStreamingPlaylists = toAdd + return + } - const currentVideoFile = this.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution) + this.VideoStreamingPlaylists = this.VideoStreamingPlaylists + .filter(s => s.type !== VideoStreamingPlaylistType.HLS) + .concat(toAdd) + } - if (currentVideoFile) { - // Remove old file and old torrent - await this.removeFile(currentVideoFile) - await this.removeTorrent(currentVideoFile) - // Remove the old video file from the array - this.VideoFiles = this.VideoFiles.filter(f => f !== currentVideoFile) + removeWebTorrentFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) { + const filePath = isRedundancy + ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile) + : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile) - // Update the database - currentVideoFile.set('extname', updatedVideoFile.extname) - currentVideoFile.set('size', updatedVideoFile.size) - currentVideoFile.set('fps', updatedVideoFile.fps) + const promises: Promise[] = [ remove(filePath) ] + if (!isRedundancy) promises.push(videoFile.removeTorrent()) - updatedVideoFile = currentVideoFile + if (videoFile.storage === VideoStorage.OBJECT_STORAGE) { + promises.push(removeWebTorrentObjectStorage(videoFile)) } - const outputPath = this.getVideoFilePath(updatedVideoFile) - await copyFilePromise(inputFilePath, outputPath) + return Promise.all(promises) + } - await this.createTorrentAndSetInfoHash(updatedVideoFile) + async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) { + const directoryPath = isRedundancy + ? getHLSRedundancyDirectory(this) + : getHLSDirectory(this) - await updatedVideoFile.save() + await remove(directoryPath) - this.VideoFiles.push(updatedVideoFile) - } + if (isRedundancy !== true) { + const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo + streamingPlaylistWithFiles.Video = this - getOriginalFileResolution () { - const originalFilePath = this.getVideoFilePath(this.getOriginalFile()) + if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) { + streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles') + } - return getVideoFileResolution(originalFilePath) - } + // Remove physical files and torrents + await Promise.all( + streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent()) + ) - getDescriptionPath () { - return `/api/${API_VERSION}/videos/${this.uuid}/description` + if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) { + await removeHLSObjectStorage(streamingPlaylist.withVideo(this)) + } + } } - removeThumbnail () { - const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) - return unlinkPromise(thumbnailPath) + isOutdated () { + if (this.isOwned()) return false + + return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL) } - removePreview () { - // Same name than video thumbnail - return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName()) + hasPrivacyForFederation () { + return isPrivacyForFederation(this.privacy) } - removeFile (videoFile: VideoFileModel) { - const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile)) - return unlinkPromise(filePath) + hasStateForFederation () { + return isStateForFederation(this.state) } - removeTorrent (videoFile: VideoFileModel) { - const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile)) - return unlinkPromise(torrentPath) + isNewVideo (newPrivacy: VideoPrivacy) { + return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true } - getActivityStreamDuration () { - // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration - return 'PT' + this.duration + 'S' + setAsRefreshed (transaction?: Transaction) { + return setAsUpdated('video', this.id, transaction) } - private getBaseUrls () { - let baseUrlHttp - let baseUrlWs + requiresAuth () { + return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist + } - if (this.isOwned()) { - baseUrlHttp = CONFIG.WEBSERVER.URL - baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT - } else { - baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host - baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host + setPrivacy (newPrivacy: VideoPrivacy) { + if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) { + this.publishedAt = new Date() } - return { baseUrlHttp, baseUrlWs } + this.privacy = newPrivacy } - private getThumbnailUrl (baseUrlHttp: string) { - return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() + isConfidential () { + return this.privacy === VideoPrivacy.PRIVATE || + this.privacy === VideoPrivacy.UNLISTED || + this.privacy === VideoPrivacy.INTERNAL } - private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) { - return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile) - } + async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) { + if (this.state === newState) throw new Error('Cannot use same state ' + newState) - private getTorrentDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) { - return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + this.getTorrentFileName(videoFile) - } + this.state = newState - private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) { - return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) - } + if (this.state === VideoState.PUBLISHED && isNewVideo) { + this.publishedAt = new Date() + } - private getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) { - return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile) + await this.save({ transaction }) } - private generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) { - const xs = this.getTorrentUrl(videoFile, baseUrlHttp) - const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] - const urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ] + getBandwidthBits (this: MVideo, videoFile: MVideoFile) { + return Math.ceil((videoFile.size * 8) / this.duration) + } - const magnetHash = { - xs, - announce, - urlList, - infoHash: videoFile.infoHash, - name: this.name + getTrackerUrls () { + if (this.isOwned()) { + return [ + WEBSERVER.URL + '/tracker/announce', + WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket' + ] } - return magnetUtil.encode(magnetHash) + return this.Trackers.map(t => t.url) } }