17 } from 'sequelize-typescript'
18 import * as Sequelize from 'sequelize'
19 import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
20 import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, throwIfNotValid } from '../utils'
22 isVideoPlaylistDescriptionValid,
23 isVideoPlaylistNameValid,
24 isVideoPlaylistPrivacyValid
25 } from '../../helpers/custom-validators/video-playlists'
26 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
27 import { CONFIG, CONSTRAINTS_FIELDS, STATIC_PATHS, THUMBNAILS_SIZE, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers'
28 import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
29 import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
30 import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
31 import { join } from 'path'
32 import { VideoPlaylistElementModel } from './video-playlist-element'
33 import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
34 import { activityPubCollectionPagination } from '../../helpers/activitypub'
35 import { remove } from 'fs-extra'
36 import { logger } from '../../helpers/logger'
39 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
40 WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
41 WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
44 type AvailableForListOptions = {
45 followerActorId: number
47 videoChannelId?: number
48 privateAndUnlisted?: boolean
52 [ScopeNames.WITH_VIDEOS_LENGTH]: {
56 Sequelize.literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
62 [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: {
65 model: () => AccountModel.scope(AccountScopeNames.SUMMARY),
69 model: () => VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
74 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
75 // Only list local playlists OR playlists that are on an instance followed by actorId
76 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
78 [ Sequelize.Op.or ]: [
84 [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow)
90 const whereAnd: any[] = []
92 if (options.privateAndUnlisted !== true) {
94 privacy: VideoPlaylistPrivacy.PUBLIC
98 if (options.accountId) {
100 ownerAccountId: options.accountId
104 if (options.videoChannelId) {
106 videoChannelId: options.videoChannelId
111 [Sequelize.Op.and]: whereAnd
114 const accountScope = {
115 method: [ AccountScopeNames.SUMMARY, actorWhere ]
122 model: AccountModel.scope(accountScope),
126 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
135 tableName: 'videoPlaylist',
138 fields: [ 'ownerAccountId' ]
141 fields: [ 'videoChannelId' ]
149 export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
157 @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name'))
162 @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description'))
167 @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy'))
169 privacy: VideoPlaylistPrivacy
172 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
173 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
177 @Default(DataType.UUIDV4)
179 @Column(DataType.UUID)
182 @ForeignKey(() => AccountModel)
184 ownerAccountId: number
186 @BelongsTo(() => AccountModel, {
192 OwnerAccount: AccountModel
194 @ForeignKey(() => VideoChannelModel)
196 videoChannelId: number
198 @BelongsTo(() => VideoChannelModel, {
204 VideoChannel: VideoChannelModel
206 @HasMany(() => VideoPlaylistElementModel, {
208 name: 'videoPlaylistId',
213 VideoPlaylistElements: VideoPlaylistElementModel[]
216 videosLength?: number
219 static async removeFiles (instance: VideoPlaylistModel) {
220 logger.info('Removing files of video playlist %s.', instance.url)
222 return instance.removeThumbnail()
225 static listForApi (options: {
226 followerActorId: number
231 videoChannelId?: number,
232 privateAndUnlisted?: boolean
235 offset: options.start,
236 limit: options.count,
237 order: getSort(options.sort)
243 ScopeNames.AVAILABLE_FOR_LIST,
245 followerActorId: options.followerActorId,
246 accountId: options.accountId,
247 videoChannelId: options.videoChannelId,
248 privateAndUnlisted: options.privateAndUnlisted
249 } as AvailableForListOptions
251 } as any, // FIXME: typings
252 ScopeNames.WITH_VIDEOS_LENGTH
255 return VideoPlaylistModel
257 .findAndCountAll(query)
258 .then(({ rows, count }) => {
259 return { total: count, data: rows }
263 static listUrlsOfForAP (accountId: number, start: number, count: number) {
265 attributes: [ 'url' ],
269 ownerAccountId: accountId
273 return VideoPlaylistModel.findAndCountAll(query)
274 .then(({ rows, count }) => {
275 return { total: count, data: rows.map(p => p.url) }
279 static doesPlaylistExist (url: string) {
287 return VideoPlaylistModel
292 static load (id: number | string, transaction: Sequelize.Transaction) {
293 const where = buildWhereIdOrUUID(id)
300 return VideoPlaylistModel
301 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ])
305 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
306 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
309 getThumbnailName () {
310 const extension = '.jpg'
312 return 'playlist-' + this.uuid + extension
316 return CONFIG.WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
319 getThumbnailStaticPath () {
320 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
324 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
325 return remove(thumbnailPath)
326 .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
330 return this.OwnerAccount.isOwned()
333 toFormattedJSON (): VideoPlaylist {
337 isLocal: this.isOwned(),
339 displayName: this.name,
340 description: this.description,
343 label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
346 thumbnailPath: this.getThumbnailStaticPath(),
348 videosLength: this.videosLength,
350 createdAt: this.createdAt,
351 updatedAt: this.updatedAt,
353 ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
354 videoChannel: this.VideoChannel ? this.VideoChannel.toFormattedSummaryJSON() : null
358 toActivityPubObject (): Promise<PlaylistObject> {
359 const handler = (start: number, count: number) => {
360 return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count)
363 return activityPubCollectionPagination(this.url, handler, null)
365 return Object.assign(o, {
366 type: 'Playlist' as 'Playlist',
368 content: this.description,
370 attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
372 type: 'Image' as 'Image',
373 url: this.getThumbnailUrl(),
374 mediaType: 'image/jpeg' as 'image/jpeg',
375 width: THUMBNAILS_SIZE.width,
376 height: THUMBNAILS_SIZE.height