17 } from 'sequelize-typescript'
18 import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
19 import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, isOutdated, throwIfNotValid } from '../utils'
21 isVideoPlaylistDescriptionValid,
22 isVideoPlaylistNameValid,
23 isVideoPlaylistPrivacyValid
24 } from '../../helpers/custom-validators/video-playlists'
25 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
31 VIDEO_PLAYLIST_PRIVACIES,
34 } from '../../initializers/constants'
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 { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
43 import { ThumbnailModel } from './thumbnail'
44 import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
45 import { fn, literal, Op, Transaction } from 'sequelize'
48 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
49 WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
50 WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY',
51 WITH_ACCOUNT = 'WITH_ACCOUNT',
52 WITH_THUMBNAIL = 'WITH_THUMBNAIL',
53 WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
56 type AvailableForListOptions = {
57 followerActorId: number
58 type?: VideoPlaylistType
60 videoChannelId?: number
61 privateAndUnlisted?: boolean
65 [ ScopeNames.WITH_THUMBNAIL ]: {
68 model: () => ThumbnailModel,
73 [ ScopeNames.WITH_VIDEOS_LENGTH ]: {
81 literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
87 [ ScopeNames.WITH_ACCOUNT ]: {
90 model: () => AccountModel,
95 [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY ]: {
98 model: () => AccountModel.scope(AccountScopeNames.SUMMARY),
102 model: () => VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
107 [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL ]: {
110 model: () => AccountModel,
114 model: () => VideoChannelModel,
119 [ ScopeNames.AVAILABLE_FOR_LIST ]: (options: AvailableForListOptions) => {
120 // Only list local playlists OR playlists that are on an instance followed by actorId
121 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
129 [ Op.in ]: literal(inQueryInstanceFollow)
135 const whereAnd: any[] = []
137 if (options.privateAndUnlisted !== true) {
139 privacy: VideoPlaylistPrivacy.PUBLIC
143 if (options.accountId) {
145 ownerAccountId: options.accountId
149 if (options.videoChannelId) {
151 videoChannelId: options.videoChannelId
165 const accountScope = {
166 method: [ AccountScopeNames.SUMMARY, actorWhere ]
173 model: AccountModel.scope(accountScope),
177 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
186 tableName: 'videoPlaylist',
189 fields: [ 'ownerAccountId' ]
192 fields: [ 'videoChannelId' ]
200 export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
208 @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name'))
213 @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true))
218 @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy'))
220 privacy: VideoPlaylistPrivacy
223 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
224 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
228 @Default(DataType.UUIDV4)
230 @Column(DataType.UUID)
234 @Default(VideoPlaylistType.REGULAR)
236 type: VideoPlaylistType
238 @ForeignKey(() => AccountModel)
240 ownerAccountId: number
242 @BelongsTo(() => AccountModel, {
248 OwnerAccount: AccountModel
250 @ForeignKey(() => VideoChannelModel)
252 videoChannelId: number
254 @BelongsTo(() => VideoChannelModel, {
260 VideoChannel: VideoChannelModel
262 @HasMany(() => VideoPlaylistElementModel, {
264 name: 'videoPlaylistId',
269 VideoPlaylistElements: VideoPlaylistElementModel[]
271 @HasOne(() => ThumbnailModel, {
273 name: 'videoPlaylistId',
279 Thumbnail: ThumbnailModel
281 static listForApi (options: {
282 followerActorId: number
286 type?: VideoPlaylistType,
288 videoChannelId?: number,
289 privateAndUnlisted?: boolean
292 offset: options.start,
293 limit: options.count,
294 order: getSort(options.sort)
300 ScopeNames.AVAILABLE_FOR_LIST,
303 followerActorId: options.followerActorId,
304 accountId: options.accountId,
305 videoChannelId: options.videoChannelId,
306 privateAndUnlisted: options.privateAndUnlisted
307 } as AvailableForListOptions
309 } as any, // FIXME: typings
310 ScopeNames.WITH_VIDEOS_LENGTH,
311 ScopeNames.WITH_THUMBNAIL
314 return VideoPlaylistModel
316 .findAndCountAll(query)
317 .then(({ rows, count }) => {
318 return { total: count, data: rows }
322 static listPublicUrlsOfForAP (accountId: number, start: number, count: number) {
324 attributes: [ 'url' ],
328 ownerAccountId: accountId,
329 privacy: VideoPlaylistPrivacy.PUBLIC
333 return VideoPlaylistModel.findAndCountAll(query)
334 .then(({ rows, count }) => {
335 return { total: count, data: rows.map(p => p.url) }
339 static listPlaylistIdsOf (accountId: number, videoIds: number[]) {
341 attributes: [ 'id' ],
343 ownerAccountId: accountId
347 attributes: [ 'videoId', 'startTimestamp', 'stopTimestamp' ],
348 model: VideoPlaylistElementModel.unscoped(),
359 return VideoPlaylistModel.findAll(query)
362 static doesPlaylistExist (url: string) {
370 return VideoPlaylistModel
375 static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction) {
376 const where = buildWhereIdOrUUID(id)
383 return VideoPlaylistModel
384 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
388 static loadWithAccountAndChannel (id: number | string, transaction: Transaction) {
389 const where = buildWhereIdOrUUID(id)
396 return VideoPlaylistModel
397 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
401 static loadByUrlAndPopulateAccount (url: string) {
408 return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
411 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
412 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
415 static getTypeLabel (type: VideoPlaylistType) {
416 return VIDEO_PLAYLIST_TYPES[type] || 'Unknown'
419 static resetPlaylistsOfChannel (videoChannelId: number, transaction: Transaction) {
427 return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
430 setThumbnail (thumbnail: ThumbnailModel) {
431 this.Thumbnail = thumbnail
435 return this.Thumbnail
439 return !!this.Thumbnail
442 generateThumbnailName () {
443 const extension = '.jpg'
445 return 'playlist-' + this.uuid + extension
449 if (!this.hasThumbnail()) return null
451 return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnail().filename
454 getThumbnailStaticPath () {
455 if (!this.hasThumbnail()) return null
457 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnail().filename)
461 this.changed('updatedAt', true)
467 return this.OwnerAccount.isOwned()
471 if (this.isOwned()) return false
473 return isOutdated(this, ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL)
476 toFormattedJSON (): VideoPlaylist {
480 isLocal: this.isOwned(),
482 displayName: this.name,
483 description: this.description,
486 label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
489 thumbnailPath: this.getThumbnailStaticPath(),
493 label: VideoPlaylistModel.getTypeLabel(this.type)
496 videosLength: this.get('videosLength') as number,
498 createdAt: this.createdAt,
499 updatedAt: this.updatedAt,
501 ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
502 videoChannel: this.VideoChannel ? this.VideoChannel.toFormattedSummaryJSON() : null
506 toActivityPubObject (page: number, t: Transaction): Promise<PlaylistObject> {
507 const handler = (start: number, count: number) => {
508 return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
511 let icon: ActivityIconObject
512 if (this.hasThumbnail()) {
514 type: 'Image' as 'Image',
515 url: this.getThumbnailUrl(),
516 mediaType: 'image/jpeg' as 'image/jpeg',
517 width: THUMBNAILS_SIZE.width,
518 height: THUMBNAILS_SIZE.height
522 return activityPubCollectionPagination(this.url, handler, page)
524 return Object.assign(o, {
525 type: 'Playlist' as 'Playlist',
527 content: this.description,
529 published: this.createdAt.toISOString(),
530 updated: this.updatedAt.toISOString(),
531 attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],