X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Fmodels%2Fvideo%2Fvideo-playlist.ts;h=630684a88350d04f5ffe9d20026c5ecdf5622144;hb=3c10840fa90fc88fc98e8169faf4745ff6c80893;hp=ef87a7ee9586f757d7dc6ca9a67d9c1d8c7a3550;hpb=c06af5012ecc925ca924e6e20db3a1d909b1148e;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index ef87a7ee9..630684a88 100644 --- a/server/models/video/video-playlist.ts +++ b/server/models/video/video-playlist.ts @@ -1,3 +1,5 @@ +import { join } from 'path' +import { FindOptions, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' import { AllowNull, BelongsTo, @@ -13,17 +15,23 @@ import { Model, Scopes, Table, - UpdatedAt, - Sequelize + UpdatedAt } from 'sequelize-typescript' +import { buildUUID, uuidToShort } from '@server/helpers/uuid' +import { MAccountId, MChannelId } from '@server/types/models' +import { AttributesOnly, buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils' +import { ActivityIconObject } from '../../../shared/models/activitypub/objects' +import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' -import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, isOutdated, throwIfNotValid, createSimilarityAttribute } from '../utils' +import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model' +import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model' +import { activityPubCollectionPagination } from '../../helpers/activitypub' +import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' import { isVideoPlaylistDescriptionValid, isVideoPlaylistNameValid, isVideoPlaylistPrivacyValid } from '../../helpers/custom-validators/video-playlists' -import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' import { ACTIVITY_PUB, CONSTRAINTS_FIELDS, @@ -33,26 +41,30 @@ import { VIDEO_PLAYLIST_TYPES, WEBSERVER } from '../../initializers/constants' -import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model' -import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' -import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' -import { join } from 'path' -import { VideoPlaylistElementModel } from './video-playlist-element' -import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' -import { activityPubCollectionPagination } from '../../helpers/activitypub' -import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model' -import { ThumbnailModel } from './thumbnail' -import { ActivityIconObject } from '../../../shared/models/activitypub/objects' -import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize' -import * as Bluebird from 'bluebird' +import { MThumbnail } from '../../types/models/video/thumbnail' import { - MVideoPlaylistAccountThumbnail, MVideoPlaylistAP, + MVideoPlaylistAccountThumbnail, + MVideoPlaylistAP, MVideoPlaylistFormattable, MVideoPlaylistFull, MVideoPlaylistFullSummary, MVideoPlaylistIdWithElements -} from '../../typings/models/video/video-playlist' -import { MThumbnail } from '../../typings/models/video/thumbnail' +} from '../../types/models/video/video-playlist' +import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' +import { ActorModel } from '../actor/actor' +import { setAsUpdated } from '../shared' +import { + buildServerIdsFollowedBy, + buildTrigramSearchIndex, + buildWhereIdOrUUID, + createSimilarityAttribute, + getPlaylistSort, + isOutdated, + throwIfNotValid +} from '../utils' +import { ThumbnailModel } from './thumbnail' +import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' +import { VideoPlaylistElementModel } from './video-playlist-element' enum ScopeNames { AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', @@ -64,16 +76,23 @@ enum ScopeNames { } type AvailableForListOptions = { - followerActorId: number + followerActorId?: number type?: VideoPlaylistType accountId?: number videoChannelId?: number - privateAndUnlisted?: boolean, + listMyPlaylists?: boolean search?: string + host?: string + uuids?: string[] + withVideos?: boolean +} + +function getVideoLengthSelect () { + return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"' } @Scopes(() => ({ - [ ScopeNames.WITH_THUMBNAIL ]: { + [ScopeNames.WITH_THUMBNAIL]: { include: [ { model: ThumbnailModel, @@ -81,17 +100,17 @@ type AvailableForListOptions = { } ] }, - [ ScopeNames.WITH_VIDEOS_LENGTH ]: { + [ScopeNames.WITH_VIDEOS_LENGTH]: { attributes: { include: [ [ - literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'), + literal(`(${getVideoLengthSelect()})`), 'videosLength' ] ] } } as FindOptions, - [ ScopeNames.WITH_ACCOUNT ]: { + [ScopeNames.WITH_ACCOUNT]: { include: [ { model: AccountModel, @@ -99,7 +118,7 @@ type AvailableForListOptions = { } ] }, - [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY ]: { + [ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY]: { include: [ { model: AccountModel.scope(AccountScopeNames.SUMMARY), @@ -111,7 +130,7 @@ type AvailableForListOptions = { } ] }, - [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL ]: { + [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: { include: [ { model: AccountModel, @@ -123,28 +142,45 @@ type AvailableForListOptions = { } ] }, - [ ScopeNames.AVAILABLE_FOR_LIST ]: (options: AvailableForListOptions) => { - // Only list local playlists OR playlists that are on an instance followed by actorId - const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId) - const whereActor = { - [ Op.or ]: [ + [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { + const whereAnd: WhereOptions[] = [] + + const whereServer = options.host && options.host !== WEBSERVER.HOST + ? { host: options.host } + : undefined + + let whereActor: WhereOptions = {} + + if (options.host === WEBSERVER.HOST) { + whereActor = { + [Op.and]: [ { serverId: null } ] + } + } + + if (options.listMyPlaylists !== true) { + whereAnd.push({ + privacy: VideoPlaylistPrivacy.PUBLIC + }) + + // Only list local playlists + const whereActorOr: WhereOptions[] = [ { serverId: null - }, - { - serverId: { - [ Op.in ]: literal(inQueryInstanceFollow) - } } ] - } - const whereAnd: WhereOptions[] = [] + // … OR playlists that are on an instance followed by actorId + if (options.followerActorId) { + const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId) - if (options.privateAndUnlisted !== true) { - whereAnd.push({ - privacy: VideoPlaylistPrivacy.PUBLIC - }) + whereActorOr.push({ + serverId: { + [Op.in]: literal(inQueryInstanceFollow) + } + }) + } + + Object.assign(whereActor, { [Op.or]: whereActorOr }) } if (options.accountId) { @@ -165,20 +201,36 @@ type AvailableForListOptions = { }) } + if (options.uuids) { + whereAnd.push({ + uuid: { + [Op.in]: options.uuids + } + }) + } + + if (options.withVideos === true) { + whereAnd.push( + literal(`(${getVideoLengthSelect()}) != 0`) + ) + } + + let attributesInclude: any[] = [ literal('0 as similarity') ] + if (options.search) { const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search) const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%') + attributesInclude = [ createSimilarityAttribute('VideoPlaylistModel.name', options.search) ] + whereAnd.push({ - id: { - [ Op.in ]: Sequelize.literal( - '(' + - 'SELECT "videoPlaylist"."id" FROM "videoPlaylist" ' + - 'WHERE ' + - 'lower(immutable_unaccent("videoPlaylist"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' + - 'lower(immutable_unaccent("videoPlaylist"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' + - ')' + [Op.or]: [ + Sequelize.literal( + 'lower(immutable_unaccent("VideoPlaylistModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))' + ), + Sequelize.literal( + 'lower(immutable_unaccent("VideoPlaylistModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' ) - } + ] }) } @@ -186,15 +238,16 @@ type AvailableForListOptions = { [Op.and]: whereAnd } - const accountScope = { - method: [ AccountScopeNames.SUMMARY, { whereActor } as SummaryOptions ] - } - return { + attributes: { + include: attributesInclude + }, where, include: [ { - model: AccountModel.scope(accountScope), + model: AccountModel.scope({ + method: [ AccountScopeNames.SUMMARY, { whereActor, whereServer } as SummaryOptions ] + }), required: true }, { @@ -209,6 +262,8 @@ type AvailableForListOptions = { @Table({ tableName: 'videoPlaylist', indexes: [ + buildTrigramSearchIndex('video_playlist_name_trigram', 'name'), + { fields: [ 'ownerAccountId' ] }, @@ -221,7 +276,7 @@ type AvailableForListOptions = { } ] }) -export class VideoPlaylistModel extends Model { +export class VideoPlaylistModel extends Model>> { @CreatedAt createdAt: Date @@ -235,7 +290,7 @@ export class VideoPlaylistModel extends Model { @AllowNull(true) @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true)) - @Column + @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.DESCRIPTION.max)) description: string @AllowNull(false) @@ -302,21 +357,15 @@ export class VideoPlaylistModel extends Model { }) Thumbnail: ThumbnailModel - static listForApi (options: { - followerActorId: number - start: number, - count: number, - sort: string, - type?: VideoPlaylistType, - accountId?: number, - videoChannelId?: number, - privateAndUnlisted?: boolean, - search?: string + static listForApi (options: AvailableForListOptions & { + start: number + count: number + sort: string }) { const query = { offset: options.start, limit: options.count, - order: getSort(options.sort) + order: getPlaylistSort(options.sort) } const scopes: (string | ScopeOptions)[] = [ @@ -324,12 +373,9 @@ export class VideoPlaylistModel extends Model { method: [ ScopeNames.AVAILABLE_FOR_LIST, { - type: options.type, - followerActorId: options.followerActorId, - accountId: options.accountId, - videoChannelId: options.videoChannelId, - privateAndUnlisted: options.privateAndUnlisted, - search: options.search + ...pick(options, [ 'type', 'followerActorId', 'accountId', 'videoChannelId', 'listMyPlaylists', 'search', 'host', 'uuids' ]), + + withVideos: options.withVideos || false } as AvailableForListOptions ] }, @@ -345,15 +391,38 @@ export class VideoPlaylistModel extends Model { }) } - static listPublicUrlsOfForAP (accountId: number, start: number, count: number) { + static searchForApi (options: Pick & { + start: number + count: number + sort: string + }) { + return VideoPlaylistModel.listForApi({ + ...options, + + type: VideoPlaylistType.REGULAR, + listMyPlaylists: false, + withVideos: true + }) + } + + static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) { + const where = { + privacy: VideoPlaylistPrivacy.PUBLIC + } + + if (options.account) { + Object.assign(where, { ownerAccountId: options.account.id }) + } + + if (options.channel) { + Object.assign(where, { videoChannelId: options.channel.id }) + } + const query = { attributes: [ 'url' ], offset: start, limit: count, - where: { - ownerAccountId: accountId, - privacy: VideoPlaylistPrivacy.PUBLIC - } + where } return VideoPlaylistModel.findAndCountAll(query) @@ -362,7 +431,7 @@ export class VideoPlaylistModel extends Model { }) } - static listPlaylistIdsOf (accountId: number, videoIds: number[]): Bluebird { + static listPlaylistIdsOf (accountId: number, videoIds: number[]): Promise { const query = { attributes: [ 'id' ], where: { @@ -374,7 +443,7 @@ export class VideoPlaylistModel extends Model { model: VideoPlaylistElementModel.unscoped(), where: { videoId: { - [Op.in]: videoIds // FIXME: sequelize ANY seems broken + [Op.in]: videoIds } }, required: true @@ -387,7 +456,7 @@ export class VideoPlaylistModel extends Model { static doesPlaylistExist (url: string) { const query = { - attributes: [], + attributes: [ 'id' ], where: { url } @@ -398,7 +467,7 @@ export class VideoPlaylistModel extends Model { .then(e => !!e) } - static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction): Bluebird { + static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction): Promise { const where = buildWhereIdOrUUID(id) const query = { @@ -411,7 +480,7 @@ export class VideoPlaylistModel extends Model { .findOne(query) } - static loadWithAccountAndChannel (id: number | string, transaction: Transaction): Bluebird { + static loadWithAccountAndChannel (id: number | string, transaction: Transaction): Promise { const where = buildWhereIdOrUUID(id) const query = { @@ -424,7 +493,7 @@ export class VideoPlaylistModel extends Model { .findOne(query) } - static loadByUrlAndPopulateAccount (url: string): Bluebird { + static loadByUrlAndPopulateAccount (url: string): Promise { const query = { where: { url @@ -434,6 +503,18 @@ export class VideoPlaylistModel extends Model { return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query) } + static loadByUrlWithAccountAndChannelSummary (url: string): Promise { + const query = { + where: { + url + } + } + + return VideoPlaylistModel + .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ]) + .findOne(query) + } + static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown' } @@ -470,7 +551,7 @@ export class VideoPlaylistModel extends Model { generateThumbnailName () { const extension = '.jpg' - return 'playlist-' + this.uuid + extension + return 'playlist-' + buildUUID() + extension } getThumbnailUrl () { @@ -485,10 +566,47 @@ export class VideoPlaylistModel extends Model { return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename) } + getWatchStaticPath () { + return buildPlaylistWatchPath({ shortUUID: uuidToShort(this.uuid) }) + } + + getEmbedStaticPath () { + return buildPlaylistEmbedPath(this) + } + + static async getStats () { + const totalLocalPlaylists = await VideoPlaylistModel.count({ + include: [ + { + model: AccountModel, + required: true, + include: [ + { + model: ActorModel, + required: true, + where: { + serverId: null + } + } + ] + } + ], + where: { + privacy: VideoPlaylistPrivacy.PUBLIC + } + }) + + return { + totalLocalPlaylists + } + } + setAsRefreshed () { - this.changed('updatedAt', true) + return setAsUpdated('videoPlaylist', this.id) + } - return this.save() + setVideosLength (videosLength: number) { + this.set('videosLength' as any, videosLength, { raw: true }) } isOwned () { @@ -505,8 +623,12 @@ export class VideoPlaylistModel extends Model { return { id: this.id, uuid: this.uuid, + shortUUID: uuidToShort(this.uuid), + isLocal: this.isOwned(), + url: this.url, + displayName: this.name, description: this.description, privacy: { @@ -515,6 +637,7 @@ export class VideoPlaylistModel extends Model { }, thumbnailPath: this.getThumbnailStaticPath(), + embedPath: this.getEmbedStaticPath(), type: { id: this.type, @@ -527,7 +650,9 @@ export class VideoPlaylistModel extends Model { updatedAt: this.updatedAt, ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(), - videoChannel: this.VideoChannel ? this.VideoChannel.toFormattedSummaryJSON() : null + videoChannel: this.VideoChannel + ? this.VideoChannel.toFormattedSummaryJSON() + : null } }