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'
32 VIDEO_PLAYLIST_PRIVACIES,
34 } from '../../initializers'
35 import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
36 import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
37 import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
38 import { join } from 'path'
39 import { VideoPlaylistElementModel } from './video-playlist-element'
40 import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
41 import { activityPubCollectionPagination } from '../../helpers/activitypub'
42 import { remove } from 'fs-extra'
43 import { logger } from '../../helpers/logger'
44 import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
47 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
48 WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
49 WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY',
50 WITH_ACCOUNT = 'WITH_ACCOUNT',
51 WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
54 type AvailableForListOptions = {
55 followerActorId: number
56 type?: VideoPlaylistType
58 videoChannelId?: number
59 privateAndUnlisted?: boolean
63 [ ScopeNames.WITH_VIDEOS_LENGTH ]: {
67 Sequelize.literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
73 [ ScopeNames.WITH_ACCOUNT ]: {
76 model: () => AccountModel,
81 [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY ]: {
84 model: () => AccountModel.scope(AccountScopeNames.SUMMARY),
88 model: () => VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
93 [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL ]: {
96 model: () => AccountModel,
100 model: () => VideoChannelModel,
105 [ ScopeNames.AVAILABLE_FOR_LIST ]: (options: AvailableForListOptions) => {
106 // Only list local playlists OR playlists that are on an instance followed by actorId
107 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
109 [ Sequelize.Op.or ]: [
115 [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow)
121 const whereAnd: any[] = []
123 if (options.privateAndUnlisted !== true) {
125 privacy: VideoPlaylistPrivacy.PUBLIC
129 if (options.accountId) {
131 ownerAccountId: options.accountId
135 if (options.videoChannelId) {
137 videoChannelId: options.videoChannelId
148 [Sequelize.Op.and]: whereAnd
151 const accountScope = {
152 method: [ AccountScopeNames.SUMMARY, actorWhere ]
159 model: AccountModel.scope(accountScope),
163 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
172 tableName: 'videoPlaylist',
175 fields: [ 'ownerAccountId' ]
178 fields: [ 'videoChannelId' ]
186 export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
194 @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name'))
199 @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description'))
204 @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy'))
206 privacy: VideoPlaylistPrivacy
209 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
210 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
214 @Default(DataType.UUIDV4)
216 @Column(DataType.UUID)
220 @Default(VideoPlaylistType.REGULAR)
222 type: VideoPlaylistType
224 @ForeignKey(() => AccountModel)
226 ownerAccountId: number
228 @BelongsTo(() => AccountModel, {
234 OwnerAccount: AccountModel
236 @ForeignKey(() => VideoChannelModel)
238 videoChannelId: number
240 @BelongsTo(() => VideoChannelModel, {
246 VideoChannel: VideoChannelModel
248 @HasMany(() => VideoPlaylistElementModel, {
250 name: 'videoPlaylistId',
255 VideoPlaylistElements: VideoPlaylistElementModel[]
258 static async removeFiles (instance: VideoPlaylistModel) {
259 logger.info('Removing files of video playlist %s.', instance.url)
261 return instance.removeThumbnail()
264 static listForApi (options: {
265 followerActorId: number
269 type?: VideoPlaylistType,
271 videoChannelId?: number,
272 privateAndUnlisted?: boolean
275 offset: options.start,
276 limit: options.count,
277 order: getSort(options.sort)
283 ScopeNames.AVAILABLE_FOR_LIST,
286 followerActorId: options.followerActorId,
287 accountId: options.accountId,
288 videoChannelId: options.videoChannelId,
289 privateAndUnlisted: options.privateAndUnlisted
290 } as AvailableForListOptions
292 } as any, // FIXME: typings
293 ScopeNames.WITH_VIDEOS_LENGTH
296 return VideoPlaylistModel
298 .findAndCountAll(query)
299 .then(({ rows, count }) => {
300 return { total: count, data: rows }
304 static listUrlsOfForAP (accountId: number, start: number, count: number) {
306 attributes: [ 'url' ],
310 ownerAccountId: accountId
314 return VideoPlaylistModel.findAndCountAll(query)
315 .then(({ rows, count }) => {
316 return { total: count, data: rows.map(p => p.url) }
320 static doesPlaylistExist (url: string) {
328 return VideoPlaylistModel
333 static loadWithAccountAndChannelSummary (id: number | string, transaction: Sequelize.Transaction) {
334 const where = buildWhereIdOrUUID(id)
341 return VideoPlaylistModel
342 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH ])
346 static loadWithAccountAndChannel (id: number | string, transaction: Sequelize.Transaction) {
347 const where = buildWhereIdOrUUID(id)
354 return VideoPlaylistModel
355 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ])
359 static loadByUrlAndPopulateAccount (url: string) {
366 return VideoPlaylistModel.scope(ScopeNames.WITH_ACCOUNT).findOne(query)
369 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
370 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
373 static getTypeLabel (type: VideoPlaylistType) {
374 return VIDEO_PLAYLIST_TYPES[type] || 'Unknown'
377 static resetPlaylistsOfChannel (videoChannelId: number, transaction: Sequelize.Transaction) {
385 return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
388 getThumbnailName () {
389 const extension = '.jpg'
391 return 'playlist-' + this.uuid + extension
395 return CONFIG.WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
398 getThumbnailStaticPath () {
399 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
403 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
404 return remove(thumbnailPath)
405 .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
409 return this.OwnerAccount.isOwned()
412 toFormattedJSON (): VideoPlaylist {
416 isLocal: this.isOwned(),
418 displayName: this.name,
419 description: this.description,
422 label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
425 thumbnailPath: this.getThumbnailStaticPath(),
429 label: VideoPlaylistModel.getTypeLabel(this.type)
432 videosLength: this.get('videosLength'),
434 createdAt: this.createdAt,
435 updatedAt: this.updatedAt,
437 ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
438 videoChannel: this.VideoChannel ? this.VideoChannel.toFormattedSummaryJSON() : null
442 toActivityPubObject (page: number, t: Sequelize.Transaction): Promise<PlaylistObject> {
443 const handler = (start: number, count: number) => {
444 return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
447 return activityPubCollectionPagination(this.url, handler, page)
449 return Object.assign(o, {
450 type: 'Playlist' as 'Playlist',
452 content: this.description,
454 published: this.createdAt.toISOString(),
455 updated: this.updatedAt.toISOString(),
456 attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
458 type: 'Image' as 'Image',
459 url: this.getThumbnailUrl(),
460 mediaType: 'image/jpeg' as 'image/jpeg',
461 width: THUMBNAILS_SIZE.width,
462 height: THUMBNAILS_SIZE.height