X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Fmodels%2Fvideo%2Fvideo.ts;h=48232fb7d74f75094fa30ac93577cca236cea92e;hb=288fe38590788fb737eb4280309846c76c51e7c3;hp=a956da16e4b78580ea64096a7e83daa65182ff8b;hpb=687d638c2bee0d223f206168173b1b95adbad983;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/models/video/video.ts b/server/models/video/video.ts index a956da16e..48232fb7d 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -24,21 +24,14 @@ import { Model, Scopes, Table, - UpdatedAt + UpdatedAt, + IIncludeOptions } 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 { - 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 { @@ -95,6 +88,8 @@ import { VideoTagModel } from './video-tag' import { ScheduleVideoUpdateModel } from './schedule-video-update' import { VideoCaptionModel } from './video-caption' import { VideoBlacklistModel } from './video-blacklist' +import { copy, remove, rename, stat, writeFile } from 'fs-extra' +import { immutableAssign } from '../../tests/utils' // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation const indexes: Sequelize.DefineIndexesOptions[] = [ @@ -124,7 +119,8 @@ 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', @@ -132,34 +128,37 @@ export enum ScopeNames { WITH_BLACKLISTED = 'WITH_BLACKLISTED' } -type AvailableForListOptions = { - actorId: number, - includeLocalVideos: boolean, - filter?: VideoFilter, - categoryOneOf?: number[], - nsfw?: boolean, - licenceOneOf?: number[], - languageOneOf?: string[], - tagsOneOf?: string[], - tagsAllOf?: string[], - withFiles?: boolean, - accountId?: number, +type ForAPIOptions = { + ids: number[] + withFiles?: boolean +} + +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 } @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' ], @@ -179,7 +178,6 @@ type AvailableForListOptions = { attributes: [ 'name', 'description', 'id' ], model: VideoChannelModel.unscoped(), required: true, - where: {}, include: [ { attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], @@ -201,8 +199,27 @@ type AvailableForListOptions = { ] } - // FIXME: It would be more efficient to use a CTE so we join AFTER the filters, but sequelize does not support it... const query: IFindOptions = { + where: { + id: { + [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.notIn]: Sequelize.literal( @@ -224,7 +241,48 @@ type AvailableForListOptions = { } ] }, - include: [ videoChannelInclude ] + include: [ ] + } + + 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) { @@ -242,17 +300,17 @@ type AvailableForListOptions = { const actorIdNumber = parseInt(options.actorId.toString(), 10) query.where['id'][ 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 + + '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 + ')' ) } @@ -315,18 +373,6 @@ type AvailableForListOptions = { } } - if (options.accountId) { - accountInclude.where = { - id: options.accountId - } - } - - if (options.videoChannelId) { - videoChannelInclude.where = { - id: options.videoChannelId - } - } - return query }, [ScopeNames.WITH_ACCOUNT_DETAILS]: { @@ -855,33 +901,22 @@ export class VideoModel extends Model { // actorId === null has a meaning, so just check undefined const actorId = options.actorId !== undefined ? options.actorId : (await getServerActor()).id - const scopes = { - method: [ - ScopeNames.AVAILABLE_FOR_LIST, { - 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 - } as AvailableForListOptions - ] + 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 } - return VideoModel.scope(scopes) - .findAndCountAll(query) - .then(({ rows, count }) => { - return { - data: rows, - total: count - } - }) + return VideoModel.getAvailableForApi(query, queryOptions) } static async searchAndPopulateAccountAndServer (options: { @@ -967,29 +1002,18 @@ export class VideoModel extends Model { } const serverActor = await getServerActor() - const scopes = { - method: [ - ScopeNames.AVAILABLE_FOR_LIST, { - actorId: serverActor.id, - includeLocalVideos: options.includeLocalVideos, - 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, t?: Sequelize.Transaction) { @@ -1081,6 +1105,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 { @@ -1091,6 +1147,29 @@ 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 rows = await VideoModel.scope(apiScope).findAll(immutableAssign(query, { offset: 0 })) + + return { + data: rows, + total: count + } + } + private static getCategoryLabel (id: number) { return VIDEO_CATEGORIES[id] || 'Misc' } @@ -1187,7 +1266,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 @@ -1497,14 +1576,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) @@ -1545,7 +1624,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) @@ -1560,7 +1639,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({ @@ -1589,7 +1668,7 @@ export class VideoModel extends Model { } const outputPath = this.getVideoFilePath(updatedVideoFile) - await copyFilePromise(inputFilePath, outputPath) + await copy(inputFilePath, outputPath) await this.createTorrentAndSetInfoHash(updatedVideoFile) @@ -1610,25 +1689,25 @@ 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 () { const previewPath = join(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName()) - return unlinkPromise(previewPath) + 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 })) }