diff options
Diffstat (limited to 'server/models/video/video-playlist.ts')
-rw-r--r-- | server/models/video/video-playlist.ts | 381 |
1 files changed, 381 insertions, 0 deletions
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts new file mode 100644 index 000000000..93b8c2f58 --- /dev/null +++ b/server/models/video/video-playlist.ts | |||
@@ -0,0 +1,381 @@ | |||
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 { CONFIG, CONSTRAINTS_FIELDS, STATIC_PATHS, THUMBNAILS_SIZE, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers' | ||
28 | import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model' | ||
29 | import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' | ||
30 | import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' | ||
31 | import { join } from 'path' | ||
32 | import { VideoPlaylistElementModel } from './video-playlist-element' | ||
33 | import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' | ||
34 | import { activityPubCollectionPagination } from '../../helpers/activitypub' | ||
35 | import { remove } from 'fs-extra' | ||
36 | import { logger } from '../../helpers/logger' | ||
37 | |||
38 | enum ScopeNames { | ||
39 | AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', | ||
40 | WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH', | ||
41 | WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL' | ||
42 | } | ||
43 | |||
44 | type AvailableForListOptions = { | ||
45 | followerActorId: number | ||
46 | accountId?: number, | ||
47 | videoChannelId?: number | ||
48 | privateAndUnlisted?: boolean | ||
49 | } | ||
50 | |||
51 | @Scopes({ | ||
52 | [ScopeNames.WITH_VIDEOS_LENGTH]: { | ||
53 | attributes: { | ||
54 | include: [ | ||
55 | [ | ||
56 | Sequelize.literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'), | ||
57 | 'videosLength' | ||
58 | ] | ||
59 | ] | ||
60 | } | ||
61 | }, | ||
62 | [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: { | ||
63 | include: [ | ||
64 | { | ||
65 | model: () => AccountModel.scope(AccountScopeNames.SUMMARY), | ||
66 | required: true | ||
67 | }, | ||
68 | { | ||
69 | model: () => VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY), | ||
70 | required: false | ||
71 | } | ||
72 | ] | ||
73 | }, | ||
74 | [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { | ||
75 | // Only list local playlists OR playlists that are on an instance followed by actorId | ||
76 | const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId) | ||
77 | const actorWhere = { | ||
78 | [ Sequelize.Op.or ]: [ | ||
79 | { | ||
80 | serverId: null | ||
81 | }, | ||
82 | { | ||
83 | serverId: { | ||
84 | [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow) | ||
85 | } | ||
86 | } | ||
87 | ] | ||
88 | } | ||
89 | |||
90 | const whereAnd: any[] = [] | ||
91 | |||
92 | if (options.privateAndUnlisted !== true) { | ||
93 | whereAnd.push({ | ||
94 | privacy: VideoPlaylistPrivacy.PUBLIC | ||
95 | }) | ||
96 | } | ||
97 | |||
98 | if (options.accountId) { | ||
99 | whereAnd.push({ | ||
100 | ownerAccountId: options.accountId | ||
101 | }) | ||
102 | } | ||
103 | |||
104 | if (options.videoChannelId) { | ||
105 | whereAnd.push({ | ||
106 | videoChannelId: options.videoChannelId | ||
107 | }) | ||
108 | } | ||
109 | |||
110 | const where = { | ||
111 | [Sequelize.Op.and]: whereAnd | ||
112 | } | ||
113 | |||
114 | const accountScope = { | ||
115 | method: [ AccountScopeNames.SUMMARY, actorWhere ] | ||
116 | } | ||
117 | |||
118 | return { | ||
119 | where, | ||
120 | include: [ | ||
121 | { | ||
122 | model: AccountModel.scope(accountScope), | ||
123 | required: true | ||
124 | }, | ||
125 | { | ||
126 | model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY), | ||
127 | required: false | ||
128 | } | ||
129 | ] | ||
130 | } | ||
131 | } | ||
132 | }) | ||
133 | |||
134 | @Table({ | ||
135 | tableName: 'videoPlaylist', | ||
136 | indexes: [ | ||
137 | { | ||
138 | fields: [ 'ownerAccountId' ] | ||
139 | }, | ||
140 | { | ||
141 | fields: [ 'videoChannelId' ] | ||
142 | }, | ||
143 | { | ||
144 | fields: [ 'url' ], | ||
145 | unique: true | ||
146 | } | ||
147 | ] | ||
148 | }) | ||
149 | export class VideoPlaylistModel extends Model<VideoPlaylistModel> { | ||
150 | @CreatedAt | ||
151 | createdAt: Date | ||
152 | |||
153 | @UpdatedAt | ||
154 | updatedAt: Date | ||
155 | |||
156 | @AllowNull(false) | ||
157 | @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name')) | ||
158 | @Column | ||
159 | name: string | ||
160 | |||
161 | @AllowNull(true) | ||
162 | @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description')) | ||
163 | @Column | ||
164 | description: string | ||
165 | |||
166 | @AllowNull(false) | ||
167 | @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy')) | ||
168 | @Column | ||
169 | privacy: VideoPlaylistPrivacy | ||
170 | |||
171 | @AllowNull(false) | ||
172 | @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) | ||
173 | @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max)) | ||
174 | url: string | ||
175 | |||
176 | @AllowNull(false) | ||
177 | @Default(DataType.UUIDV4) | ||
178 | @IsUUID(4) | ||
179 | @Column(DataType.UUID) | ||
180 | uuid: string | ||
181 | |||
182 | @ForeignKey(() => AccountModel) | ||
183 | @Column | ||
184 | ownerAccountId: number | ||
185 | |||
186 | @BelongsTo(() => AccountModel, { | ||
187 | foreignKey: { | ||
188 | allowNull: false | ||
189 | }, | ||
190 | onDelete: 'CASCADE' | ||
191 | }) | ||
192 | OwnerAccount: AccountModel | ||
193 | |||
194 | @ForeignKey(() => VideoChannelModel) | ||
195 | @Column | ||
196 | videoChannelId: number | ||
197 | |||
198 | @BelongsTo(() => VideoChannelModel, { | ||
199 | foreignKey: { | ||
200 | allowNull: false | ||
201 | }, | ||
202 | onDelete: 'CASCADE' | ||
203 | }) | ||
204 | VideoChannel: VideoChannelModel | ||
205 | |||
206 | @HasMany(() => VideoPlaylistElementModel, { | ||
207 | foreignKey: { | ||
208 | name: 'videoPlaylistId', | ||
209 | allowNull: false | ||
210 | }, | ||
211 | onDelete: 'cascade' | ||
212 | }) | ||
213 | VideoPlaylistElements: VideoPlaylistElementModel[] | ||
214 | |||
215 | // Calculated field | ||
216 | videosLength?: number | ||
217 | |||
218 | @BeforeDestroy | ||
219 | static async removeFiles (instance: VideoPlaylistModel) { | ||
220 | logger.info('Removing files of video playlist %s.', instance.url) | ||
221 | |||
222 | return instance.removeThumbnail() | ||
223 | } | ||
224 | |||
225 | static listForApi (options: { | ||
226 | followerActorId: number | ||
227 | start: number, | ||
228 | count: number, | ||
229 | sort: string, | ||
230 | accountId?: number, | ||
231 | videoChannelId?: number, | ||
232 | privateAndUnlisted?: boolean | ||
233 | }) { | ||
234 | const query = { | ||
235 | offset: options.start, | ||
236 | limit: options.count, | ||
237 | order: getSort(options.sort) | ||
238 | } | ||
239 | |||
240 | const scopes = [ | ||
241 | { | ||
242 | method: [ | ||
243 | ScopeNames.AVAILABLE_FOR_LIST, | ||
244 | { | ||
245 | followerActorId: options.followerActorId, | ||
246 | accountId: options.accountId, | ||
247 | videoChannelId: options.videoChannelId, | ||
248 | privateAndUnlisted: options.privateAndUnlisted | ||
249 | } as AvailableForListOptions | ||
250 | ] | ||
251 | } as any, // FIXME: typings | ||
252 | ScopeNames.WITH_VIDEOS_LENGTH | ||
253 | ] | ||
254 | |||
255 | return VideoPlaylistModel | ||
256 | .scope(scopes) | ||
257 | .findAndCountAll(query) | ||
258 | .then(({ rows, count }) => { | ||
259 | return { total: count, data: rows } | ||
260 | }) | ||
261 | } | ||
262 | |||
263 | static listUrlsOfForAP (accountId: number, start: number, count: number) { | ||
264 | const query = { | ||
265 | attributes: [ 'url' ], | ||
266 | offset: start, | ||
267 | limit: count, | ||
268 | where: { | ||
269 | ownerAccountId: accountId | ||
270 | } | ||
271 | } | ||
272 | |||
273 | return VideoPlaylistModel.findAndCountAll(query) | ||
274 | .then(({ rows, count }) => { | ||
275 | return { total: count, data: rows.map(p => p.url) } | ||
276 | }) | ||
277 | } | ||
278 | |||
279 | static doesPlaylistExist (url: string) { | ||
280 | const query = { | ||
281 | attributes: [], | ||
282 | where: { | ||
283 | url | ||
284 | } | ||
285 | } | ||
286 | |||
287 | return VideoPlaylistModel | ||
288 | .findOne(query) | ||
289 | .then(e => !!e) | ||
290 | } | ||
291 | |||
292 | static load (id: number | string, transaction: Sequelize.Transaction) { | ||
293 | const where = buildWhereIdOrUUID(id) | ||
294 | |||
295 | const query = { | ||
296 | where, | ||
297 | transaction | ||
298 | } | ||
299 | |||
300 | return VideoPlaylistModel | ||
301 | .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ]) | ||
302 | .findOne(query) | ||
303 | } | ||
304 | |||
305 | static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { | ||
306 | return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown' | ||
307 | } | ||
308 | |||
309 | getThumbnailName () { | ||
310 | const extension = '.jpg' | ||
311 | |||
312 | return 'playlist-' + this.uuid + extension | ||
313 | } | ||
314 | |||
315 | getThumbnailUrl () { | ||
316 | return CONFIG.WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() | ||
317 | } | ||
318 | |||
319 | getThumbnailStaticPath () { | ||
320 | return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) | ||
321 | } | ||
322 | |||
323 | removeThumbnail () { | ||
324 | const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) | ||
325 | return remove(thumbnailPath) | ||
326 | .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err })) | ||
327 | } | ||
328 | |||
329 | isOwned () { | ||
330 | return this.OwnerAccount.isOwned() | ||
331 | } | ||
332 | |||
333 | toFormattedJSON (): VideoPlaylist { | ||
334 | return { | ||
335 | id: this.id, | ||
336 | uuid: this.uuid, | ||
337 | isLocal: this.isOwned(), | ||
338 | |||
339 | displayName: this.name, | ||
340 | description: this.description, | ||
341 | privacy: { | ||
342 | id: this.privacy, | ||
343 | label: VideoPlaylistModel.getPrivacyLabel(this.privacy) | ||
344 | }, | ||
345 | |||
346 | thumbnailPath: this.getThumbnailStaticPath(), | ||
347 | |||
348 | videosLength: this.videosLength, | ||
349 | |||
350 | createdAt: this.createdAt, | ||
351 | updatedAt: this.updatedAt, | ||
352 | |||
353 | ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(), | ||
354 | videoChannel: this.VideoChannel.toFormattedSummaryJSON() | ||
355 | } | ||
356 | } | ||
357 | |||
358 | toActivityPubObject (): Promise<PlaylistObject> { | ||
359 | const handler = (start: number, count: number) => { | ||
360 | return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count) | ||
361 | } | ||
362 | |||
363 | return activityPubCollectionPagination(this.url, handler, null) | ||
364 | .then(o => { | ||
365 | return Object.assign(o, { | ||
366 | type: 'Playlist' as 'Playlist', | ||
367 | name: this.name, | ||
368 | content: this.description, | ||
369 | uuid: this.uuid, | ||
370 | attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [], | ||
371 | icon: { | ||
372 | type: 'Image' as 'Image', | ||
373 | url: this.getThumbnailUrl(), | ||
374 | mediaType: 'image/jpeg' as 'image/jpeg', | ||
375 | width: THUMBNAILS_SIZE.width, | ||
376 | height: THUMBNAILS_SIZE.height | ||
377 | } | ||
378 | }) | ||
379 | }) | ||
380 | } | ||
381 | } | ||