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