X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Fmodels%2Fvideo%2Fvideo.ts;h=6fb5ececae5b29886202f312f0f90d44f88a3b3d;hb=9a629c6efbe39dfac290347670ca41b0d7100f41;hp=27e73bbf126d18dd36987ec89991f2d219c4857c;hpb=8cd72bd37724054f8942f2fefc7aa2e60eca74cf;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 27e73bbf1..6fb5ececa 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -17,6 +17,7 @@ import { HasMany, HasOne, IFindOptions, + IIncludeOptions, Is, IsInt, IsUUID, @@ -30,15 +31,7 @@ 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 { - copyFilePromise, - createTorrentPromise, - peertubeTruncate, - renamePromise, - statPromise, - unlinkPromise, - writeFilePromise -} from '../../helpers/core-utils' +import { createTorrentPromise, peertubeTruncate } from '../../helpers/core-utils' import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' import { isBooleanValid } from '../../helpers/custom-validators/misc' import { @@ -56,6 +49,7 @@ import { generateImageFromVideoFile, getVideoFileFPS, getVideoFileResolution, tr import { logger } from '../../helpers/logger' import { getServerActor } from '../../helpers/utils' import { + ACTIVITY_PUB, API_VERSION, CONFIG, CONSTRAINTS_FIELDS, @@ -83,7 +77,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate' import { ActorModel } from '../activitypub/actor' import { AvatarModel } from '../avatar/avatar' import { ServerModel } from '../server/server' -import { buildTrigramSearchIndex, createSearchTrigramQuery, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' +import { buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils' import { TagModel } from './tag' import { VideoAbuseModel } from './video-abuse' import { VideoChannelModel } from './video-channel' @@ -93,7 +87,9 @@ import { VideoShareModel } from './video-share' import { VideoTagModel } from './video-tag' import { ScheduleVideoUpdateModel } from './schedule-video-update' import { VideoCaptionModel } from './video-caption' -import { VideosSearchQuery } from '../../../shared/models/search' +import { VideoBlacklistModel } from './video-blacklist' +import { copy, remove, rename, stat, writeFile } from 'fs-extra' +import { VideoViewModel } from './video-views' // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation const indexes: Sequelize.DefineIndexesOptions[] = [ @@ -123,40 +119,47 @@ const indexes: Sequelize.DefineIndexesOptions[] = [ ] export enum ScopeNames { - AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', + AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS', + 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_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', + WITH_BLACKLISTED = 'WITH_BLACKLISTED' +} + +type ForAPIOptions = { + ids: number[] + withFiles?: boolean } -type AvailableForListOptions = { - actorId: number, - filter?: VideoFilter, - categoryOneOf?: number[], - nsfw?: boolean, - licenceOneOf?: number[], - languageOneOf?: string[], - tagsOneOf?: string[], - tagsAllOf?: string[], - withFiles?: boolean, - accountId?: number, +type AvailableForListIDsOptions = { + actorId: number + includeLocalVideos: boolean + filter?: VideoFilter + categoryOneOf?: number[] + nsfw?: boolean + licenceOneOf?: number[] + languageOneOf?: string[] + tagsOneOf?: string[] + tagsAllOf?: string[] + withFiles?: boolean + accountId?: number videoChannelId?: number + trendingDays?: number } @Scopes({ - [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { + [ScopeNames.FOR_API]: (options: ForAPIOptions) => { 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' ], @@ -176,7 +179,6 @@ type AvailableForListOptions = { attributes: [ 'name', 'description', 'id' ], model: VideoChannelModel.unscoped(), required: true, - where: {}, include: [ { attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], @@ -198,28 +200,36 @@ type AvailableForListOptions = { ] } - // 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" ' + - 'WHERE "actor"."serverId" IS NULL OR ' + - '"actor"."id" IN (SELECT "targetActorId" FROM "actorFollow" WHERE "actorId" = 1)' + // Subquery for optimization - ')' - ) + [Sequelize.Op.any]: options.ids + } + }, + include: [ videoChannelInclude ] + } + + if (options.withFiles === true) { + query.include.push({ + model: VideoFileModel.unscoped(), + required: true + }) + } + + return query + }, + [ScopeNames.AVAILABLE_FOR_LIST_IDS]: (options: AvailableForListIDsOptions) => { + const query: IFindOptions = { + attributes: [ 'id' ], + where: { + id: { + [Sequelize.Op.and]: [ + { + [ Sequelize.Op.notIn ]: Sequelize.literal( + '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")' + ) + } + ] }, // Always list public videos privacy: VideoPrivacy.PUBLIC, @@ -236,13 +246,87 @@ type AvailableForListOptions = { } ] }, - include: [ videoChannelInclude ] + include: [ ] } - if (options.withFiles === true) { - query.include.push({ - model: VideoFileModel.unscoped(), + if (options.filter || options.accountId || options.videoChannelId) { + const videoChannelInclude: IIncludeOptions = { + attributes: [], + model: VideoChannelModel.unscoped(), required: true + } + + if (options.videoChannelId) { + videoChannelInclude.where = { + id: options.videoChannelId + } + } + + if (options.filter || options.accountId) { + const accountInclude: IIncludeOptions = { + attributes: [], + model: AccountModel.unscoped(), + required: true + } + + if (options.filter) { + accountInclude.include = [ + { + attributes: [], + model: ActorModel.unscoped(), + required: true, + where: VideoModel.buildActorWhereWithFilter(options.filter) + } + ] + } + + if (options.accountId) { + accountInclude.where = { id: options.accountId } + } + + videoChannelInclude.include = [ accountInclude ] + } + + query.include.push(videoChannelInclude) + } + + if (options.actorId) { + let localVideosReq = '' + if (options.includeLocalVideos === true) { + localVideosReq = ' UNION ALL ' + + '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" ' + + 'WHERE "actor"."serverId" IS NULL' + } + + // Force actorId to be a number to avoid SQL injections + const actorIdNumber = parseInt(options.actorId.toString(), 10) + query.where['id'][Sequelize.Op.and].push({ + [ 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 ALL ' + + '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" ' + + 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' + + 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + + localVideosReq + + ')' + ) + }) + } + + if (options.withFiles === true) { + query.where['id'][Sequelize.Op.and].push({ + [ Sequelize.Op.in ]: Sequelize.literal( + '(SELECT "videoId" FROM "videoFile")' + ) }) } @@ -254,24 +338,28 @@ type AvailableForListOptions = { } if (options.tagsOneOf) { - query.where['id'][Sequelize.Op.in] = Sequelize.literal( - '(' + + query.where['id'][Sequelize.Op.and].push({ + [Sequelize.Op.in]: Sequelize.literal( + '(' + 'SELECT "videoId" FROM "videoTag" ' + 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + 'WHERE "tag"."name" IN (' + createTagsIn(options.tagsOneOf) + ')' + - ')' - ) + ')' + ) + }) } if (options.tagsAllOf) { - query.where['id'][Sequelize.Op.in] = Sequelize.literal( + query.where['id'][Sequelize.Op.and].push({ + [Sequelize.Op.in]: Sequelize.literal( '(' + - 'SELECT "videoId" FROM "videoTag" ' + - 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + - 'WHERE "tag"."name" IN (' + createTagsIn(options.tagsAllOf) + ')' + - 'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + options.tagsAllOf.length + + 'SELECT "videoId" FROM "videoTag" ' + + 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + + 'WHERE "tag"."name" IN (' + createTagsIn(options.tagsAllOf) + ')' + + 'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + options.tagsAllOf.length + ')' - ) + ) + }) } } @@ -297,16 +385,19 @@ type AvailableForListOptions = { } } - if (options.accountId) { - accountInclude.where = { - id: options.accountId - } - } + if (options.trendingDays) { + query.include.push({ + attributes: [], + model: VideoViewModel, + required: false, + where: { + startDate: { + [ Sequelize.Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * options.trendingDays) + } + } + }) - if (options.videoChannelId) { - videoChannelInclude.where = { - id: options.videoChannelId - } + query.subQuery = false } return query @@ -366,11 +457,20 @@ type AvailableForListOptions = { [ScopeNames.WITH_TAGS]: { include: [ () => TagModel ] }, + [ScopeNames.WITH_BLACKLISTED]: { + include: [ + { + attributes: [ 'id', 'reason' ], + model: () => VideoBlacklistModel, + required: false + } + ] + }, [ScopeNames.WITH_FILES]: { include: [ { model: () => VideoFileModel.unscoped(), - required: true + required: false } ] }, @@ -565,6 +665,16 @@ export class VideoModel extends Model { }) VideoComments: VideoCommentModel[] + @HasMany(() => VideoViewModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade', + hooks: true + }) + VideoViews: VideoViewModel[] + @HasOne(() => ScheduleVideoUpdateModel, { foreignKey: { name: 'videoId', @@ -574,6 +684,15 @@ export class VideoModel extends Model { }) ScheduleVideoUpdate: ScheduleVideoUpdateModel + @HasOne(() => VideoBlacklistModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + VideoBlacklist: VideoBlacklistModel + @HasMany(() => VideoCaptionModel, { foreignKey: { name: 'videoId', @@ -600,8 +719,6 @@ export class VideoModel extends Model { }) as VideoChannelModel } - logger.debug('Sending delete of video %s.', instance.url) - return sendDeleteVideo(instance, options.transaction) } @@ -612,7 +729,7 @@ export class VideoModel extends Model { static async removeFiles (instance: VideoModel) { const tasks: Promise[] = [] - logger.debug('Removing files of video %s.', instance.url) + logger.info('Removing files of video %s.', instance.url) tasks.push(instance.removeThumbnail()) @@ -633,7 +750,7 @@ export class VideoModel extends Model { // 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 }) + logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err }) }) return undefined @@ -663,7 +780,7 @@ 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 + ')') @@ -750,11 +867,11 @@ export class VideoModel extends Model { }) } - static listUserVideosForApi (accountId: number, start: number, count: number, sort: string, hideNSFW: boolean, withFiles = false) { + static listUserVideosForApi (accountId: number, start: number, count: number, sort: string, withFiles = false) { const query: IFindOptions = { offset: start, limit: count, - order: getSort(sort), + order: getVideoSort(sort), include: [ { model: VideoChannelModel, @@ -772,6 +889,10 @@ export class VideoModel extends Model { { model: ScheduleVideoUpdateModel, required: false + }, + { + model: VideoBlacklistModel, + required: false } ] } @@ -783,12 +904,6 @@ export class VideoModel extends Model { }) } - if (hideNSFW === true) { - query.where = { - nsfw: false - } - } - return VideoModel.findAndCountAll(query).then(({ rows, count }) => { return { data: rows, @@ -802,6 +917,7 @@ export class VideoModel extends Model { count: number, sort: string, nsfw: boolean, + includeLocalVideos: boolean, withFiles: boolean, categoryOneOf?: number[], licenceOneOf?: number[], @@ -810,45 +926,48 @@ export class VideoModel extends Model { tagsAllOf?: string[], filter?: VideoFilter, accountId?: number, - videoChannelId?: number + videoChannelId?: number, + actorId?: number + trendingDays?: number }) { - const query = { + const query: IFindOptions = { offset: options.start, limit: options.count, - order: getSort(options.sort) + order: getVideoSort(options.sort) } - const serverActor = await getServerActor() - const scopes = { - method: [ - ScopeNames.AVAILABLE_FOR_LIST, { - actorId: serverActor.id, - nsfw: options.nsfw, - categoryOneOf: options.categoryOneOf, - licenceOneOf: options.licenceOneOf, - languageOneOf: options.languageOneOf, - tagsOneOf: options.tagsOneOf, - tagsAllOf: options.tagsAllOf, - filter: options.filter, - withFiles: options.withFiles, - accountId: options.accountId, - videoChannelId: options.videoChannelId - } as AvailableForListOptions - ] + let trendingDays: number + if (options.sort.endsWith('trending')) { + trendingDays = CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS + + query.group = 'VideoModel.id' } - return VideoModel.scope(scopes) - .findAndCountAll(query) - .then(({ rows, count }) => { - return { - data: rows, - total: count - } - }) + // actorId === null has a meaning, so just check undefined + const actorId = options.actorId !== undefined ? options.actorId : (await getServerActor()).id + + const queryOptions = { + actorId, + nsfw: options.nsfw, + categoryOneOf: options.categoryOneOf, + licenceOneOf: options.licenceOneOf, + languageOneOf: options.languageOneOf, + tagsOneOf: options.tagsOneOf, + tagsAllOf: options.tagsAllOf, + filter: options.filter, + withFiles: options.withFiles, + accountId: options.accountId, + videoChannelId: options.videoChannelId, + includeLocalVideos: options.includeLocalVideos, + trendingDays + } + + return VideoModel.getAvailableForApi(query, queryOptions) } static async searchAndPopulateAccountAndServer (options: { - search: string + includeLocalVideos: boolean + search?: string start?: number count?: number sort?: string @@ -883,47 +1002,70 @@ export class VideoModel extends Model { whereAnd.push({ duration: durationRange }) } - whereAnd.push(createSearchTrigramQuery('VideoModel.name', options.search)) + const attributesInclude = [] + const escapedSearch = VideoModel.sequelize.escape(options.search) + const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%') + if (options.search) { + whereAnd.push( + { + id: { + [ Sequelize.Op.in ]: Sequelize.literal( + '(' + + 'SELECT "video"."id" FROM "video" ' + + 'WHERE ' + + 'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' + + 'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' + + 'UNION ALL ' + + 'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' + + 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + + 'WHERE "tag"."name" = ' + escapedSearch + + ')' + ) + } + } + ) + + attributesInclude.push(createSimilarityAttribute('VideoModel.name', options.search)) + } + + // Cannot search on similarity if we don't have a search + if (!options.search) { + attributesInclude.push( + Sequelize.literal('0 as similarity') + ) + } const query: IFindOptions = { attributes: { - include: [ createSimilarityAttribute('VideoModel.name', options.search) ] + include: attributesInclude }, offset: options.start, limit: options.count, - order: getSort(options.sort), + order: getVideoSort(options.sort), where: { [ Sequelize.Op.and ]: whereAnd } } const serverActor = await getServerActor() - const scopes = { - method: [ - ScopeNames.AVAILABLE_FOR_LIST, { - actorId: serverActor.id, - nsfw: options.nsfw, - categoryOneOf: options.categoryOneOf, - licenceOneOf: options.licenceOneOf, - languageOneOf: options.languageOneOf, - tagsOneOf: options.tagsOneOf, - tagsAllOf: options.tagsAllOf - } as AvailableForListOptions - ] + const queryOptions = { + actorId: serverActor.id, + includeLocalVideos: options.includeLocalVideos, + nsfw: options.nsfw, + categoryOneOf: options.categoryOneOf, + licenceOneOf: options.licenceOneOf, + languageOneOf: options.languageOneOf, + tagsOneOf: options.tagsOneOf, + tagsAllOf: options.tagsAllOf } - return VideoModel.scope(scopes) - .findAndCountAll(query) - .then(({ rows, count }) => { - return { - data: rows, - total: count - } - }) + return VideoModel.getAvailableForApi(query, queryOptions) } - static load (id: number) { - return VideoModel.findById(id) + static load (id: number, t?: Sequelize.Transaction) { + const options = t ? { transaction: t } : undefined + + return VideoModel.findById(id, options) } static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) { @@ -938,28 +1080,19 @@ export class VideoModel extends Model { return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) } - static loadByUUIDOrURLAndPopulateAccount (uuid: string, url: string, t?: Sequelize.Transaction) { - const query: IFindOptions = { - where: { - [Sequelize.Op.or]: [ - { uuid }, - { url } - ] - } - } - - if (t !== undefined) query.transaction = t - - return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) - } - static loadAndPopulateAccountAndServerAndTags (id: number) { const options = { order: [ [ 'Tags', 'name', 'ASC' ] ] } return VideoModel - .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_SCHEDULED_UPDATE ]) + .scope([ + ScopeNames.WITH_TAGS, + ScopeNames.WITH_BLACKLISTED, + ScopeNames.WITH_FILES, + ScopeNames.WITH_ACCOUNT_DETAILS, + ScopeNames.WITH_SCHEDULED_UPDATE + ]) .findById(id, options) } @@ -985,7 +1118,13 @@ export class VideoModel extends Model { } return VideoModel - .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_SCHEDULED_UPDATE ]) + .scope([ + ScopeNames.WITH_TAGS, + ScopeNames.WITH_BLACKLISTED, + ScopeNames.WITH_FILES, + ScopeNames.WITH_ACCOUNT_DETAILS, + ScopeNames.WITH_SCHEDULED_UPDATE + ]) .findOne(options) } @@ -1012,6 +1151,38 @@ export class VideoModel extends Model { } } + static incrementViews (id: number, views: number) { + return VideoModel.increment('views', { + by: views, + where: { + id + } + }) + } + + // threshold corresponds to how many video the field should have to be returned + static getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) { + const query: IFindOptions = { + attributes: [ field ], + limit: count, + group: field, + having: Sequelize.where(Sequelize.fn('COUNT', Sequelize.col(field)), { + [Sequelize.Op.gte]: threshold + }) as any, // FIXME: typings + where: { + [field]: { + [Sequelize.Op.not]: null + }, + privacy: VideoPrivacy.PUBLIC, + state: VideoState.PUBLISHED + }, + order: [ this.sequelize.random() ] + } + + return VideoModel.findAll(query) + .then(rows => rows.map(r => r[field])) + } + private static buildActorWhereWithFilter (filter?: VideoFilter) { if (filter && filter === 'local') { return { @@ -1022,6 +1193,40 @@ export class VideoModel extends Model { return {} } + private static async getAvailableForApi (query: IFindOptions, options: AvailableForListIDsOptions) { + const idsScope = { + method: [ + ScopeNames.AVAILABLE_FOR_LIST_IDS, options + ] + } + + const { count, rows: rowsId } = await VideoModel.scope(idsScope).findAndCountAll(query) + const ids = rowsId.map(r => r.id) + + if (ids.length === 0) return { data: [], total: count } + + const apiScope = { + method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ] + } + + const secondQuery = { + offset: 0, + limit: query.limit, + attributes: query.attributes, + order: [ // Keep original order + Sequelize.literal( + ids.map(id => `"VideoModel".id = ${id} DESC`).join(', ') + ) + ] + } + const rows = await VideoModel.scope(apiScope).findAll(secondQuery) + + return { + data: rows, + total: count + } + } + private static getCategoryLabel (id: number) { return VIDEO_CATEGORIES[id] || 'Misc' } @@ -1118,7 +1323,7 @@ export class VideoModel extends Model { const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile)) logger.info('Creating torrent %s.', filePath) - await writeFilePromise(filePath, torrent) + await writeFile(filePath, torrent) const parsedTorrent = parseTorrent(torrent) videoFile.infoHash = parsedTorrent.infoHash @@ -1140,7 +1345,8 @@ export class VideoModel extends Model { additionalAttributes: { state?: boolean, waitTranscoding?: boolean, - scheduledUpdate?: boolean + scheduledUpdate?: boolean, + blacklistInfo?: boolean } }): Video { const formattedAccount = this.VideoChannel.Account.toFormattedJSON() @@ -1217,6 +1423,11 @@ export class VideoModel extends Model { privacy: this.ScheduleVideoUpdate.privacy || undefined } } + + if (options.additionalAttributes.blacklistInfo === true) { + videoObject.blacklisted = !!this.VideoBlacklist + videoObject.blacklistedReason = this.VideoBlacklist ? this.VideoBlacklist.reason : null + } } return videoObject @@ -1225,7 +1436,8 @@ export class VideoModel extends Model { toFormattedDetailsJSON (): VideoDetails { const formattedJson = this.toFormattedJSON({ additionalAttributes: { - scheduledUpdate: true + scheduledUpdate: true, + blacklistInfo: true } }) @@ -1317,22 +1529,23 @@ export class VideoModel extends Model { type: 'Link', mimeType: VIDEO_EXT_MIMETYPE[file.extname], href: this.getVideoFileUrl(file, baseUrlHttp), - width: file.resolution, - size: file.size + height: file.resolution, + size: file.size, + fps: file.fps }) url.push({ type: 'Link', mimeType: 'application/x-bittorrent', href: this.getTorrentUrl(file, baseUrlHttp), - width: file.resolution + height: file.resolution }) url.push({ type: 'Link', mimeType: 'application/x-bittorrent;x-scheme-handler/magnet', href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs), - width: file.resolution + height: file.resolution }) } @@ -1420,14 +1633,14 @@ export class VideoModel extends Model { await transcode(transcodeOptions) try { - await unlinkPromise(videoInputPath) + await remove(videoInputPath) // Important to do this before getVideoFilename() to take in account the new file extension inputVideoFile.set('extname', newExtname) const videoOutputPath = this.getVideoFilePath(inputVideoFile) - await renamePromise(videoTranscodedPath, videoOutputPath) - const stats = await statPromise(videoOutputPath) + await rename(videoTranscodedPath, videoOutputPath) + const stats = await stat(videoOutputPath) const fps = await getVideoFileFPS(videoOutputPath) inputVideoFile.set('size', stats.size) @@ -1468,7 +1681,7 @@ export class VideoModel extends Model { await transcode(transcodeOptions) - const stats = await statPromise(videoOutputPath) + const stats = await stat(videoOutputPath) const fps = await getVideoFileFPS(videoOutputPath) newVideoFile.set('size', stats.size) @@ -1483,7 +1696,7 @@ export class VideoModel extends Model { async importVideoFile (inputFilePath: string) { const { videoFileResolution } = await getVideoFileResolution(inputFilePath) - const { size } = await statPromise(inputFilePath) + const { size } = await stat(inputFilePath) const fps = await getVideoFileFPS(inputFilePath) let updatedVideoFile = new VideoFileModel({ @@ -1512,7 +1725,7 @@ export class VideoModel extends Model { } const outputPath = this.getVideoFilePath(updatedVideoFile) - await copyFilePromise(inputFilePath, outputPath) + await copy(inputFilePath, outputPath) await this.createTorrentAndSetInfoHash(updatedVideoFile) @@ -1533,22 +1746,26 @@ export class VideoModel extends Model { removeThumbnail () { const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) - return unlinkPromise(thumbnailPath) + return remove(thumbnailPath) + .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err })) } removePreview () { - // Same name than video thumbnail - return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName()) + const previewPath = join(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName()) + return remove(previewPath) + .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err })) } removeFile (videoFile: VideoFileModel) { const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile)) - return unlinkPromise(filePath) + return remove(filePath) + .catch(err => logger.warn('Cannot delete file %s.', filePath, { err })) } removeTorrent (videoFile: VideoFileModel) { const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile)) - return unlinkPromise(torrentPath) + return remove(torrentPath) + .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) } getActivityStreamDuration () { @@ -1556,6 +1773,17 @@ export class VideoModel extends Model { return 'PT' + this.duration + 'S' } + isOutdated () { + if (this.isOwned()) return false + + const now = Date.now() + const createdAtTime = this.createdAt.getTime() + const updatedAtTime = this.updatedAt.getTime() + + return (now - createdAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL && + (now - updatedAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL + } + private getBaseUrls () { let baseUrlHttp let baseUrlWs