]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video-playlist.ts
Playlist server API
[github/Chocobozzz/PeerTube.git] / server / models / video / video-playlist.ts
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 }