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