X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Fmodels%2Fvideo%2Fvideo-playlist.ts;h=8fb3d5f153539ac910174b3506985edb3db78dec;hb=4ec52d04dcc5d664612331f8e08d7d90da990415;hp=4d2ea0a666ad11600238af46b51f68706bb4e734;hpb=09979f8959425390b879bce22101a9bc061ae9a0;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index 4d2ea0a66..8fb3d5f15 100644 --- a/server/models/video/video-playlist.ts +++ b/server/models/video/video-playlist.ts @@ -1,6 +1,7 @@ +import { join } from 'path' +import { FindOptions, Includeable, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' import { AllowNull, - BeforeDestroy, BelongsTo, Column, CreatedAt, @@ -8,6 +9,7 @@ import { Default, ForeignKey, HasMany, + HasOne, Is, IsUUID, Model, @@ -15,115 +17,172 @@ import { Table, UpdatedAt } from 'sequelize-typescript' -import * as Sequelize from 'sequelize' +import { activityPubCollectionPagination } from '@server/lib/activitypub/collection' +import { MAccountId, MChannelId } from '@server/types/models' +import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils' +import { buildUUID, uuidToShort } from '@shared/extra-utils' +import { AttributesOnly } from '@shared/typescript-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, throwIfNotValid } from '../utils' +import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model' +import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model' +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 { - CONFIG, + ACTIVITY_PUB, CONSTRAINTS_FIELDS, STATIC_PATHS, THUMBNAILS_SIZE, VIDEO_PLAYLIST_PRIVACIES, - VIDEO_PLAYLIST_TYPES -} from '../../initializers' -import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model' -import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' + VIDEO_PLAYLIST_TYPES, + WEBSERVER +} from '../../initializers/constants' +import { MThumbnail } from '../../types/models/video/thumbnail' +import { + MVideoPlaylistAccountThumbnail, + MVideoPlaylistAP, + MVideoPlaylistFormattable, + MVideoPlaylistFull, + MVideoPlaylistFullSummary, + MVideoPlaylistIdWithElements +} 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 { join } from 'path' import { VideoPlaylistElementModel } from './video-playlist-element' -import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' -import { activityPubCollectionPagination } from '../../helpers/activitypub' -import { remove } from 'fs-extra' -import { logger } from '../../helpers/logger' -import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model' enum ScopeNames { AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH', WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY', WITH_ACCOUNT = 'WITH_ACCOUNT', + WITH_THUMBNAIL = 'WITH_THUMBNAIL', WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL' } type AvailableForListOptions = { - followerActorId: number + followerActorId?: number type?: VideoPlaylistType accountId?: number videoChannelId?: number - privateAndUnlisted?: boolean + listMyPlaylists?: boolean + search?: string + host?: string + uuids?: string[] + withVideos?: boolean + forCount?: boolean +} + +function getVideoLengthSelect () { + return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"' } -@Scopes({ - [ ScopeNames.WITH_VIDEOS_LENGTH ]: { +@Scopes(() => ({ + [ScopeNames.WITH_THUMBNAIL]: { + include: [ + { + model: ThumbnailModel, + required: false + } + ] + }, + [ScopeNames.WITH_VIDEOS_LENGTH]: { attributes: { include: [ [ - Sequelize.literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'), + literal(`(${getVideoLengthSelect()})`), 'videosLength' ] ] } - }, - [ ScopeNames.WITH_ACCOUNT ]: { + } as FindOptions, + [ScopeNames.WITH_ACCOUNT]: { include: [ { - model: () => AccountModel, + model: AccountModel, required: true } ] }, - [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY ]: { + [ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY]: { include: [ { - model: () => AccountModel.scope(AccountScopeNames.SUMMARY), + model: AccountModel.scope(AccountScopeNames.SUMMARY), required: true }, { - model: () => VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY), + model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY), required: false } ] }, - [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL ]: { + [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: { include: [ { - model: () => AccountModel, + model: AccountModel, required: true }, { - model: () => VideoChannelModel, + model: VideoChannelModel, required: false } ] }, - [ 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 actorWhere = { - [ Sequelize.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: { - [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow) - } } ] - } - const whereAnd: any[] = [] + // … 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) { @@ -144,33 +203,74 @@ 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({ + [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 + '))' + ) + ] + }) + } + const where = { - [Sequelize.Op.and]: whereAnd + [Op.and]: whereAnd } - const accountScope = { - method: [ AccountScopeNames.SUMMARY, actorWhere ] + const include: Includeable[] = [ + { + model: AccountModel.scope({ + method: [ AccountScopeNames.SUMMARY, { whereActor, whereServer, forCount: options.forCount } as SummaryOptions ] + }), + required: true + } + ] + + if (options.forCount !== true) { + include.push({ + model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY), + required: false + }) } return { + attributes: { + include: attributesInclude + }, where, - include: [ - { - model: AccountModel.scope(accountScope), - required: true - }, - { - model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY), - required: false - } - ] - } + include + } as FindOptions } -}) +})) @Table({ tableName: 'videoPlaylist', indexes: [ + buildTrigramSearchIndex('video_playlist_name_trigram', 'name'), + { fields: [ 'ownerAccountId' ] }, @@ -183,7 +283,7 @@ type AvailableForListOptions = { } ] }) -export class VideoPlaylistModel extends Model { +export class VideoPlaylistModel extends Model>> { @CreatedAt createdAt: Date @@ -196,8 +296,8 @@ export class VideoPlaylistModel extends Model { name: string @AllowNull(true) - @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description')) - @Column + @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true)) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.DESCRIPTION.max)) description: string @AllowNull(false) @@ -254,72 +354,148 @@ export class VideoPlaylistModel extends Model { }) VideoPlaylistElements: VideoPlaylistElementModel[] - @BeforeDestroy - static async removeFiles (instance: VideoPlaylistModel) { - logger.info('Removing files of video playlist %s.', instance.url) - - return instance.removeThumbnail() - } + @HasOne(() => ThumbnailModel, { + foreignKey: { + name: 'videoPlaylistId', + allowNull: true + }, + onDelete: 'CASCADE', + hooks: true + }) + Thumbnail: ThumbnailModel - static listForApi (options: { - followerActorId: number - start: number, - count: number, - sort: string, - type?: VideoPlaylistType, - accountId?: number, - videoChannelId?: number, - privateAndUnlisted?: boolean + 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 = [ + const commonAvailableForListOptions = pick(options, [ + 'type', + 'followerActorId', + 'accountId', + 'videoChannelId', + 'listMyPlaylists', + 'search', + 'host', + 'uuids' + ]) + + const scopesFind: (string | ScopeOptions)[] = [ { method: [ ScopeNames.AVAILABLE_FOR_LIST, { - type: options.type, - followerActorId: options.followerActorId, - accountId: options.accountId, - videoChannelId: options.videoChannelId, - privateAndUnlisted: options.privateAndUnlisted + ...commonAvailableForListOptions, + + withVideos: options.withVideos || false } as AvailableForListOptions ] - } as any, // FIXME: typings + }, + ScopeNames.WITH_VIDEOS_LENGTH, + ScopeNames.WITH_THUMBNAIL + ] + + const scopesCount: (string | ScopeOptions)[] = [ + { + method: [ + ScopeNames.AVAILABLE_FOR_LIST, + + { + ...commonAvailableForListOptions, + + withVideos: options.withVideos || false, + forCount: true + } as AvailableForListOptions + ] + }, ScopeNames.WITH_VIDEOS_LENGTH ] - return VideoPlaylistModel - .scope(scopes) - .findAndCountAll(query) - .then(({ rows, count }) => { - return { total: count, data: rows } - }) + return Promise.all([ + VideoPlaylistModel.scope(scopesCount).count(), + VideoPlaylistModel.scope(scopesFind).findAll(query) + ]).then(([ count, rows ]) => ({ total: count, data: rows })) + } + + static searchForApi (options: Pick & { + start: number + count: number + sort: string + }) { + return VideoPlaylistModel.listForApi({ + ...options, + + type: VideoPlaylistType.REGULAR, + listMyPlaylists: false, + withVideos: true + }) } - static listUrlsOfForAP (accountId: number, start: number, count: number) { + 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 getQuery = (forCount: boolean) => { + return { + attributes: forCount === true + ? [] + : [ 'url' ], + offset: start, + limit: count, + where + } + } + + return Promise.all([ + VideoPlaylistModel.count(getQuery(true)), + VideoPlaylistModel.findAll(getQuery(false)) + ]).then(([ total, rows ]) => ({ + total, + data: rows.map(p => p.url) + })) + } + + static listPlaylistIdsOf (accountId: number, videoIds: number[]): Promise { const query = { - attributes: [ 'url' ], - offset: start, - limit: count, + attributes: [ 'id' ], where: { ownerAccountId: accountId - } + }, + include: [ + { + attributes: [ 'id', 'videoId', 'startTimestamp', 'stopTimestamp' ], + model: VideoPlaylistElementModel.unscoped(), + where: { + videoId: { + [Op.in]: videoIds + } + }, + required: true + } + ] } - return VideoPlaylistModel.findAndCountAll(query) - .then(({ rows, count }) => { - return { total: count, data: rows.map(p => p.url) } - }) + return VideoPlaylistModel.findAll(query) } static doesPlaylistExist (url: string) { const query = { - attributes: [], + attributes: [ 'id' ], where: { url } @@ -330,7 +506,7 @@ export class VideoPlaylistModel extends Model { .then(e => !!e) } - static loadWithAccountAndChannelSummary (id: number | string, transaction: Sequelize.Transaction) { + static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction): Promise { const where = buildWhereIdOrUUID(id) const query = { @@ -339,11 +515,11 @@ export class VideoPlaylistModel extends Model { } return VideoPlaylistModel - .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH ]) + .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ]) .findOne(query) } - static loadWithAccountAndChannel (id: number | string, transaction: Sequelize.Transaction) { + static loadWithAccountAndChannel (id: number | string, transaction: Transaction): Promise { const where = buildWhereIdOrUUID(id) const query = { @@ -352,18 +528,30 @@ export class VideoPlaylistModel extends Model { } return VideoPlaylistModel - .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ]) + .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ]) .findOne(query) } - static loadByUrlAndPopulateAccount (url: string) { + static loadByUrlAndPopulateAccount (url: string): Promise { + const query = { + where: { + url + } + } + + 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).findOne(query) + return VideoPlaylistModel + .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ]) + .findOne(query) } static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { @@ -374,7 +562,7 @@ export class VideoPlaylistModel extends Model { return VIDEO_PLAYLIST_TYPES[type] || 'Unknown' } - static resetPlaylistsOfChannel (videoChannelId: number, transaction: Sequelize.Transaction) { + static resetPlaylistsOfChannel (videoChannelId: number, transaction: Transaction) { const query = { where: { videoChannelId @@ -385,36 +573,101 @@ export class VideoPlaylistModel extends Model { return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query) } - getThumbnailName () { + async setAndSaveThumbnail (thumbnail: MThumbnail, t: Transaction) { + thumbnail.videoPlaylistId = this.id + + this.Thumbnail = await thumbnail.save({ transaction: t }) + } + + hasThumbnail () { + return !!this.Thumbnail + } + + hasGeneratedThumbnail () { + return this.hasThumbnail() && this.Thumbnail.automaticallyGenerated === true + } + + generateThumbnailName () { const extension = '.jpg' - return 'playlist-' + this.uuid + extension + return 'playlist-' + buildUUID() + extension } getThumbnailUrl () { - return CONFIG.WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() + if (!this.hasThumbnail()) return null + + return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename } getThumbnailStaticPath () { - return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) + if (!this.hasThumbnail()) return null + + return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename) + } + + getWatchStaticPath () { + return buildPlaylistWatchPath({ shortUUID: uuidToShort(this.uuid) }) } - removeThumbnail () { - const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) - return remove(thumbnailPath) - .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err })) + 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 () { + return setAsUpdated('videoPlaylist', this.id) + } + + setVideosLength (videosLength: number) { + this.set('videosLength' as any, videosLength, { raw: true }) } isOwned () { return this.OwnerAccount.isOwned() } - toFormattedJSON (): VideoPlaylist { + isOutdated () { + if (this.isOwned()) return false + + return isOutdated(this, ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL) + } + + toFormattedJSON (this: MVideoPlaylistFormattable): VideoPlaylist { return { id: this.id, uuid: this.uuid, + shortUUID: uuidToShort(this.uuid), + isLocal: this.isOwned(), + url: this.url, + displayName: this.name, description: this.description, privacy: { @@ -423,44 +676,53 @@ export class VideoPlaylistModel extends Model { }, thumbnailPath: this.getThumbnailStaticPath(), + embedPath: this.getEmbedStaticPath(), type: { id: this.type, label: VideoPlaylistModel.getTypeLabel(this.type) }, - videosLength: this.get('videosLength'), + videosLength: this.get('videosLength') as number, createdAt: this.createdAt, updatedAt: this.updatedAt, ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(), - videoChannel: this.VideoChannel ? this.VideoChannel.toFormattedSummaryJSON() : null + videoChannel: this.VideoChannel + ? this.VideoChannel.toFormattedSummaryJSON() + : null } } - toActivityPubObject (page: number, t: Sequelize.Transaction): Promise { + toActivityPubObject (this: MVideoPlaylistAP, page: number, t: Transaction): Promise { const handler = (start: number, count: number) => { return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t) } + let icon: ActivityIconObject + if (this.hasThumbnail()) { + icon = { + type: 'Image' as 'Image', + url: this.getThumbnailUrl(), + mediaType: 'image/jpeg' as 'image/jpeg', + width: THUMBNAILS_SIZE.width, + height: THUMBNAILS_SIZE.height + } + } + return activityPubCollectionPagination(this.url, handler, page) .then(o => { return Object.assign(o, { type: 'Playlist' as 'Playlist', name: this.name, content: this.description, + mediaType: 'text/markdown' as 'text/markdown', uuid: this.uuid, published: this.createdAt.toISOString(), updated: this.updatedAt.toISOString(), attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [], - icon: { - type: 'Image' as 'Image', - url: this.getThumbnailUrl(), - mediaType: 'image/jpeg' as 'image/jpeg', - width: THUMBNAILS_SIZE.width, - height: THUMBNAILS_SIZE.height - } + icon }) }) }