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