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