From 418d092afa81e2c8fe8ac6838fc4b5eb0af6a782 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 26 Feb 2019 10:55:40 +0100 Subject: Playlist server API --- server/models/account/account.ts | 60 +++- server/models/activitypub/actor-follow.ts | 2 +- server/models/activitypub/actor.ts | 5 + server/models/utils.ts | 21 +- server/models/video/video-channel.ts | 78 +++++- server/models/video/video-format-utils.ts | 36 +-- server/models/video/video-playlist-element.ts | 231 ++++++++++++++++ server/models/video/video-playlist.ts | 381 ++++++++++++++++++++++++++ server/models/video/video.ts | 127 +++++---- 9 files changed, 836 insertions(+), 105 deletions(-) create mode 100644 server/models/video/video-playlist-element.ts create mode 100644 server/models/video/video-playlist.ts (limited to 'server/models') diff --git a/server/models/account/account.ts b/server/models/account/account.ts index ee22d8528..3fb766c8a 100644 --- a/server/models/account/account.ts +++ b/server/models/account/account.ts @@ -10,11 +10,11 @@ import { ForeignKey, HasMany, Is, - Model, + Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' -import { Account } from '../../../shared/models/actors' +import { Account, AccountSummary } from '../../../shared/models/actors' import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' import { sendDeleteActor } from '../../lib/activitypub/send' import { ActorModel } from '../activitypub/actor' @@ -25,6 +25,13 @@ import { VideoChannelModel } from '../video/video-channel' import { VideoCommentModel } from '../video/video-comment' import { UserModel } from './user' import { CONFIG } from '../../initializers' +import { AvatarModel } from '../avatar/avatar' +import { WhereOptions } from 'sequelize' +import { VideoPlaylistModel } from '../video/video-playlist' + +export enum ScopeNames { + SUMMARY = 'SUMMARY' +} @DefaultScope({ include: [ @@ -34,6 +41,32 @@ import { CONFIG } from '../../initializers' } ] }) +@Scopes({ + [ ScopeNames.SUMMARY ]: (whereActor?: WhereOptions) => { + return { + attributes: [ 'id', 'name' ], + include: [ + { + attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], + model: ActorModel.unscoped(), + required: true, + where: whereActor, + include: [ + { + attributes: [ 'host' ], + model: ServerModel.unscoped(), + required: false + }, + { + model: AvatarModel.unscoped(), + required: false + } + ] + } + ] + } + } +}) @Table({ tableName: 'account', indexes: [ @@ -112,6 +145,15 @@ export class AccountModel extends Model { }) VideoChannels: VideoChannelModel[] + @HasMany(() => VideoPlaylistModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'cascade', + hooks: true + }) + VideoPlaylists: VideoPlaylistModel[] + @HasMany(() => VideoCommentModel, { foreignKey: { allowNull: false @@ -285,6 +327,20 @@ export class AccountModel extends Model { return Object.assign(actor, account) } + toFormattedSummaryJSON (): AccountSummary { + const actor = this.Actor.toFormattedJSON() + + return { + id: this.id, + uuid: actor.uuid, + name: actor.name, + displayName: this.getDisplayName(), + url: actor.url, + host: actor.host, + avatar: actor.avatar + } + } + toActivityPubObject () { const obj = this.Actor.toActivityPubObject(this.name, 'Account') diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts index 796e07a42..e3eeb7dae 100644 --- a/server/models/activitypub/actor-follow.ts +++ b/server/models/activitypub/actor-follow.ts @@ -407,7 +407,7 @@ export class ActorFollowModel extends Model { }) } - static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) { + static listAcceptedFollowerUrlsForAP (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) { return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count) } diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts index 49f82023b..2fceb21dd 100644 --- a/server/models/activitypub/actor.ts +++ b/server/models/activitypub/actor.ts @@ -444,6 +444,7 @@ export class ActorModel extends Model { id: this.url, following: this.getFollowingUrl(), followers: this.getFollowersUrl(), + playlists: this.getPlaylistsUrl(), inbox: this.inboxUrl, outbox: this.outboxUrl, preferredUsername: this.preferredUsername, @@ -494,6 +495,10 @@ export class ActorModel extends Model { return this.url + '/followers' } + getPlaylistsUrl () { + return this.url + '/playlists' + } + getPublicKeyUrl () { return this.url + '#main-key' } diff --git a/server/models/utils.ts b/server/models/utils.ts index 5b4093aec..4ebd07dab 100644 --- a/server/models/utils.ts +++ b/server/models/utils.ts @@ -1,4 +1,5 @@ import { Sequelize } from 'sequelize-typescript' +import * as validator from 'validator' type SortType = { sortModel: any, sortValue: string } @@ -74,13 +75,25 @@ function buildBlockedAccountSQL (serverAccountId: number, userAccountId?: number const blockerIdsString = blockerIds.join(', ') - const query = 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' + + return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' + ' UNION ALL ' + 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' + 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' + 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')' +} + +function buildServerIdsFollowedBy (actorId: any) { + const actorIdNumber = parseInt(actorId + '', 10) + + return '(' + + 'SELECT "actor"."serverId" FROM "actorFollow" ' + + 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' + + 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + + ')' +} - return query +function buildWhereIdOrUUID (id: number | string) { + return validator.isInt('' + id) ? { id } : { uuid: id } } // --------------------------------------------------------------------------- @@ -93,7 +106,9 @@ export { getSortOnModel, createSimilarityAttribute, throwIfNotValid, - buildTrigramSearchIndex + buildServerIdsFollowedBy, + buildTrigramSearchIndex, + buildWhereIdOrUUID } // --------------------------------------------------------------------------- diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts index 2426b3de6..112abf8cf 100644 --- a/server/models/video/video-channel.ts +++ b/server/models/video/video-channel.ts @@ -8,7 +8,7 @@ import { Default, DefaultScope, ForeignKey, - HasMany, + HasMany, IFindOptions, Is, Model, Scopes, @@ -17,20 +17,22 @@ import { UpdatedAt } from 'sequelize-typescript' import { ActivityPubActor } from '../../../shared/models/activitypub' -import { VideoChannel } from '../../../shared/models/videos' +import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos' import { isVideoChannelDescriptionValid, isVideoChannelNameValid, isVideoChannelSupportValid } from '../../helpers/custom-validators/video-channels' import { sendDeleteActor } from '../../lib/activitypub/send' -import { AccountModel } from '../account/account' +import { AccountModel, ScopeNames as AccountModelScopeNames } from '../account/account' import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' -import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' +import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' import { VideoModel } from './video' import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers' import { ServerModel } from '../server/server' import { DefineIndexesOptions } from 'sequelize' +import { AvatarModel } from '../avatar/avatar' +import { VideoPlaylistModel } from './video-playlist' // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation const indexes: DefineIndexesOptions[] = [ @@ -44,11 +46,12 @@ const indexes: DefineIndexesOptions[] = [ } ] -enum ScopeNames { +export enum ScopeNames { AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', WITH_ACCOUNT = 'WITH_ACCOUNT', WITH_ACTOR = 'WITH_ACTOR', - WITH_VIDEOS = 'WITH_VIDEOS' + WITH_VIDEOS = 'WITH_VIDEOS', + SUMMARY = 'SUMMARY' } type AvailableForListOptions = { @@ -64,15 +67,41 @@ type AvailableForListOptions = { ] }) @Scopes({ - [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { - const actorIdNumber = parseInt(options.actorId + '', 10) + [ScopeNames.SUMMARY]: (required: boolean, withAccount: boolean) => { + const base: IFindOptions = { + attributes: [ 'name', 'description', 'id' ], + include: [ + { + attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], + model: ActorModel.unscoped(), + required: true, + include: [ + { + attributes: [ 'host' ], + model: ServerModel.unscoped(), + required: false + }, + { + model: AvatarModel.unscoped(), + required: false + } + ] + } + ] + } + + if (withAccount === true) { + base.include.push({ + model: AccountModel.scope(AccountModelScopeNames.SUMMARY), + required: true + }) + } + return base + }, + [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { // Only list local channels OR channels that are on an instance followed by actorId - const inQueryInstanceFollow = '(' + - 'SELECT "actor"."serverId" FROM "actorFollow" ' + - 'INNER JOIN "actor" ON actor.id= "actorFollow"."targetActorId" ' + - 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + - ')' + const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId) return { include: [ @@ -192,6 +221,15 @@ export class VideoChannelModel extends Model { }) Videos: VideoModel[] + @HasMany(() => VideoPlaylistModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'cascade', + hooks: true + }) + VideoPlaylists: VideoPlaylistModel[] + @BeforeDestroy static async sendDeleteIfOwned (instance: VideoChannelModel, options) { if (!instance.Actor) { @@ -460,6 +498,20 @@ export class VideoChannelModel extends Model { return Object.assign(actor, videoChannel) } + toFormattedSummaryJSON (): VideoChannelSummary { + const actor = this.Actor.toFormattedJSON() + + return { + id: this.id, + uuid: actor.uuid, + name: actor.name, + displayName: this.getDisplayName(), + url: actor.url, + host: actor.host, + avatar: actor.avatar + } + } + toActivityPubObject (): ActivityPubActor { const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel') diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index a62335333..dc10fb9a2 100644 --- a/server/models/video/video-format-utils.ts +++ b/server/models/video/video-format-utils.ts @@ -26,12 +26,10 @@ export type VideoFormattingJSONOptions = { waitTranscoding?: boolean, scheduledUpdate?: boolean, blacklistInfo?: boolean + playlistInfo?: boolean } } function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video { - const formattedAccount = video.VideoChannel.Account.toFormattedJSON() - const formattedVideoChannel = video.VideoChannel.toFormattedJSON() - const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined const videoObject: Video = { @@ -68,24 +66,9 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting updatedAt: video.updatedAt, publishedAt: video.publishedAt, originallyPublishedAt: video.originallyPublishedAt, - account: { - id: formattedAccount.id, - uuid: formattedAccount.uuid, - name: formattedAccount.name, - displayName: formattedAccount.displayName, - url: formattedAccount.url, - host: formattedAccount.host, - avatar: formattedAccount.avatar - }, - channel: { - id: formattedVideoChannel.id, - uuid: formattedVideoChannel.uuid, - name: formattedVideoChannel.name, - displayName: formattedVideoChannel.displayName, - url: formattedVideoChannel.url, - host: formattedVideoChannel.host, - avatar: formattedVideoChannel.avatar - }, + + account: video.VideoChannel.Account.toFormattedSummaryJSON(), + channel: video.VideoChannel.toFormattedSummaryJSON(), userHistory: userHistory ? { currentTime: userHistory.currentTime @@ -115,6 +98,17 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting videoObject.blacklisted = !!video.VideoBlacklist videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null } + + if (options.additionalAttributes.playlistInfo === true) { + // We filtered on a specific videoId/videoPlaylistId, that is unique + const playlistElement = video.VideoPlaylistElements[0] + + videoObject.playlistElement = { + position: playlistElement.position, + startTimestamp: playlistElement.startTimestamp, + stopTimestamp: playlistElement.stopTimestamp + } + } } return videoObject diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts new file mode 100644 index 000000000..d76149d12 --- /dev/null +++ b/server/models/video/video-playlist-element.ts @@ -0,0 +1,231 @@ +import { + AllowNull, + BelongsTo, + Column, + CreatedAt, + DataType, + Default, + ForeignKey, + Is, + IsInt, + Min, + Model, + Table, + UpdatedAt +} from 'sequelize-typescript' +import { VideoModel } from './video' +import { VideoPlaylistModel } from './video-playlist' +import * as Sequelize from 'sequelize' +import { getSort, throwIfNotValid } from '../utils' +import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' +import { CONSTRAINTS_FIELDS } from '../../initializers' +import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object' + +@Table({ + tableName: 'videoPlaylistElement', + indexes: [ + { + fields: [ 'videoPlaylistId' ] + }, + { + fields: [ 'videoId' ] + }, + { + fields: [ 'videoPlaylistId', 'videoId' ], + unique: true + }, + { + fields: [ 'videoPlaylistId', 'position' ], + unique: true + }, + { + fields: [ 'url' ], + unique: true + } + ] +}) +export class VideoPlaylistElementModel extends Model { + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @AllowNull(false) + @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max)) + url: string + + @AllowNull(false) + @Default(1) + @IsInt + @Min(1) + @Column + position: number + + @AllowNull(true) + @IsInt + @Min(0) + @Column + startTimestamp: number + + @AllowNull(true) + @IsInt + @Min(0) + @Column + stopTimestamp: number + + @ForeignKey(() => VideoPlaylistModel) + @Column + videoPlaylistId: number + + @BelongsTo(() => VideoPlaylistModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + VideoPlaylist: VideoPlaylistModel + + @ForeignKey(() => VideoModel) + @Column + videoId: number + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + Video: VideoModel + + static deleteAllOf (videoPlaylistId: number, transaction?: Sequelize.Transaction) { + const query = { + where: { + videoPlaylistId + }, + transaction + } + + return VideoPlaylistElementModel.destroy(query) + } + + static loadByPlaylistAndVideo (videoPlaylistId: number, videoId: number) { + const query = { + where: { + videoPlaylistId, + videoId + } + } + + return VideoPlaylistElementModel.findOne(query) + } + + static loadByPlaylistAndVideoForAP (playlistId: number | string, videoId: number | string) { + const playlistWhere = validator.isUUID('' + playlistId) ? { uuid: playlistId } : { id: playlistId } + const videoWhere = validator.isUUID('' + videoId) ? { uuid: videoId } : { id: videoId } + + const query = { + include: [ + { + attributes: [ 'privacy' ], + model: VideoPlaylistModel.unscoped(), + where: playlistWhere + }, + { + attributes: [ 'url' ], + model: VideoModel.unscoped(), + where: videoWhere + } + ] + } + + return VideoPlaylistElementModel.findOne(query) + } + + static listUrlsOfForAP (videoPlaylistId: number, start: number, count: number) { + const query = { + attributes: [ 'url' ], + offset: start, + limit: count, + order: getSort('position'), + where: { + videoPlaylistId + } + } + + return VideoPlaylistElementModel + .findAndCountAll(query) + .then(({ rows, count }) => { + return { total: count, data: rows.map(e => e.url) } + }) + } + + static getNextPositionOf (videoPlaylistId: number, transaction?: Sequelize.Transaction) { + const query = { + where: { + videoPlaylistId + }, + transaction + } + + return VideoPlaylistElementModel.max('position', query) + .then(position => position ? position + 1 : 1) + } + + static reassignPositionOf ( + videoPlaylistId: number, + firstPosition: number, + endPosition: number, + newPosition: number, + transaction?: Sequelize.Transaction + ) { + const query = { + where: { + videoPlaylistId, + position: { + [Sequelize.Op.gte]: firstPosition, + [Sequelize.Op.lte]: endPosition + } + }, + transaction + } + + return VideoPlaylistElementModel.update({ position: Sequelize.literal(`${newPosition} + "position" - ${firstPosition}`) }, query) + } + + static increasePositionOf ( + videoPlaylistId: number, + fromPosition: number, + toPosition?: number, + by = 1, + transaction?: Sequelize.Transaction + ) { + const query = { + where: { + videoPlaylistId, + position: { + [Sequelize.Op.gte]: fromPosition + } + }, + transaction + } + + return VideoPlaylistElementModel.increment({ position: by }, query) + } + + toActivityPubObject (): PlaylistElementObject { + const base: PlaylistElementObject = { + id: this.url, + type: 'PlaylistElement', + + url: this.Video.url, + position: this.position + } + + if (this.startTimestamp) base.startTimestamp = this.startTimestamp + if (this.stopTimestamp) base.stopTimestamp = this.stopTimestamp + + return base + } +} diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts new file mode 100644 index 000000000..93b8c2f58 --- /dev/null +++ b/server/models/video/video-playlist.ts @@ -0,0 +1,381 @@ +import { + AllowNull, + BeforeDestroy, + BelongsTo, + Column, + CreatedAt, + DataType, + Default, + ForeignKey, + HasMany, + Is, + IsUUID, + Model, + Scopes, + 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 { + 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 { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model' +import { AccountModel, ScopeNames as AccountScopeNames } 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' + +enum ScopeNames { + AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', + WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH', + WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL' +} + +type AvailableForListOptions = { + followerActorId: number + accountId?: number, + videoChannelId?: number + privateAndUnlisted?: boolean +} + +@Scopes({ + [ScopeNames.WITH_VIDEOS_LENGTH]: { + attributes: { + include: [ + [ + Sequelize.literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'), + 'videosLength' + ] + ] + } + }, + [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: { + include: [ + { + model: () => AccountModel.scope(AccountScopeNames.SUMMARY), + required: true + }, + { + model: () => VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY), + 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[] = [] + + if (options.privateAndUnlisted !== true) { + whereAnd.push({ + privacy: VideoPlaylistPrivacy.PUBLIC + }) + } + + if (options.accountId) { + whereAnd.push({ + ownerAccountId: options.accountId + }) + } + + if (options.videoChannelId) { + whereAnd.push({ + videoChannelId: options.videoChannelId + }) + } + + const where = { + [Sequelize.Op.and]: whereAnd + } + + const accountScope = { + method: [ AccountScopeNames.SUMMARY, actorWhere ] + } + + return { + where, + include: [ + { + model: AccountModel.scope(accountScope), + required: true + }, + { + model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY), + required: false + } + ] + } + } +}) + +@Table({ + tableName: 'videoPlaylist', + indexes: [ + { + fields: [ 'ownerAccountId' ] + }, + { + fields: [ 'videoChannelId' ] + }, + { + fields: [ 'url' ], + unique: true + } + ] +}) +export class VideoPlaylistModel extends Model { + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @AllowNull(false) + @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name')) + @Column + name: string + + @AllowNull(true) + @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description')) + @Column + description: string + + @AllowNull(false) + @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy')) + @Column + privacy: VideoPlaylistPrivacy + + @AllowNull(false) + @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max)) + url: string + + @AllowNull(false) + @Default(DataType.UUIDV4) + @IsUUID(4) + @Column(DataType.UUID) + uuid: string + + @ForeignKey(() => AccountModel) + @Column + ownerAccountId: number + + @BelongsTo(() => AccountModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + OwnerAccount: AccountModel + + @ForeignKey(() => VideoChannelModel) + @Column + videoChannelId: number + + @BelongsTo(() => VideoChannelModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + VideoChannel: VideoChannelModel + + @HasMany(() => VideoPlaylistElementModel, { + foreignKey: { + name: 'videoPlaylistId', + allowNull: false + }, + 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() + } + + static listForApi (options: { + followerActorId: number + start: number, + count: number, + sort: string, + accountId?: number, + videoChannelId?: number, + privateAndUnlisted?: boolean + }) { + const query = { + offset: options.start, + limit: options.count, + order: getSort(options.sort) + } + + const scopes = [ + { + method: [ + ScopeNames.AVAILABLE_FOR_LIST, + { + followerActorId: options.followerActorId, + accountId: options.accountId, + videoChannelId: options.videoChannelId, + privateAndUnlisted: options.privateAndUnlisted + } as AvailableForListOptions + ] + } as any, // FIXME: typings + ScopeNames.WITH_VIDEOS_LENGTH + ] + + return VideoPlaylistModel + .scope(scopes) + .findAndCountAll(query) + .then(({ rows, count }) => { + return { total: count, data: rows } + }) + } + + static listUrlsOfForAP (accountId: number, start: number, count: number) { + const query = { + attributes: [ 'url' ], + offset: start, + limit: count, + where: { + ownerAccountId: accountId + } + } + + return VideoPlaylistModel.findAndCountAll(query) + .then(({ rows, count }) => { + return { total: count, data: rows.map(p => p.url) } + }) + } + + static doesPlaylistExist (url: string) { + const query = { + attributes: [], + where: { + url + } + } + + return VideoPlaylistModel + .findOne(query) + .then(e => !!e) + } + + static load (id: number | string, transaction: Sequelize.Transaction) { + const where = buildWhereIdOrUUID(id) + + const query = { + where, + transaction + } + + return VideoPlaylistModel + .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ]) + .findOne(query) + } + + static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { + return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown' + } + + getThumbnailName () { + const extension = '.jpg' + + return 'playlist-' + this.uuid + extension + } + + getThumbnailUrl () { + return CONFIG.WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() + } + + getThumbnailStaticPath () { + return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) + } + + removeThumbnail () { + const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) + return remove(thumbnailPath) + .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err })) + } + + isOwned () { + return this.OwnerAccount.isOwned() + } + + toFormattedJSON (): VideoPlaylist { + return { + id: this.id, + uuid: this.uuid, + isLocal: this.isOwned(), + + displayName: this.name, + description: this.description, + privacy: { + id: this.privacy, + label: VideoPlaylistModel.getPrivacyLabel(this.privacy) + }, + + thumbnailPath: this.getThumbnailStaticPath(), + + videosLength: this.videosLength, + + createdAt: this.createdAt, + updatedAt: this.updatedAt, + + ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(), + videoChannel: this.VideoChannel.toFormattedSummaryJSON() + } + } + + toActivityPubObject (): Promise { + const handler = (start: number, count: number) => { + return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count) + } + + return activityPubCollectionPagination(this.url, handler, null) + .then(o => { + return Object.assign(o, { + type: 'Playlist' as 'Playlist', + name: this.name, + content: this.description, + uuid: this.uuid, + 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 + } + }) + }) + } +} diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 4516b9c7b..7a102b058 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -40,7 +40,7 @@ import { isVideoDurationValid, isVideoLanguageValid, isVideoLicenceValid, - isVideoNameValid, isVideoOriginallyPublishedAtValid, + isVideoNameValid, isVideoPrivacyValid, isVideoStateValid, isVideoSupportValid @@ -52,7 +52,9 @@ import { ACTIVITY_PUB, API_VERSION, CONFIG, - CONSTRAINTS_FIELDS, HLS_PLAYLIST_DIRECTORY, HLS_REDUNDANCY_DIRECTORY, + CONSTRAINTS_FIELDS, + HLS_PLAYLIST_DIRECTORY, + HLS_REDUNDANCY_DIRECTORY, PREVIEWS_SIZE, REMOTE_SCHEME, STATIC_DOWNLOAD_PATHS, @@ -70,10 +72,17 @@ import { AccountVideoRateModel } from '../account/account-video-rate' import { ActorModel } from '../activitypub/actor' import { AvatarModel } from '../avatar/avatar' import { ServerModel } from '../server/server' -import { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils' +import { + buildBlockedAccountSQL, + buildTrigramSearchIndex, + buildWhereIdOrUUID, + createSimilarityAttribute, + getVideoSort, + throwIfNotValid +} from '../utils' import { TagModel } from './tag' import { VideoAbuseModel } from './video-abuse' -import { VideoChannelModel } from './video-channel' +import { VideoChannelModel, ScopeNames as VideoChannelScopeNames } from './video-channel' import { VideoCommentModel } from './video-comment' import { VideoFileModel } from './video-file' import { VideoShareModel } from './video-share' @@ -91,11 +100,11 @@ import { videoModelToFormattedDetailsJSON, videoModelToFormattedJSON } from './video-format-utils' -import * as validator from 'validator' import { UserVideoHistoryModel } from '../account/user-video-history' import { UserModel } from '../account/user' import { VideoImportModel } from './video-import' import { VideoStreamingPlaylistModel } from './video-streaming-playlist' +import { VideoPlaylistElementModel } from './video-playlist-element' // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation const indexes: Sequelize.DefineIndexesOptions[] = [ @@ -175,6 +184,9 @@ export enum ScopeNames { type ForAPIOptions = { ids: number[] + + videoPlaylistId?: number + withFiles?: boolean } @@ -182,6 +194,7 @@ type AvailableForListIDsOptions = { serverAccountId: number followerActorId: number includeLocalVideos: boolean + filter?: VideoFilter categoryOneOf?: number[] nsfw?: boolean @@ -189,9 +202,14 @@ type AvailableForListIDsOptions = { languageOneOf?: string[] tagsOneOf?: string[] tagsAllOf?: string[] + withFiles?: boolean + accountId?: number videoChannelId?: number + + videoPlaylistId?: number + trendingDays?: number user?: UserModel, historyOfUser?: UserModel @@ -199,62 +217,17 @@ type AvailableForListIDsOptions = { @Scopes({ [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => { - const accountInclude = { - attributes: [ 'id', 'name' ], - model: AccountModel.unscoped(), - required: true, - include: [ - { - attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], - model: ActorModel.unscoped(), - required: true, - include: [ - { - attributes: [ 'host' ], - model: ServerModel.unscoped(), - required: false - }, - { - model: AvatarModel.unscoped(), - required: false - } - ] - } - ] - } - - const videoChannelInclude = { - attributes: [ 'name', 'description', 'id' ], - model: VideoChannelModel.unscoped(), - required: true, - include: [ - { - attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], - model: ActorModel.unscoped(), - required: true, - include: [ - { - attributes: [ 'host' ], - model: ServerModel.unscoped(), - required: false - }, - { - model: AvatarModel.unscoped(), - required: false - } - ] - }, - accountInclude - ] - } - const query: IFindOptions = { where: { id: { [ Sequelize.Op.any ]: options.ids } }, - include: [ videoChannelInclude ] + include: [ + { + model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY) + } + ] } if (options.withFiles === true) { @@ -264,6 +237,13 @@ type AvailableForListIDsOptions = { }) } + if (options.videoPlaylistId) { + query.include.push({ + model: VideoPlaylistElementModel.unscoped(), + required: true + }) + } + return query }, [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => { @@ -315,6 +295,17 @@ type AvailableForListIDsOptions = { Object.assign(query.where, privacyWhere) } + if (options.videoPlaylistId) { + query.include.push({ + attributes: [], + model: VideoPlaylistElementModel.unscoped(), + required: true, + where: { + videoPlaylistId: options.videoPlaylistId + } + }) + } + if (options.filter || options.accountId || options.videoChannelId) { const videoChannelInclude: IIncludeOptions = { attributes: [], @@ -772,6 +763,15 @@ export class VideoModel extends Model { }) Tags: TagModel[] + @HasMany(() => VideoPlaylistElementModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + VideoPlaylistElements: VideoPlaylistElementModel[] + @HasMany(() => VideoAbuseModel, { foreignKey: { name: 'videoId', @@ -1118,6 +1118,7 @@ export class VideoModel extends Model { accountId?: number, videoChannelId?: number, followerActorId?: number + videoPlaylistId?: number, trendingDays?: number, user?: UserModel, historyOfUser?: UserModel @@ -1157,6 +1158,7 @@ export class VideoModel extends Model { withFiles: options.withFiles, accountId: options.accountId, videoChannelId: options.videoChannelId, + videoPlaylistId: options.videoPlaylistId, includeLocalVideos: options.includeLocalVideos, user: options.user, historyOfUser: options.historyOfUser, @@ -1280,7 +1282,7 @@ export class VideoModel extends Model { } static load (id: number | string, t?: Sequelize.Transaction) { - const where = VideoModel.buildWhereIdOrUUID(id) + const where = buildWhereIdOrUUID(id) const options = { where, transaction: t @@ -1290,7 +1292,7 @@ export class VideoModel extends Model { } static loadWithRights (id: number | string, t?: Sequelize.Transaction) { - const where = VideoModel.buildWhereIdOrUUID(id) + const where = buildWhereIdOrUUID(id) const options = { where, transaction: t @@ -1300,7 +1302,7 @@ export class VideoModel extends Model { } static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { - const where = VideoModel.buildWhereIdOrUUID(id) + const where = buildWhereIdOrUUID(id) const options = { attributes: [ 'id' ], @@ -1353,7 +1355,7 @@ export class VideoModel extends Model { } static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { - const where = VideoModel.buildWhereIdOrUUID(id) + const where = buildWhereIdOrUUID(id) const options = { order: [ [ 'Tags', 'name', 'ASC' ] ], @@ -1380,7 +1382,7 @@ export class VideoModel extends Model { } static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) { - const where = VideoModel.buildWhereIdOrUUID(id) + const where = buildWhereIdOrUUID(id) const options = { order: [ [ 'Tags', 'name', 'ASC' ] ], @@ -1582,10 +1584,6 @@ export class VideoModel extends Model { return VIDEO_STATES[ id ] || 'Unknown' } - static buildWhereIdOrUUID (id: number | string) { - return validator.isInt('' + id) ? { id } : { uuid: id } - } - getOriginalFile () { if (Array.isArray(this.VideoFiles) === false) return undefined @@ -1598,7 +1596,6 @@ export class VideoModel extends Model { } getThumbnailName () { - // We always have a copy of the thumbnail const extension = '.jpg' return this.uuid + extension } -- cgit v1.2.3