]>
Commit | Line | Data |
---|---|---|
418d092a C |
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' | |
9f79ade6 | 20 | import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, isOutdated, throwIfNotValid } from '../utils' |
418d092a C |
21 | import { |
22 | isVideoPlaylistDescriptionValid, | |
23 | isVideoPlaylistNameValid, | |
24 | isVideoPlaylistPrivacyValid | |
25 | } from '../../helpers/custom-validators/video-playlists' | |
26 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | |
df0b219d | 27 | import { |
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 |
36 | import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model' |
37 | import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' | |
38 | import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' | |
39 | import { join } from 'path' | |
40 | import { VideoPlaylistElementModel } from './video-playlist-element' | |
41 | import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' | |
42 | import { activityPubCollectionPagination } from '../../helpers/activitypub' | |
43 | import { remove } from 'fs-extra' | |
44 | import { logger } from '../../helpers/logger' | |
df0b219d | 45 | import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model' |
6dd9de95 | 46 | import { CONFIG } from '../../initializers/config' |
418d092a C |
47 | |
48 | enum 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 | ||
56 | type 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 | }) | |
188 | export 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 | } |