X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;ds=sidebyside;f=server%2Fmodels%2Fvideo%2Fvideo.ts;h=695990b17ba1d07ebac52425d75ac75c0d8aec3c;hb=2d3741d6d92e9bd1f41694c7442a6d1da434e1f2;hp=459fcb31e5ddb351cd749a9cd625c1eeaee667b7;hpb=fbad87b0472f574409f7aa3ae7f8b54927d0cdd6;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 459fcb31e..695990b17 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -30,15 +30,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 +48,7 @@ import { generateImageFromVideoFile, getVideoFileFPS, getVideoFileResolution, tr import { logger } from '../../helpers/logger' import { getServerActor } from '../../helpers/utils' import { + ACTIVITY_PUB, API_VERSION, CONFIG, CONSTRAINTS_FIELDS, @@ -93,6 +86,8 @@ import { VideoShareModel } from './video-share' 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' // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation const indexes: Sequelize.DefineIndexesOptions[] = [ @@ -126,11 +121,13 @@ export enum ScopeNames { 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 AvailableForListOptions = { actorId: number, + includeLocalVideos: boolean, filter?: VideoFilter, categoryOneOf?: number[], nsfw?: boolean, @@ -197,35 +194,12 @@ type AvailableForListOptions = { ] } - // Force actorId to be a number to avoid SQL injections - const actorIdNumber = parseInt(options.actorId.toString(), 10) - // 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.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 ' + - ' 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 + - ')' ) }, // Always list public videos @@ -246,6 +220,36 @@ type AvailableForListOptions = { include: [ 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.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.include.push({ model: VideoFileModel.unscoped(), @@ -373,6 +377,15 @@ type AvailableForListOptions = { [ScopeNames.WITH_TAGS]: { include: [ () => TagModel ] }, + [ScopeNames.WITH_BLACKLISTED]: { + include: [ + { + attributes: [ 'id', 'reason' ], + model: () => VideoBlacklistModel, + required: false + } + ] + }, [ScopeNames.WITH_FILES]: { include: [ { @@ -581,6 +594,15 @@ export class VideoModel extends Model { }) ScheduleVideoUpdate: ScheduleVideoUpdateModel + @HasOne(() => VideoBlacklistModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + VideoBlacklist: VideoBlacklistModel + @HasMany(() => VideoCaptionModel, { foreignKey: { name: 'videoId', @@ -755,7 +777,7 @@ 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, @@ -777,6 +799,10 @@ export class VideoModel extends Model { { model: ScheduleVideoUpdateModel, required: false + }, + { + model: VideoBlacklistModel, + required: false } ] } @@ -788,12 +814,6 @@ export class VideoModel extends Model { }) } - if (hideNSFW === true) { - query.where = { - nsfw: false - } - } - return VideoModel.findAndCountAll(query).then(({ rows, count }) => { return { data: rows, @@ -807,6 +827,7 @@ export class VideoModel extends Model { count: number, sort: string, nsfw: boolean, + includeLocalVideos: boolean, withFiles: boolean, categoryOneOf?: number[], licenceOneOf?: number[], @@ -815,7 +836,8 @@ export class VideoModel extends Model { tagsAllOf?: string[], filter?: VideoFilter, accountId?: number, - videoChannelId?: number + videoChannelId?: number, + actorId?: number }) { const query = { offset: options.start, @@ -823,11 +845,13 @@ export class VideoModel extends Model { order: getSort(options.sort) } - const serverActor = await getServerActor() + // 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: serverActor.id, + actorId, nsfw: options.nsfw, categoryOneOf: options.categoryOneOf, licenceOneOf: options.licenceOneOf, @@ -837,7 +861,8 @@ export class VideoModel extends Model { filter: options.filter, withFiles: options.withFiles, accountId: options.accountId, - videoChannelId: options.videoChannelId + videoChannelId: options.videoChannelId, + includeLocalVideos: options.includeLocalVideos } as AvailableForListOptions ] } @@ -853,6 +878,7 @@ export class VideoModel extends Model { } static async searchAndPopulateAccountAndServer (options: { + includeLocalVideos: boolean search?: string start?: number count?: number @@ -897,7 +923,8 @@ export class VideoModel extends Model { id: { [ Sequelize.Op.in ]: Sequelize.literal( '(' + - 'SELECT "video"."id" FROM "video" WHERE ' + + '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 ' + @@ -937,6 +964,7 @@ export class VideoModel extends Model { method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId: serverActor.id, + includeLocalVideos: options.includeLocalVideos, nsfw: options.nsfw, categoryOneOf: options.categoryOneOf, licenceOneOf: options.licenceOneOf, @@ -957,8 +985,10 @@ export class VideoModel extends Model { }) } - 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) { @@ -973,28 +1003,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) } @@ -1020,7 +1041,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) } @@ -1047,6 +1074,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 { @@ -1153,7 +1212,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 @@ -1175,7 +1234,8 @@ export class VideoModel extends Model { additionalAttributes: { state?: boolean, waitTranscoding?: boolean, - scheduledUpdate?: boolean + scheduledUpdate?: boolean, + blacklistInfo?: boolean } }): Video { const formattedAccount = this.VideoChannel.Account.toFormattedJSON() @@ -1252,6 +1312,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 @@ -1260,7 +1325,8 @@ export class VideoModel extends Model { toFormattedDetailsJSON (): VideoDetails { const formattedJson = this.toFormattedJSON({ additionalAttributes: { - scheduledUpdate: true + scheduledUpdate: true, + blacklistInfo: true } }) @@ -1352,22 +1418,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 }) } @@ -1455,14 +1522,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) @@ -1503,7 +1570,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) @@ -1518,7 +1585,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({ @@ -1547,7 +1614,7 @@ export class VideoModel extends Model { } const outputPath = this.getVideoFilePath(updatedVideoFile) - await copyFilePromise(inputFilePath, outputPath) + await copy(inputFilePath, outputPath) await this.createTorrentAndSetInfoHash(updatedVideoFile) @@ -1568,22 +1635,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 () { @@ -1591,6 +1662,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