]>
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' | |
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' | |
df0b219d C |
27 | import { |
28 | CONFIG, | |
29 | CONSTRAINTS_FIELDS, | |
30 | STATIC_PATHS, | |
31 | THUMBNAILS_SIZE, | |
32 | VIDEO_PLAYLIST_PRIVACIES, | |
33 | VIDEO_PLAYLIST_TYPES | |
34 | } from '../../initializers' | |
418d092a C |
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' | |
df0b219d | 44 | import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model' |
418d092a C |
45 | |
46 | enum ScopeNames { | |
47 | AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', | |
48 | WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH', | |
df0b219d | 49 | WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY', |
09979f89 C |
50 | WITH_ACCOUNT = 'WITH_ACCOUNT', |
51 | WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL' | |
418d092a C |
52 | } |
53 | ||
54 | type AvailableForListOptions = { | |
55 | followerActorId: number | |
df0b219d C |
56 | type?: VideoPlaylistType |
57 | accountId?: number | |
418d092a C |
58 | videoChannelId?: number |
59 | privateAndUnlisted?: boolean | |
60 | } | |
61 | ||
62 | @Scopes({ | |
df0b219d | 63 | [ ScopeNames.WITH_VIDEOS_LENGTH ]: { |
418d092a C |
64 | attributes: { |
65 | include: [ | |
66 | [ | |
67 | Sequelize.literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'), | |
68 | 'videosLength' | |
69 | ] | |
70 | ] | |
71 | } | |
72 | }, | |
df0b219d C |
73 | [ ScopeNames.WITH_ACCOUNT ]: { |
74 | include: [ | |
75 | { | |
76 | model: () => AccountModel, | |
77 | required: true | |
78 | } | |
79 | ] | |
80 | }, | |
81 | [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY ]: { | |
418d092a C |
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 | }, | |
09979f89 C |
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 | }, | |
df0b219d | 105 | [ ScopeNames.AVAILABLE_FOR_LIST ]: (options: AvailableForListOptions) => { |
418d092a C |
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 | ||
df0b219d C |
141 | if (options.type) { |
142 | whereAnd.push({ | |
143 | type: options.type | |
144 | }) | |
145 | } | |
146 | ||
418d092a C |
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 | ||
df0b219d C |
219 | @AllowNull(false) |
220 | @Default(VideoPlaylistType.REGULAR) | |
221 | @Column | |
222 | type: VideoPlaylistType | |
223 | ||
418d092a C |
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: { | |
07b1a18a | 242 | allowNull: true |
418d092a C |
243 | }, |
244 | onDelete: 'CASCADE' | |
245 | }) | |
246 | VideoChannel: VideoChannelModel | |
247 | ||
248 | @HasMany(() => VideoPlaylistElementModel, { | |
249 | foreignKey: { | |
250 | name: 'videoPlaylistId', | |
251 | allowNull: false | |
252 | }, | |
df0b219d | 253 | onDelete: 'CASCADE' |
418d092a C |
254 | }) |
255 | VideoPlaylistElements: VideoPlaylistElementModel[] | |
256 | ||
418d092a C |
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, | |
df0b219d | 269 | type?: VideoPlaylistType, |
418d092a C |
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 | { | |
df0b219d | 285 | type: options.type, |
418d092a C |
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 | ||
0b16f5f2 | 304 | static listPublicUrlsOfForAP (accountId: number, start: number, count: number) { |
418d092a C |
305 | const query = { |
306 | attributes: [ 'url' ], | |
307 | offset: start, | |
308 | limit: count, | |
309 | where: { | |
0b16f5f2 C |
310 | ownerAccountId: accountId, |
311 | privacy: VideoPlaylistPrivacy.PUBLIC | |
418d092a C |
312 | } |
313 | } | |
314 | ||
315 | return VideoPlaylistModel.findAndCountAll(query) | |
316 | .then(({ rows, count }) => { | |
317 | return { total: count, data: rows.map(p => p.url) } | |
318 | }) | |
319 | } | |
320 | ||
f0a39880 C |
321 | static listPlaylistIdsOf (accountId: number, videoIds: number[]) { |
322 | const query = { | |
323 | attributes: [ 'id' ], | |
324 | where: { | |
325 | ownerAccountId: accountId | |
326 | }, | |
327 | include: [ | |
328 | { | |
329 | attributes: [ 'videoId', 'startTimestamp', 'stopTimestamp' ], | |
330 | model: VideoPlaylistElementModel.unscoped(), | |
331 | where: { | |
332 | videoId: { | |
333 | [Sequelize.Op.any]: videoIds | |
334 | } | |
335 | }, | |
336 | required: true | |
337 | } | |
338 | ] | |
339 | } | |
340 | ||
341 | return VideoPlaylistModel.findAll(query) | |
342 | } | |
343 | ||
418d092a C |
344 | static doesPlaylistExist (url: string) { |
345 | const query = { | |
346 | attributes: [], | |
347 | where: { | |
348 | url | |
349 | } | |
350 | } | |
351 | ||
352 | return VideoPlaylistModel | |
353 | .findOne(query) | |
354 | .then(e => !!e) | |
355 | } | |
356 | ||
09979f89 | 357 | static loadWithAccountAndChannelSummary (id: number | string, transaction: Sequelize.Transaction) { |
418d092a C |
358 | const where = buildWhereIdOrUUID(id) |
359 | ||
360 | const query = { | |
361 | where, | |
362 | transaction | |
363 | } | |
364 | ||
365 | return VideoPlaylistModel | |
df0b219d | 366 | .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH ]) |
09979f89 C |
367 | .findOne(query) |
368 | } | |
369 | ||
370 | static loadWithAccountAndChannel (id: number | string, transaction: Sequelize.Transaction) { | |
371 | const where = buildWhereIdOrUUID(id) | |
372 | ||
373 | const query = { | |
374 | where, | |
375 | transaction | |
376 | } | |
377 | ||
378 | return VideoPlaylistModel | |
379 | .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ]) | |
418d092a C |
380 | .findOne(query) |
381 | } | |
382 | ||
df0b219d C |
383 | static loadByUrlAndPopulateAccount (url: string) { |
384 | const query = { | |
385 | where: { | |
386 | url | |
387 | } | |
388 | } | |
389 | ||
390 | return VideoPlaylistModel.scope(ScopeNames.WITH_ACCOUNT).findOne(query) | |
391 | } | |
392 | ||
418d092a C |
393 | static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { |
394 | return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown' | |
395 | } | |
396 | ||
df0b219d C |
397 | static getTypeLabel (type: VideoPlaylistType) { |
398 | return VIDEO_PLAYLIST_TYPES[type] || 'Unknown' | |
399 | } | |
400 | ||
401 | static resetPlaylistsOfChannel (videoChannelId: number, transaction: Sequelize.Transaction) { | |
402 | const query = { | |
403 | where: { | |
404 | videoChannelId | |
405 | }, | |
406 | transaction | |
407 | } | |
408 | ||
409 | return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query) | |
410 | } | |
411 | ||
418d092a C |
412 | getThumbnailName () { |
413 | const extension = '.jpg' | |
414 | ||
415 | return 'playlist-' + this.uuid + extension | |
416 | } | |
417 | ||
418 | getThumbnailUrl () { | |
419 | return CONFIG.WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() | |
420 | } | |
421 | ||
422 | getThumbnailStaticPath () { | |
423 | return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) | |
424 | } | |
425 | ||
426 | removeThumbnail () { | |
427 | const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) | |
428 | return remove(thumbnailPath) | |
429 | .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err })) | |
430 | } | |
431 | ||
432 | isOwned () { | |
433 | return this.OwnerAccount.isOwned() | |
434 | } | |
435 | ||
436 | toFormattedJSON (): VideoPlaylist { | |
437 | return { | |
438 | id: this.id, | |
439 | uuid: this.uuid, | |
440 | isLocal: this.isOwned(), | |
441 | ||
442 | displayName: this.name, | |
443 | description: this.description, | |
444 | privacy: { | |
445 | id: this.privacy, | |
446 | label: VideoPlaylistModel.getPrivacyLabel(this.privacy) | |
447 | }, | |
448 | ||
449 | thumbnailPath: this.getThumbnailStaticPath(), | |
450 | ||
df0b219d C |
451 | type: { |
452 | id: this.type, | |
453 | label: VideoPlaylistModel.getTypeLabel(this.type) | |
454 | }, | |
455 | ||
456 | videosLength: this.get('videosLength'), | |
418d092a C |
457 | |
458 | createdAt: this.createdAt, | |
459 | updatedAt: this.updatedAt, | |
460 | ||
461 | ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(), | |
07b1a18a | 462 | videoChannel: this.VideoChannel ? this.VideoChannel.toFormattedSummaryJSON() : null |
418d092a C |
463 | } |
464 | } | |
465 | ||
df0b219d | 466 | toActivityPubObject (page: number, t: Sequelize.Transaction): Promise<PlaylistObject> { |
418d092a | 467 | const handler = (start: number, count: number) => { |
df0b219d | 468 | return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t) |
418d092a C |
469 | } |
470 | ||
df0b219d | 471 | return activityPubCollectionPagination(this.url, handler, page) |
418d092a C |
472 | .then(o => { |
473 | return Object.assign(o, { | |
474 | type: 'Playlist' as 'Playlist', | |
475 | name: this.name, | |
476 | content: this.description, | |
477 | uuid: this.uuid, | |
df0b219d C |
478 | published: this.createdAt.toISOString(), |
479 | updated: this.updatedAt.toISOString(), | |
418d092a C |
480 | attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [], |
481 | icon: { | |
482 | type: 'Image' as 'Image', | |
483 | url: this.getThumbnailUrl(), | |
484 | mediaType: 'image/jpeg' as 'image/jpeg', | |
485 | width: THUMBNAILS_SIZE.width, | |
486 | height: THUMBNAILS_SIZE.height | |
487 | } | |
488 | }) | |
489 | }) | |
490 | } | |
491 | } |