]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/video/video-playlist.ts
Lazy load avatars
[github/Chocobozzz/PeerTube.git] / server / models / video / video-playlist.ts
CommitLineData
418d092a
C
1import {
2 AllowNull,
418d092a
C
3 BelongsTo,
4 Column,
5 CreatedAt,
6 DataType,
7 Default,
8 ForeignKey,
9 HasMany,
e8bafea3 10 HasOne,
418d092a
C
11 Is,
12 IsUUID,
13 Model,
14 Scopes,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
418d092a 18import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
9f79ade6 19import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, isOutdated, throwIfNotValid } from '../utils'
418d092a
C
20import {
21 isVideoPlaylistDescriptionValid,
22 isVideoPlaylistNameValid,
23 isVideoPlaylistPrivacyValid
24} from '../../helpers/custom-validators/video-playlists'
25import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
df0b219d 26import {
9f79ade6 27 ACTIVITY_PUB,
df0b219d
C
28 CONSTRAINTS_FIELDS,
29 STATIC_PATHS,
30 THUMBNAILS_SIZE,
31 VIDEO_PLAYLIST_PRIVACIES,
6dd9de95
C
32 VIDEO_PLAYLIST_TYPES,
33 WEBSERVER
74dc3bca 34} from '../../initializers/constants'
418d092a 35import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
bfbd9128 36import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
418d092a
C
37import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
38import { join } from 'path'
39import { VideoPlaylistElementModel } from './video-playlist-element'
40import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
41import { activityPubCollectionPagination } from '../../helpers/activitypub'
df0b219d 42import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
e8bafea3
C
43import { ThumbnailModel } from './thumbnail'
44import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
3acc5084 45import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
418d092a
C
46
47enum ScopeNames {
48 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
49 WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
df0b219d 50 WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY',
09979f89 51 WITH_ACCOUNT = 'WITH_ACCOUNT',
e8bafea3 52 WITH_THUMBNAIL = 'WITH_THUMBNAIL',
09979f89 53 WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
418d092a
C
54}
55
56type AvailableForListOptions = {
57 followerActorId: number
df0b219d
C
58 type?: VideoPlaylistType
59 accountId?: number
418d092a
C
60 videoChannelId?: number
61 privateAndUnlisted?: boolean
62}
63
3acc5084 64@Scopes(() => ({
e8bafea3
C
65 [ ScopeNames.WITH_THUMBNAIL ]: {
66 include: [
67 {
3acc5084 68 model: ThumbnailModel,
e8bafea3
C
69 required: false
70 }
71 ]
72 },
df0b219d 73 [ ScopeNames.WITH_VIDEOS_LENGTH ]: {
418d092a
C
74 attributes: {
75 include: [
1735c825
C
76 [
77 literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
418d092a
C
78 'videosLength'
79 ]
80 ]
81 }
3acc5084 82 } as FindOptions,
df0b219d
C
83 [ ScopeNames.WITH_ACCOUNT ]: {
84 include: [
85 {
3acc5084 86 model: AccountModel,
df0b219d
C
87 required: true
88 }
89 ]
90 },
91 [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY ]: {
418d092a
C
92 include: [
93 {
3acc5084 94 model: AccountModel.scope(AccountScopeNames.SUMMARY),
418d092a
C
95 required: true
96 },
97 {
3acc5084 98 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
418d092a
C
99 required: false
100 }
101 ]
102 },
09979f89
C
103 [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL ]: {
104 include: [
105 {
3acc5084 106 model: AccountModel,
09979f89
C
107 required: true
108 },
109 {
3acc5084 110 model: VideoChannelModel,
09979f89
C
111 required: false
112 }
113 ]
114 },
df0b219d 115 [ ScopeNames.AVAILABLE_FOR_LIST ]: (options: AvailableForListOptions) => {
418d092a
C
116 // Only list local playlists OR playlists that are on an instance followed by actorId
117 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
bfbd9128 118 const whereActor = {
1735c825 119 [ Op.or ]: [
418d092a
C
120 {
121 serverId: null
122 },
123 {
124 serverId: {
1735c825 125 [ Op.in ]: literal(inQueryInstanceFollow)
418d092a
C
126 }
127 }
128 ]
129 }
130
3acc5084 131 const whereAnd: WhereOptions[] = []
418d092a
C
132
133 if (options.privateAndUnlisted !== true) {
134 whereAnd.push({
135 privacy: VideoPlaylistPrivacy.PUBLIC
136 })
137 }
138
139 if (options.accountId) {
140 whereAnd.push({
141 ownerAccountId: options.accountId
142 })
143 }
144
145 if (options.videoChannelId) {
146 whereAnd.push({
147 videoChannelId: options.videoChannelId
148 })
149 }
150
df0b219d
C
151 if (options.type) {
152 whereAnd.push({
153 type: options.type
154 })
155 }
156
418d092a 157 const where = {
1735c825 158 [Op.and]: whereAnd
418d092a
C
159 }
160
161 const accountScope = {
bfbd9128 162 method: [ AccountScopeNames.SUMMARY, { whereActor } as SummaryOptions ]
418d092a
C
163 }
164
165 return {
166 where,
167 include: [
168 {
169 model: AccountModel.scope(accountScope),
170 required: true
171 },
172 {
173 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
174 required: false
175 }
176 ]
3acc5084 177 } as FindOptions
418d092a 178 }
3acc5084 179}))
418d092a
C
180
181@Table({
182 tableName: 'videoPlaylist',
183 indexes: [
184 {
185 fields: [ 'ownerAccountId' ]
186 },
187 {
188 fields: [ 'videoChannelId' ]
189 },
190 {
191 fields: [ 'url' ],
192 unique: true
193 }
194 ]
195})
196export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
197 @CreatedAt
198 createdAt: Date
199
200 @UpdatedAt
201 updatedAt: Date
202
203 @AllowNull(false)
204 @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name'))
205 @Column
206 name: string
207
208 @AllowNull(true)
1735c825 209 @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true))
418d092a
C
210 @Column
211 description: string
212
213 @AllowNull(false)
214 @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy'))
215 @Column
216 privacy: VideoPlaylistPrivacy
217
218 @AllowNull(false)
219 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
220 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
221 url: string
222
223 @AllowNull(false)
224 @Default(DataType.UUIDV4)
225 @IsUUID(4)
226 @Column(DataType.UUID)
227 uuid: string
228
df0b219d
C
229 @AllowNull(false)
230 @Default(VideoPlaylistType.REGULAR)
231 @Column
232 type: VideoPlaylistType
233
418d092a
C
234 @ForeignKey(() => AccountModel)
235 @Column
236 ownerAccountId: number
237
238 @BelongsTo(() => AccountModel, {
239 foreignKey: {
240 allowNull: false
241 },
242 onDelete: 'CASCADE'
243 })
244 OwnerAccount: AccountModel
245
246 @ForeignKey(() => VideoChannelModel)
247 @Column
248 videoChannelId: number
249
250 @BelongsTo(() => VideoChannelModel, {
251 foreignKey: {
07b1a18a 252 allowNull: true
418d092a
C
253 },
254 onDelete: 'CASCADE'
255 })
256 VideoChannel: VideoChannelModel
257
258 @HasMany(() => VideoPlaylistElementModel, {
259 foreignKey: {
260 name: 'videoPlaylistId',
261 allowNull: false
262 },
df0b219d 263 onDelete: 'CASCADE'
418d092a
C
264 })
265 VideoPlaylistElements: VideoPlaylistElementModel[]
266
e8bafea3
C
267 @HasOne(() => ThumbnailModel, {
268 foreignKey: {
269 name: 'videoPlaylistId',
270 allowNull: true
271 },
272 onDelete: 'CASCADE',
273 hooks: true
274 })
275 Thumbnail: ThumbnailModel
418d092a
C
276
277 static listForApi (options: {
278 followerActorId: number
279 start: number,
280 count: number,
281 sort: string,
df0b219d 282 type?: VideoPlaylistType,
418d092a
C
283 accountId?: number,
284 videoChannelId?: number,
285 privateAndUnlisted?: boolean
286 }) {
287 const query = {
288 offset: options.start,
289 limit: options.count,
290 order: getSort(options.sort)
291 }
292
3acc5084 293 const scopes: (string | ScopeOptions)[] = [
418d092a
C
294 {
295 method: [
296 ScopeNames.AVAILABLE_FOR_LIST,
297 {
df0b219d 298 type: options.type,
418d092a
C
299 followerActorId: options.followerActorId,
300 accountId: options.accountId,
301 videoChannelId: options.videoChannelId,
302 privateAndUnlisted: options.privateAndUnlisted
303 } as AvailableForListOptions
304 ]
3acc5084 305 },
e8bafea3
C
306 ScopeNames.WITH_VIDEOS_LENGTH,
307 ScopeNames.WITH_THUMBNAIL
418d092a
C
308 ]
309
310 return VideoPlaylistModel
311 .scope(scopes)
312 .findAndCountAll(query)
313 .then(({ rows, count }) => {
314 return { total: count, data: rows }
315 })
316 }
317
0b16f5f2 318 static listPublicUrlsOfForAP (accountId: number, start: number, count: number) {
418d092a
C
319 const query = {
320 attributes: [ 'url' ],
321 offset: start,
322 limit: count,
323 where: {
0b16f5f2
C
324 ownerAccountId: accountId,
325 privacy: VideoPlaylistPrivacy.PUBLIC
418d092a
C
326 }
327 }
328
329 return VideoPlaylistModel.findAndCountAll(query)
330 .then(({ rows, count }) => {
331 return { total: count, data: rows.map(p => p.url) }
332 })
333 }
334
f0a39880
C
335 static listPlaylistIdsOf (accountId: number, videoIds: number[]) {
336 const query = {
337 attributes: [ 'id' ],
338 where: {
339 ownerAccountId: accountId
340 },
341 include: [
342 {
bfbd9128 343 attributes: [ 'id', 'videoId', 'startTimestamp', 'stopTimestamp' ],
f0a39880
C
344 model: VideoPlaylistElementModel.unscoped(),
345 where: {
346 videoId: {
3acc5084 347 [Op.in]: videoIds // FIXME: sequelize ANY seems broken
f0a39880
C
348 }
349 },
350 required: true
351 }
352 ]
353 }
354
355 return VideoPlaylistModel.findAll(query)
356 }
357
418d092a
C
358 static doesPlaylistExist (url: string) {
359 const query = {
360 attributes: [],
361 where: {
362 url
363 }
364 }
365
366 return VideoPlaylistModel
367 .findOne(query)
368 .then(e => !!e)
369 }
370
1735c825 371 static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction) {
418d092a
C
372 const where = buildWhereIdOrUUID(id)
373
374 const query = {
375 where,
376 transaction
377 }
378
379 return VideoPlaylistModel
e8bafea3 380 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
09979f89
C
381 .findOne(query)
382 }
383
1735c825 384 static loadWithAccountAndChannel (id: number | string, transaction: Transaction) {
09979f89
C
385 const where = buildWhereIdOrUUID(id)
386
387 const query = {
388 where,
389 transaction
390 }
391
392 return VideoPlaylistModel
e8bafea3 393 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
418d092a
C
394 .findOne(query)
395 }
396
df0b219d
C
397 static loadByUrlAndPopulateAccount (url: string) {
398 const query = {
399 where: {
400 url
401 }
402 }
403
e8bafea3 404 return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
df0b219d
C
405 }
406
418d092a
C
407 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
408 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
409 }
410
df0b219d
C
411 static getTypeLabel (type: VideoPlaylistType) {
412 return VIDEO_PLAYLIST_TYPES[type] || 'Unknown'
413 }
414
1735c825 415 static resetPlaylistsOfChannel (videoChannelId: number, transaction: Transaction) {
df0b219d
C
416 const query = {
417 where: {
418 videoChannelId
419 },
420 transaction
421 }
422
423 return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
424 }
425
3acc5084
C
426 async setAndSaveThumbnail (thumbnail: ThumbnailModel, t: Transaction) {
427 thumbnail.videoPlaylistId = this.id
e8bafea3 428
3acc5084 429 this.Thumbnail = await thumbnail.save({ transaction: t })
e8bafea3
C
430 }
431
432 hasThumbnail () {
433 return !!this.Thumbnail
434 }
435
65af03a2
C
436 hasGeneratedThumbnail () {
437 return this.hasThumbnail() && this.Thumbnail.automaticallyGenerated === true
438 }
439
e8bafea3 440 generateThumbnailName () {
418d092a
C
441 const extension = '.jpg'
442
443 return 'playlist-' + this.uuid + extension
444 }
445
446 getThumbnailUrl () {
e8bafea3
C
447 if (!this.hasThumbnail()) return null
448
3acc5084 449 return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename
418d092a
C
450 }
451
452 getThumbnailStaticPath () {
e8bafea3 453 if (!this.hasThumbnail()) return null
418d092a 454
3acc5084 455 return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
418d092a
C
456 }
457
9f79ade6
C
458 setAsRefreshed () {
459 this.changed('updatedAt', true)
460
461 return this.save()
462 }
463
418d092a
C
464 isOwned () {
465 return this.OwnerAccount.isOwned()
466 }
467
9f79ade6
C
468 isOutdated () {
469 if (this.isOwned()) return false
470
471 return isOutdated(this, ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL)
472 }
473
418d092a
C
474 toFormattedJSON (): VideoPlaylist {
475 return {
476 id: this.id,
477 uuid: this.uuid,
478 isLocal: this.isOwned(),
479
480 displayName: this.name,
481 description: this.description,
482 privacy: {
483 id: this.privacy,
484 label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
485 },
486
487 thumbnailPath: this.getThumbnailStaticPath(),
488
df0b219d
C
489 type: {
490 id: this.type,
491 label: VideoPlaylistModel.getTypeLabel(this.type)
492 },
493
1735c825 494 videosLength: this.get('videosLength') as number,
418d092a
C
495
496 createdAt: this.createdAt,
497 updatedAt: this.updatedAt,
498
499 ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
07b1a18a 500 videoChannel: this.VideoChannel ? this.VideoChannel.toFormattedSummaryJSON() : null
418d092a
C
501 }
502 }
503
1735c825 504 toActivityPubObject (page: number, t: Transaction): Promise<PlaylistObject> {
418d092a 505 const handler = (start: number, count: number) => {
df0b219d 506 return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
418d092a
C
507 }
508
e8bafea3
C
509 let icon: ActivityIconObject
510 if (this.hasThumbnail()) {
511 icon = {
512 type: 'Image' as 'Image',
513 url: this.getThumbnailUrl(),
514 mediaType: 'image/jpeg' as 'image/jpeg',
515 width: THUMBNAILS_SIZE.width,
516 height: THUMBNAILS_SIZE.height
517 }
518 }
519
df0b219d 520 return activityPubCollectionPagination(this.url, handler, page)
418d092a
C
521 .then(o => {
522 return Object.assign(o, {
523 type: 'Playlist' as 'Playlist',
524 name: this.name,
525 content: this.description,
526 uuid: this.uuid,
df0b219d
C
527 published: this.createdAt.toISOString(),
528 updated: this.updatedAt.toISOString(),
418d092a 529 attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
e8bafea3 530 icon
418d092a
C
531 })
532 })
533 }
534}