X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Fmodels%2Fvideo%2Fvideo.ts;h=44aaa24efe3b02c55185b12fc512249e6deb5595;hb=28dfb44b145c537aba07ae73cb1287f25532022a;hp=272bba0e1c6ee08aa187a457765d47521c966cdf;hpb=d95d15598847c7f020aa056e7e6e0c02d2bbf732;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 272bba0e1..44aaa24ef 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -2,7 +2,7 @@ import * as Bluebird from 'bluebird' import { remove } from 'fs-extra' import { maxBy, minBy, pick } from 'lodash' import { join } from 'path' -import { FindOptions, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' +import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' import { AllowNull, BeforeDestroy, @@ -24,15 +24,18 @@ import { Table, UpdatedAt } from 'sequelize-typescript' +import { setAsUpdated } from '@server/helpers/database-utils' import { buildNSFWFilter } from '@server/helpers/express-utils' -import { getPrivaciesForFederation, isPrivacyForFederation } from '@server/helpers/video' -import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths' +import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video' +import { LiveManager } from '@server/lib/live-manager' +import { getHLSDirectory, getVideoFilePath } from '@server/lib/video-paths' import { getServerActor } from '@server/models/application/application' import { ModelCache } from '@server/models/model-cache' +import { AttributesOnly } from '@shared/core-utils' import { VideoFile } from '@shared/models/videos/video-file.model' import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared' -import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' -import { Video, VideoDetails } from '../../../shared/models/videos' +import { VideoObject } from '../../../shared/models/activitypub/objects' +import { Video, VideoDetails, VideoRateType } from '../../../shared/models/videos' import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' import { VideoFilter } from '../../../shared/models/videos/video-query.type' import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' @@ -50,7 +53,7 @@ import { isVideoStateValid, isVideoSupportValid } from '../../helpers/custom-validators/videos' -import { getVideoFileResolution } from '../../helpers/ffmpeg-utils' +import { getVideoFileResolution } from '../../helpers/ffprobe-utils' import { logger } from '../../helpers/logger' import { CONFIG } from '../../initializers/config' import { @@ -58,8 +61,6 @@ import { API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, - REMOTE_SCHEME, - STATIC_DOWNLOAD_PATHS, STATIC_PATHS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, @@ -77,6 +78,7 @@ import { MStreamingPlaylistFilesVideo, MUserAccountId, MUserId, + MVideo, MVideoAccountLight, MVideoAccountLightBlacklistAllFiles, MVideoAP, @@ -99,11 +101,14 @@ import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models import { VideoAbuseModel } from '../abuse/video-abuse' import { AccountModel } from '../account/account' import { AccountVideoRateModel } from '../account/account-video-rate' -import { UserVideoHistoryModel } from '../account/user-video-history' -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 { TrackerModel } from '../server/tracker' +import { VideoTrackerModel } from '../server/video-tracker' +import { UserModel } from '../user/user' +import { UserVideoHistoryModel } from '../user/user-video-history' import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils' import { ScheduleVideoUpdateModel } from './schedule-video-update' import { TagModel } from './tag' @@ -121,6 +126,7 @@ import { videoModelToFormattedJSON } from './video-format-utils' import { VideoImportModel } from './video-import' +import { VideoLiveModel } from './video-live' import { VideoPlaylistElementModel } from './video-playlist-element' import { buildListQuery, BuildVideosQueryOptions, wrapForAPIResults } from './video-query-builder' import { VideoShareModel } from './video-share' @@ -133,6 +139,7 @@ export enum ScopeNames { FOR_API = 'FOR_API', WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS', WITH_TAGS = 'WITH_TAGS', + WITH_TRACKERS = 'WITH_TRACKERS', WITH_WEBTORRENT_FILES = 'WITH_WEBTORRENT_FILES', WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', WITH_BLACKLISTED = 'WITH_BLACKLISTED', @@ -140,7 +147,8 @@ export enum ScopeNames { WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS', WITH_USER_ID = 'WITH_USER_ID', WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES', - WITH_THUMBNAILS = 'WITH_THUMBNAILS' + WITH_THUMBNAILS = 'WITH_THUMBNAILS', + WITH_LIVE = 'WITH_LIVE' } export type ForAPIOptions = { @@ -148,8 +156,6 @@ export type ForAPIOptions = { videoPlaylistId?: number - withFiles?: boolean - withAccountBlockerIds?: number[] } @@ -187,26 +193,26 @@ export type AvailableForListIDsOptions = { attributes: [ 'id', 'url', 'uuid', 'remote' ] }, [ScopeNames.FOR_API]: (options: ForAPIOptions) => { - const query: FindOptions = { - include: [ - { - model: VideoChannelModel.scope({ - method: [ - VideoChannelScopeNames.SUMMARY, { - withAccount: true, - withAccountBlockerIds: options.withAccountBlockerIds - } as SummaryOptions - ] - }), - required: true - }, - { - attributes: [ 'type', 'filename' ], - model: ThumbnailModel, - required: false - } - ] - } + const include: Includeable[] = [ + { + model: VideoChannelModel.scope({ + method: [ + VideoChannelScopeNames.SUMMARY, { + withAccount: true, + withAccountBlockerIds: options.withAccountBlockerIds + } as SummaryOptions + ] + }), + required: true + }, + { + attributes: [ 'type', 'filename' ], + model: ThumbnailModel, + required: false + } + ] + + const query: FindOptions = {} if (options.ids) { query.where = { @@ -216,15 +222,8 @@ export type AvailableForListIDsOptions = { } } - if (options.withFiles === true) { - query.include.push({ - model: VideoFileModel, - required: true - }) - } - if (options.videoPlaylistId) { - query.include.push({ + include.push({ model: VideoPlaylistElementModel.unscoped(), required: true, where: { @@ -233,6 +232,8 @@ export type AvailableForListIDsOptions = { }) } + query.include = include + return query }, [ScopeNames.WITH_THUMBNAILS]: { @@ -243,6 +244,14 @@ export type AvailableForListIDsOptions = { } ] }, + [ScopeNames.WITH_LIVE]: { + include: [ + { + model: VideoLiveModel.unscoped(), + required: false + } + ] + }, [ScopeNames.WITH_USER_ID]: { include: [ { @@ -278,7 +287,8 @@ export type AvailableForListIDsOptions = { required: false }, { - model: AvatarModel.unscoped(), + model: ActorImageModel.unscoped(), + as: 'Avatar', required: false } ] @@ -300,7 +310,8 @@ export type AvailableForListIDsOptions = { required: false }, { - model: AvatarModel.unscoped(), + model: ActorImageModel.unscoped(), + as: 'Avatar', required: false } ] @@ -314,6 +325,14 @@ export type AvailableForListIDsOptions = { [ScopeNames.WITH_TAGS]: { include: [ TagModel ] }, + [ScopeNames.WITH_TRACKERS]: { + include: [ + { + attributes: [ 'id', 'url' ], + model: TrackerModel + } + ] + }, [ScopeNames.WITH_BLACKLISTED]: { include: [ { @@ -340,7 +359,7 @@ export type AvailableForListIDsOptions = { include: [ { model: VideoFileModel, - separate: true, // We may have multiple files, having multiple redundancies so let's separate this join + separate: true, required: false, include: subInclude } @@ -367,8 +386,8 @@ export type AvailableForListIDsOptions = { include: [ { model: VideoStreamingPlaylistModel.unscoped(), - separate: true, // We may have multiple streaming playlists, having multiple redundancies so let's separate this join required: false, + separate: true, include: subInclude } ] @@ -410,7 +429,12 @@ export type AvailableForListIDsOptions = { ] }, { fields: [ 'duration' ] }, - { fields: [ 'views' ] }, + { + fields: [ + { name: 'views', order: 'DESC' }, + { name: 'id', order: 'ASC' } + ] + }, { fields: [ 'channelId' ] }, { fields: [ 'originallyPublishedAt' ], @@ -466,7 +490,7 @@ export type AvailableForListIDsOptions = { } ] }) -export class VideoModel extends Model { +export class VideoModel extends Model>> { @AllowNull(false) @Default(DataType.UUIDV4) @@ -549,6 +573,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)) @@ -607,6 +636,13 @@ export class VideoModel extends Model { }) Tags: TagModel[] + @BelongsToMany(() => TrackerModel, { + foreignKey: 'videoId', + through: () => VideoTrackerModel, + onDelete: 'CASCADE' + }) + Trackers: TrackerModel[] + @HasMany(() => ThumbnailModel, { foreignKey: { name: 'videoId', @@ -719,6 +755,15 @@ export class VideoModel extends Model { }) VideoBlacklist: VideoBlacklistModel + @HasOne(() => VideoLiveModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + VideoLive: VideoLiveModel + @HasOne(() => VideoImportModel, { foreignKey: { name: 'videoId', @@ -741,21 +786,20 @@ export class VideoModel extends Model { @BeforeDestroy static async sendDelete (instance: MVideoAccountLight, options) { - if (instance.isOwned()) { - if (!instance.VideoChannel) { - instance.VideoChannel = await instance.$get('VideoChannel', { - include: [ - ActorModel, - AccountModel - ], - transaction: options.transaction - }) as MChannelAccountDefault - } + 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 @@ -772,7 +816,7 @@ export class VideoModel extends Model { // Remove physical files and torrents instance.VideoFiles.forEach(file => { tasks.push(instance.removeFile(file)) - tasks.push(instance.removeTorrent(file)) + tasks.push(file.removeTorrent()) }) // Remove playlists file @@ -794,6 +838,15 @@ export class VideoModel extends Model { return undefined } + @BeforeDestroy + static stopLiveIfNeeded (instance: VideoModel) { + if (!instance.isLive) return + + logger.info('Stopping live of video %s after video deletion.', instance.uuid) + + return LiveManager.Instance.stopSessionOf(instance.id) + } + @BeforeDestroy static invalidateCache (instance: VideoModel) { ModelCache.Instance.invalidateCache('video', instance.id) @@ -803,14 +856,15 @@ export class VideoModel extends Model { static async saveEssentialDataToAbuses (instance: VideoModel, options) { const tasks: Promise[] = [] - logger.info('Saving video abuses details of video %s.', instance.url) - if (!Array.isArray(instance.VideoAbuses)) { instance.VideoAbuses = await instance.$get('VideoAbuses') 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) { @@ -826,18 +880,14 @@ export class VideoModel extends Model { return undefined } - static listLocal (): Bluebird { + static listLocal (): Promise { const query = { where: { remote: false } } - return VideoModel.scope([ - ScopeNames.WITH_WEBTORRENT_FILES, - ScopeNames.WITH_STREAMING_PLAYLISTS, - ScopeNames.WITH_THUMBNAILS - ]).findAll(query) + return VideoModel.findAll(query) } static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { @@ -869,7 +919,7 @@ export class VideoModel extends Model { }, include: [ { - attributes: [ 'language', 'fileUrl' ], + attributes: [ 'filename', 'language', 'fileUrl' ], model: VideoCaptionModel.unscoped(), required: false }, @@ -920,6 +970,17 @@ export class VideoModel extends Model { } ] }, + { + model: VideoStreamingPlaylistModel.unscoped(), + required: false, + include: [ + { + model: VideoFileModel, + required: false + } + ] + }, + VideoLiveModel.unscoped(), VideoFileModel, TagModel ] @@ -943,17 +1004,48 @@ export class VideoModel extends Model { }) } - static listUserVideosForApi ( - accountId: number, - start: number, - count: number, - sort: string, + static async listPublishedLiveUUIDs () { + const options = { + attributes: [ 'uuid' ], + where: { + isLive: true, + remote: false, + state: VideoState.PUBLISHED + } + } + + const result = await VideoModel.findAll(options) + + return result.map(v => v.uuid) + } + + static listUserVideosForApi (options: { + accountId: number + start: number + count: number + sort: string + isLive?: boolean search?: string - ) { + }) { + const { accountId, start, count, sort, search, isLive } = options + function buildBaseQuery (): FindOptions { - let baseQuery = { + const where: WhereOptions = {} + + if (search) { + where.name = { + [Op.iLike]: '%' + search + '%' + } + } + + if (isLive) { + where.isLive = isLive + } + + const baseQuery = { offset: start, limit: count, + where, order: getVideoSort(sort), include: [ { @@ -972,16 +1064,6 @@ export class VideoModel extends Model { ] } - if (search) { - baseQuery = Object.assign(baseQuery, { - where: { - name: { - [Op.iLike]: '%' + search + '%' - } - } - }) - } - return baseQuery } @@ -1009,31 +1091,46 @@ export class VideoModel extends Model { start: number count: number sort: string + nsfw: boolean + filter?: VideoFilter + isLive?: boolean + includeLocalVideos: boolean withFiles: boolean + categoryOneOf?: number[] licenceOneOf?: number[] languageOneOf?: string[] tagsOneOf?: string[] tagsAllOf?: string[] - filter?: VideoFilter + accountId?: number videoChannelId?: number + followerActorId?: number + videoPlaylistId?: number + trendingDays?: number + user?: MUserAccountId historyOfUser?: MUserId + countVideos?: boolean + + search?: string }) { - if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) { + if ((options.filter === 'all-local' || options.filter === 'all') && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) { throw new Error('Try to filter all-local but no user has not the see all videos right') } const trendingDays = options.sort.endsWith('trending') ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS : undefined + let trendingAlgorithm + if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot' + if (options.sort.endsWith('best')) trendingAlgorithm = 'best' const serverActor = await getServerActor() @@ -1049,6 +1146,7 @@ export class VideoModel extends Model { followerActorId, serverAccountId: serverActor.Account.id, nsfw: options.nsfw, + isLive: options.isLive, categoryOneOf: options.categoryOneOf, licenceOneOf: options.licenceOneOf, languageOneOf: options.languageOneOf, @@ -1062,7 +1160,9 @@ export class VideoModel extends Model { includeLocalVideos: options.includeLocalVideos, user: options.user, historyOfUser: options.historyOfUser, - trendingDays + trendingDays, + trendingAlgorithm, + search: options.search } return VideoModel.getAvailableForApi(queryOptions, options.countVideos) @@ -1079,6 +1179,7 @@ export class VideoModel extends Model { originallyPublishedStartDate?: string originallyPublishedEndDate?: string nsfw?: boolean + isLive?: boolean categoryOneOf?: number[] licenceOneOf?: number[] languageOneOf?: string[] @@ -1090,23 +1191,32 @@ export class VideoModel extends Model { filter?: VideoFilter }) { const serverActor = await getServerActor() + const queryOptions = { followerActorId: serverActor.id, serverAccountId: serverActor.Account.id, + includeLocalVideos: options.includeLocalVideos, nsfw: options.nsfw, + isLive: options.isLive, + categoryOneOf: options.categoryOneOf, licenceOneOf: options.licenceOneOf, languageOneOf: options.languageOneOf, + tagsOneOf: options.tagsOneOf, tagsAllOf: options.tagsAllOf, + user: options.user, filter: options.filter, + start: options.start, count: options.count, sort: options.sort, + startDate: options.startDate, endDate: options.endDate, + originallyPublishedStartDate: options.originallyPublishedStartDate, originallyPublishedEndDate: options.originallyPublishedEndDate, @@ -1119,7 +1229,77 @@ export class VideoModel extends Model { return VideoModel.getAvailableForApi(queryOptions) } - static load (id: number | string, t?: Transaction): Bluebird { + static countLocalLives () { + const options = { + where: { + 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 + } + } + ] + } + ] + } + ], + where: { + createdAt: { + [Op.gte]: since + } + } + } + + 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.count(options) + } + + static load (id: number | string, t?: Transaction): Promise { const where = buildWhereIdOrUUID(id) const options = { where, @@ -1129,7 +1309,7 @@ export class VideoModel extends Model { return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options) } - static loadWithBlacklist (id: number | string, t?: Transaction): Bluebird { + static loadWithBlacklist (id: number | string, t?: Transaction): Promise { const where = buildWhereIdOrUUID(id) const options = { where, @@ -1142,7 +1322,7 @@ export class VideoModel extends Model { ]).findOne(options) } - static loadImmutableAttributes (id: number | string, t?: Transaction): Bluebird { + static loadImmutableAttributes (id: number | string, t?: Transaction): Promise { const fun = () => { const query = { where: buildWhereIdOrUUID(id), @@ -1160,7 +1340,7 @@ export class VideoModel extends Model { }) } - static loadWithRights (id: number | string, t?: Transaction): Bluebird { + static loadWithRights (id: number | string, t?: Transaction): Promise { const where = buildWhereIdOrUUID(id) const options = { where, @@ -1169,12 +1349,11 @@ export class VideoModel extends Model { return VideoModel.scope([ ScopeNames.WITH_BLACKLISTED, - ScopeNames.WITH_USER_ID, - ScopeNames.WITH_THUMBNAILS + ScopeNames.WITH_USER_ID ]).findOne(options) } - static loadOnlyId (id: number | string, t?: Transaction): Bluebird { + static loadOnlyId (id: number | string, t?: Transaction): Promise { const where = buildWhereIdOrUUID(id) const options = { @@ -1186,7 +1365,7 @@ export class VideoModel extends Model { return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options) } - static loadWithFiles (id: number | string, t?: Transaction, logging?: boolean): Bluebird { + static loadWithFiles (id: number | string, t?: Transaction, logging?: boolean): Promise { const where = buildWhereIdOrUUID(id) const query = { @@ -1202,7 +1381,7 @@ export class VideoModel extends Model { ]).findOne(query) } - static loadByUUID (uuid: string): Bluebird { + static loadByUUID (uuid: string): Promise { const options = { where: { uuid @@ -1212,7 +1391,7 @@ export class VideoModel extends Model { return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options) } - static loadByUrl (url: string, transaction?: Transaction): Bluebird { + static loadByUrl (url: string, transaction?: Transaction): Promise { const query: FindOptions = { where: { url @@ -1223,7 +1402,7 @@ export class VideoModel extends Model { return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query) } - static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Bluebird { + static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise { const fun = () => { const query: FindOptions = { where: { @@ -1243,7 +1422,7 @@ export class VideoModel extends Model { }) } - static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Bluebird { + static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise { const query: FindOptions = { where: { url @@ -1260,7 +1439,7 @@ export class VideoModel extends Model { ]).findOne(query) } - static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Bluebird { + static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise { const where = buildWhereIdOrUUID(id) const options = { @@ -1276,7 +1455,8 @@ export class VideoModel extends Model { ScopeNames.WITH_SCHEDULED_UPDATE, ScopeNames.WITH_WEBTORRENT_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS, - ScopeNames.WITH_THUMBNAILS + ScopeNames.WITH_THUMBNAILS, + ScopeNames.WITH_LIVE ] if (userId) { @@ -1292,7 +1472,7 @@ export class VideoModel extends Model { id: number | string t?: Transaction userId?: number - }): Bluebird { + }): Promise { const { id, t, userId } = parameters const where = buildWhereIdOrUUID(id) @@ -1308,6 +1488,8 @@ export class VideoModel extends Model { ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_SCHEDULED_UPDATE, ScopeNames.WITH_THUMBNAILS, + ScopeNames.WITH_LIVE, + ScopeNames.WITH_TRACKERS, { method: [ ScopeNames.WITH_WEBTORRENT_FILES, true ] }, { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] } ] @@ -1362,6 +1544,24 @@ export class VideoModel extends Model { }) } + 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 + }) + } + static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) { // Instances only share videos const query = 'SELECT 1 FROM "videoShare" ' + @@ -1390,7 +1590,7 @@ export class VideoModel extends Model { return VideoModel.update({ support: videoChannel.support }, options) } - static getAllIdsFromChannel (videoChannel: MChannelId): Bluebird { + static getAllIdsFromChannel (videoChannel: MChannelId): Promise { const query = { attributes: [ 'id' ], where: { @@ -1419,7 +1619,7 @@ export class VideoModel extends Model { includeLocalVideos: true } - const { query, replacements } = buildListQuery(VideoModel, queryOptions) + const { query, replacements } = buildListQuery(VideoModel.sequelize, queryOptions) return this.sequelize.query(query, { replacements, type: QueryTypes.SELECT }) .then(rows => rows.map(r => r[field])) @@ -1447,7 +1647,7 @@ export class VideoModel extends Model { if (countVideos !== true) return Promise.resolve(undefined) const countOptions = Object.assign({}, options, { isCount: true }) - const { query: queryCount, replacements: replacementsCount } = buildListQuery(VideoModel, countOptions) + const { query: queryCount, replacements: replacementsCount } = buildListQuery(VideoModel.sequelize, countOptions) return VideoModel.sequelize.query(queryCount, { replacements: replacementsCount, type: QueryTypes.SELECT }) .then(rows => rows.length !== 0 ? rows[0].total : 0) @@ -1456,7 +1656,7 @@ export class VideoModel extends Model { function getModels () { if (options.count === 0) return Promise.resolve([]) - const { query, replacements, order } = buildListQuery(VideoModel, options) + const { query, replacements, order } = buildListQuery(VideoModel.sequelize, options) const queryModels = wrapForAPIResults(query, replacements, options, order) return VideoModel.sequelize.query(queryModels, { replacements, type: QueryTypes.SELECT, nest: true }) @@ -1472,7 +1672,8 @@ export class VideoModel extends Model { } private static buildAPIResult (rows: any[]) { - const memo: { [ id: number ]: VideoModel } = {} + const videosMemo: { [ id: number ]: VideoModel } = {} + const videoStreamingPlaylistMemo: { [ id: number ]: VideoStreamingPlaylistModel } = {} const thumbnailsDone = new Set() const historyDone = new Set() @@ -1483,7 +1684,23 @@ export class VideoModel extends Model { const avatarKeys = [ 'id', 'filename', 'fileUrl', 'onDisk', 'createdAt', 'updatedAt' ] const actorKeys = [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ] const serverKeys = [ 'id', 'host' ] - const videoFileKeys = [ 'id', 'createdAt', 'updatedAt', 'resolution', 'size', 'extname', 'infoHash', 'fps', 'videoId' ] + const videoFileKeys = [ + 'id', + 'createdAt', + 'updatedAt', + 'resolution', + 'size', + 'extname', + 'filename', + 'fileUrl', + 'torrentFilename', + 'torrentUrl', + 'infoHash', + 'fps', + 'videoId', + 'videoStreamingPlaylistId' + ] + const videoStreamingPlaylistKeys = [ 'id', 'type', 'playlistUrl' ] const videoKeys = [ 'id', 'uuid', @@ -1500,6 +1717,7 @@ export class VideoModel extends Model { 'likes', 'dislikes', 'remote', + 'isLive', 'url', 'commentsEnabled', 'downloadEnabled', @@ -1511,17 +1729,18 @@ export class VideoModel extends Model { 'createdAt', 'updatedAt' ] + const buildOpts = { raw: true } function buildActor (rowActor: any) { const avatarModel = rowActor.Avatar.id !== null - ? new AvatarModel(pick(rowActor.Avatar, avatarKeys)) + ? new ActorImageModel(pick(rowActor.Avatar, avatarKeys), buildOpts) : null const serverModel = rowActor.Server.id !== null - ? new ServerModel(pick(rowActor.Server, serverKeys)) + ? new ServerModel(pick(rowActor.Server, serverKeys), buildOpts) : null - const actorModel = new ActorModel(pick(rowActor, actorKeys)) + const actorModel = new ActorModel(pick(rowActor, actorKeys), buildOpts) actorModel.Avatar = avatarModel actorModel.Server = serverModel @@ -1529,52 +1748,71 @@ export class VideoModel extends Model { } for (const row of rows) { - if (!memo[row.id]) { + if (!videosMemo[row.id]) { // Build Channel const channel = row.VideoChannel - const channelModel = new VideoChannelModel(pick(channel, [ 'id', 'name', 'description', 'actorId' ])) + const channelModel = new VideoChannelModel(pick(channel, [ 'id', 'name', 'description', 'actorId' ]), buildOpts) channelModel.Actor = buildActor(channel.Actor) const account = row.VideoChannel.Account - const accountModel = new AccountModel(pick(account, [ 'id', 'name' ])) + const accountModel = new AccountModel(pick(account, [ 'id', 'name' ]), buildOpts) accountModel.Actor = buildActor(account.Actor) channelModel.Account = accountModel - const videoModel = new VideoModel(pick(row, videoKeys)) + const videoModel = new VideoModel(pick(row, videoKeys), buildOpts) videoModel.VideoChannel = channelModel videoModel.UserVideoHistories = [] videoModel.Thumbnails = [] videoModel.VideoFiles = [] + videoModel.VideoStreamingPlaylists = [] - memo[row.id] = videoModel + videosMemo[row.id] = videoModel // Don't take object value to have a sorted array videos.push(videoModel) } - const videoModel = memo[row.id] + const videoModel = videosMemo[row.id] if (row.userVideoHistory?.id && !historyDone.has(row.userVideoHistory.id)) { - const historyModel = new UserVideoHistoryModel(pick(row.userVideoHistory, [ 'id', 'currentTime' ])) + const historyModel = new UserVideoHistoryModel(pick(row.userVideoHistory, [ 'id', 'currentTime' ]), buildOpts) videoModel.UserVideoHistories.push(historyModel) historyDone.add(row.userVideoHistory.id) } if (row.Thumbnails?.id && !thumbnailsDone.has(row.Thumbnails.id)) { - const thumbnailModel = new ThumbnailModel(pick(row.Thumbnails, [ 'id', 'type', 'filename' ])) + const thumbnailModel = new ThumbnailModel(pick(row.Thumbnails, [ 'id', 'type', 'filename' ]), buildOpts) videoModel.Thumbnails.push(thumbnailModel) thumbnailsDone.add(row.Thumbnails.id) } if (row.VideoFiles?.id && !videoFilesDone.has(row.VideoFiles.id)) { - const videoFileModel = new VideoFileModel(pick(row.VideoFiles, videoFileKeys)) + const videoFileModel = new VideoFileModel(pick(row.VideoFiles, videoFileKeys), buildOpts) videoModel.VideoFiles.push(videoFileModel) videoFilesDone.add(row.VideoFiles.id) } + + if (row.VideoStreamingPlaylists?.id && !videoStreamingPlaylistMemo[row.VideoStreamingPlaylists.id]) { + const streamingPlaylist = new VideoStreamingPlaylistModel(pick(row.VideoStreamingPlaylists, videoStreamingPlaylistKeys), buildOpts) + streamingPlaylist.VideoFiles = [] + + videoModel.VideoStreamingPlaylists.push(streamingPlaylist) + + videoStreamingPlaylistMemo[streamingPlaylist.id] = streamingPlaylist + } + + if (row.VideoStreamingPlaylists?.VideoFiles?.id && !videoFilesDone.has(row.VideoStreamingPlaylists.VideoFiles.id)) { + const streamingPlaylist = videoStreamingPlaylistMemo[row.VideoStreamingPlaylists.id] + + const videoFileModel = new VideoFileModel(pick(row.VideoStreamingPlaylists.VideoFiles, videoFileKeys), buildOpts) + streamingPlaylist.VideoFiles.push(videoFileModel) + + videoFilesDone.add(row.VideoStreamingPlaylists.VideoFiles.id) + } } return videos @@ -1609,6 +1847,7 @@ export class VideoModel extends Model { } 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) @@ -1643,7 +1882,11 @@ export class VideoModel extends Model { return Object.assign(file, { Video: this }) } - async addAndSaveThumbnail (thumbnail: MThumbnail, transaction: Transaction) { + hasWebTorrentFiles () { + return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0 + } + + async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) { thumbnail.videoId = this.id const savedThumbnail = await thumbnail.save({ transaction }) @@ -1656,20 +1899,12 @@ export class VideoModel extends Model { this.Thumbnails.push(savedThumbnail) } - generateThumbnailName () { - return this.uuid + '.jpg' - } - getMiniature () { if (Array.isArray(this.Thumbnails) === false) return undefined return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE) } - generatePreviewName () { - return this.uuid + '.jpg' - } - hasPreview () { return !!this.getPreview() } @@ -1685,7 +1920,7 @@ export class VideoModel extends Model { } getWatchStaticPath () { - return '/videos/watch/' + this.uuid + return '/w/' + this.uuid } getEmbedStaticPath () { @@ -1715,12 +1950,23 @@ export class VideoModel extends Model { return videoModelToFormattedDetailsJSON(this) } - getFormattedVideoFilesJSON (): VideoFile[] { - const { baseUrlHttp, baseUrlWs } = this.getBaseUrls() - return videoFilesModelToFormattedJSON(this, baseUrlHttp, baseUrlWs, this.VideoFiles) + getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] { + let files: VideoFile[] = [] + + if (Array.isArray(this.VideoFiles)) { + const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, includeMagnet) + files = files.concat(result) + } + + for (const p of (this.VideoStreamingPlaylists || [])) { + const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, includeMagnet) + files = files.concat(result) + } + + return files } - toActivityPubObject (this: MVideoAP): VideoTorrentObject { + toActivityPubObject (this: MVideoAP): VideoObject { return videoModelToActivityPubObject(this) } @@ -1771,12 +2017,6 @@ export class VideoModel extends Model { .catch(err => logger.warn('Cannot delete file %s.', filePath, { err })) } - removeTorrent (videoFile: MVideoFile) { - const torrentPath = getTorrentFilePath(this, videoFile) - return remove(torrentPath) - .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) - } - async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) { const directoryPath = getHLSDirectory(this, isRedundancy) @@ -1792,7 +2032,7 @@ export class VideoModel extends Model { // Remove physical files and torrents await Promise.all( - streamingPlaylistWithFiles.VideoFiles.map(file => streamingPlaylistWithFiles.removeTorrent(file)) + streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent()) ) } } @@ -1807,14 +2047,16 @@ export class VideoModel extends Model { return isPrivacyForFederation(this.privacy) } + hasStateForFederation () { + return isStateForFederation(this.state) + } + isNewVideo (newPrivacy: VideoPrivacy) { return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true } setAsRefreshed () { - this.changed('updatedAt', true) - - return this.save() + return setAsUpdated('video', this.id) } requiresAuth () { @@ -1847,53 +2089,18 @@ export class VideoModel extends Model { return false } - getBaseUrls () { - if (this.isOwned()) { - return { - baseUrlHttp: WEBSERVER.URL, - baseUrlWs: WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT - } - } - - return { - baseUrlHttp: REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host, - baseUrlWs: REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host - } - } - - getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) { - return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] - } - - getTorrentUrl (videoFile: MVideoFile, baseUrlHttp: string) { - return baseUrlHttp + STATIC_PATHS.TORRENTS + getTorrentFileName(this, videoFile) - } - - getTorrentDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) { - return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + getTorrentFileName(this, videoFile) - } - - getVideoFileUrl (videoFile: MVideoFile, baseUrlHttp: string) { - return baseUrlHttp + STATIC_PATHS.WEBSEED + getVideoFilename(this, videoFile) - } - - getVideoFileMetadataUrl (videoFile: MVideoFile, baseUrlHttp: string) { - const path = '/api/v1/videos/' - - return this.isOwned() - ? baseUrlHttp + path + this.uuid + '/metadata/' + videoFile.id - : videoFile.metadataUrl - } - - getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) { - return baseUrlHttp + STATIC_PATHS.REDUNDANCY + getVideoFilename(this, videoFile) + getBandwidthBits (videoFile: MVideoFile) { + return Math.ceil((videoFile.size * 8) / this.duration) } - getVideoFileDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) { - return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + getVideoFilename(this, videoFile) - } + getTrackerUrls () { + if (this.isOwned()) { + return [ + WEBSERVER.URL + '/tracker/announce', + WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket' + ] + } - getBandwidthBits (videoFile: MVideoFile) { - return Math.ceil((videoFile.size * 8) / this.duration) + return this.Trackers.map(t => t.url) } }