X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Fmodels%2Fvideo%2Fvideo-playlist.ts;h=4ca17ebec0126f49ace0f97be84c932f5f2e19ea;hb=3d527ba173a37bd61ec8ad742642bb320d12995c;hp=397887ebf1a6c793d83e75f27e5711d138c8f8a2;hpb=07b1a18aa678d260009a93e36606c5c5f585723d;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index 397887ebf..4ca17ebec 100644 --- a/server/models/video/video-playlist.ts +++ b/server/models/video/video-playlist.ts @@ -1,6 +1,5 @@ import { AllowNull, - BeforeDestroy, BelongsTo, Column, CreatedAt, @@ -8,6 +7,7 @@ import { Default, ForeignKey, HasMany, + HasOne, Is, IsUUID, Model, @@ -15,84 +15,140 @@ import { Table, UpdatedAt } from 'sequelize-typescript' -import * as Sequelize from 'sequelize' import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' -import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, throwIfNotValid } from '../utils' +import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, isOutdated, throwIfNotValid } from '../utils' import { isVideoPlaylistDescriptionValid, isVideoPlaylistNameValid, isVideoPlaylistPrivacyValid } from '../../helpers/custom-validators/video-playlists' import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' -import { CONFIG, CONSTRAINTS_FIELDS, STATIC_PATHS, THUMBNAILS_SIZE, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers' +import { + ACTIVITY_PUB, + CONSTRAINTS_FIELDS, + STATIC_PATHS, + THUMBNAILS_SIZE, + VIDEO_PLAYLIST_PRIVACIES, + VIDEO_PLAYLIST_TYPES, + WEBSERVER +} from '../../initializers/constants' import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model' -import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' +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 { remove } from 'fs-extra' -import { logger } from '../../helpers/logger' +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 { + MVideoPlaylistAccountThumbnail, + MVideoPlaylistAP, + MVideoPlaylistFormattable, + MVideoPlaylistFull, + MVideoPlaylistFullSummary, + MVideoPlaylistIdWithElements +} from '../../typings/models/video/video-playlist' +import { MThumbnail } from '../../typings/models/video/thumbnail' 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 - accountId?: number, + type?: VideoPlaylistType + accountId?: number videoChannelId?: number - privateAndUnlisted?: boolean + listMyPlaylists?: boolean + search?: string } -@Scopes({ +@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('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'), 'videosLength' ] ] } + } as FindOptions, + [ScopeNames.WITH_ACCOUNT]: { + include: [ + { + model: AccountModel, + required: true + } + ] + }, + [ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY]: { + include: [ + { + model: AccountModel.scope(AccountScopeNames.SUMMARY), + required: true + }, + { + model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY), + required: false + } + ] }, [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: { include: [ { - model: () => AccountModel.scope(AccountScopeNames.SUMMARY), + model: AccountModel, required: true }, { - model: () => VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY), + 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 ]: [ - { - serverId: null - }, - { - serverId: { - [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow) - } - } - ] - } - const whereAnd: any[] = [] + let whereActor: WhereOptions = {} + + const whereAnd: WhereOptions[] = [] - if (options.privateAndUnlisted !== true) { + if (options.listMyPlaylists !== true) { whereAnd.push({ privacy: VideoPlaylistPrivacy.PUBLIC }) + + // Only list local playlists OR playlists that are on an instance followed by actorId + const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId) + + whereActor = { + [Op.or]: [ + { + serverId: null + }, + { + serverId: { + [Op.in]: literal(inQueryInstanceFollow) + } + } + ] + } } if (options.accountId) { @@ -107,12 +163,26 @@ type AvailableForListOptions = { }) } + if (options.type) { + whereAnd.push({ + type: options.type + }) + } + + if (options.search) { + whereAnd.push({ + name: { + [Op.iLike]: '%' + options.search + '%' + } + }) + } + const where = { - [Sequelize.Op.and]: whereAnd + [Op.and]: whereAnd } const accountScope = { - method: [ AccountScopeNames.SUMMARY, actorWhere ] + method: [ AccountScopeNames.SUMMARY, { whereActor } as SummaryOptions ] } return { @@ -127,9 +197,9 @@ type AvailableForListOptions = { required: false } ] - } + } as FindOptions } -}) +})) @Table({ tableName: 'videoPlaylist', @@ -159,7 +229,7 @@ export class VideoPlaylistModel extends Model { name: string @AllowNull(true) - @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description')) + @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true)) @Column description: string @@ -179,6 +249,11 @@ export class VideoPlaylistModel extends Model { @Column(DataType.UUID) uuid: string + @AllowNull(false) + @Default(VideoPlaylistType.REGULAR) + @Column + type: VideoPlaylistType + @ForeignKey(() => AccountModel) @Column ownerAccountId: number @@ -208,28 +283,30 @@ export class VideoPlaylistModel extends Model { name: 'videoPlaylistId', allowNull: false }, - onDelete: 'cascade' + onDelete: 'CASCADE' }) VideoPlaylistElements: VideoPlaylistElementModel[] - // Calculated field - videosLength?: number - - @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, - accountId?: number, - videoChannelId?: number, - privateAndUnlisted?: boolean + start: number + count: number + sort: string + type?: VideoPlaylistType + accountId?: number + videoChannelId?: number + listMyPlaylists?: boolean + search?: string }) { const query = { offset: options.start, @@ -237,19 +314,22 @@ export class VideoPlaylistModel extends Model { order: getSort(options.sort) } - const scopes = [ + const scopes: (string | ScopeOptions)[] = [ { method: [ ScopeNames.AVAILABLE_FOR_LIST, { + type: options.type, followerActorId: options.followerActorId, accountId: options.accountId, videoChannelId: options.videoChannelId, - privateAndUnlisted: options.privateAndUnlisted + listMyPlaylists: options.listMyPlaylists, + search: options.search } as AvailableForListOptions ] - } as any, // FIXME: typings - ScopeNames.WITH_VIDEOS_LENGTH + }, + ScopeNames.WITH_VIDEOS_LENGTH, + ScopeNames.WITH_THUMBNAIL ] return VideoPlaylistModel @@ -260,13 +340,14 @@ export class VideoPlaylistModel extends Model { }) } - static listUrlsOfForAP (accountId: number, start: number, count: number) { + static listPublicUrlsOfForAP (accountId: number, start: number, count: number) { const query = { attributes: [ 'url' ], offset: start, limit: count, where: { - ownerAccountId: accountId + ownerAccountId: accountId, + privacy: VideoPlaylistPrivacy.PUBLIC } } @@ -276,6 +357,29 @@ export class VideoPlaylistModel extends Model { }) } + static listPlaylistIdsOf (accountId: number, videoIds: number[]): Bluebird { + const query = { + attributes: [ 'id' ], + where: { + ownerAccountId: accountId + }, + include: [ + { + attributes: [ 'id', 'videoId', 'startTimestamp', 'stopTimestamp' ], + model: VideoPlaylistElementModel.unscoped(), + where: { + videoId: { + [Op.in]: videoIds + } + }, + required: true + } + ] + } + + return VideoPlaylistModel.findAll(query) + } + static doesPlaylistExist (url: string) { const query = { attributes: [], @@ -289,7 +393,7 @@ export class VideoPlaylistModel extends Model { .then(e => !!e) } - static load (id: number | string, transaction: Sequelize.Transaction) { + static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction): Bluebird { const where = buildWhereIdOrUUID(id) const query = { @@ -298,39 +402,101 @@ export class VideoPlaylistModel extends Model { } return VideoPlaylistModel - .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, 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: Transaction): Bluebird { + const where = buildWhereIdOrUUID(id) + + const query = { + where, + transaction + } + + return VideoPlaylistModel + .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ]) + .findOne(query) + } + + static loadByUrlAndPopulateAccount (url: string): Bluebird { + const query = { + where: { + url + } + } + + return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query) + } + static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown' } - getThumbnailName () { + static getTypeLabel (type: VideoPlaylistType) { + return VIDEO_PLAYLIST_TYPES[type] || 'Unknown' + } + + static resetPlaylistsOfChannel (videoChannelId: number, transaction: Transaction) { + const query = { + where: { + videoChannelId + }, + transaction + } + + return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query) + } + + 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 } 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) } - removeThumbnail () { - const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) - return remove(thumbnailPath) - .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err })) + setAsRefreshed () { + this.changed('updatedAt', true) + + return this.save() } 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, @@ -345,7 +511,12 @@ export class VideoPlaylistModel extends Model { thumbnailPath: this.getThumbnailStaticPath(), - videosLength: this.videosLength, + type: { + id: this.type, + label: VideoPlaylistModel.getTypeLabel(this.type) + }, + + videosLength: this.get('videosLength') as number, createdAt: this.createdAt, updatedAt: this.updatedAt, @@ -355,26 +526,33 @@ export class VideoPlaylistModel extends Model { } } - toActivityPubObject (): Promise { + toActivityPubObject (this: MVideoPlaylistAP, page: number, t: Transaction): Promise { const handler = (start: number, count: number) => { - return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count) + return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t) } - return activityPubCollectionPagination(this.url, handler, null) + 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, 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 }) }) }