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