]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video-channel.ts
Add pagination to account video channels endpoint
[github/Chocobozzz/PeerTube.git] / server / models / video / video-channel.ts
1 import {
2 AllowNull,
3 BeforeDestroy,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 Default,
9 DefaultScope,
10 ForeignKey,
11 HasMany,
12 Is,
13 Model,
14 Scopes,
15 Sequelize,
16 Table,
17 UpdatedAt
18 } from 'sequelize-typescript'
19 import { ActivityPubActor } from '../../../shared/models/activitypub'
20 import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
21 import {
22 isVideoChannelDescriptionValid,
23 isVideoChannelNameValid,
24 isVideoChannelSupportValid
25 } from '../../helpers/custom-validators/video-channels'
26 import { sendDeleteActor } from '../../lib/activitypub/send'
27 import { AccountModel, ScopeNames as AccountModelScopeNames } from '../account/account'
28 import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
29 import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
30 import { VideoModel } from './video'
31 import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
32 import { ServerModel } from '../server/server'
33 import { FindOptions, ModelIndexesOptions, Op } from 'sequelize'
34 import { AvatarModel } from '../avatar/avatar'
35 import { VideoPlaylistModel } from './video-playlist'
36
37 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
38 const indexes: ModelIndexesOptions[] = [
39 buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
40
41 {
42 fields: [ 'accountId' ]
43 },
44 {
45 fields: [ 'actorId' ]
46 }
47 ]
48
49 export enum ScopeNames {
50 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
51 WITH_ACCOUNT = 'WITH_ACCOUNT',
52 WITH_ACTOR = 'WITH_ACTOR',
53 WITH_VIDEOS = 'WITH_VIDEOS',
54 SUMMARY = 'SUMMARY'
55 }
56
57 type AvailableForListOptions = {
58 actorId: number
59 }
60
61 @DefaultScope(() => ({
62 include: [
63 {
64 model: ActorModel,
65 required: true
66 }
67 ]
68 }))
69 @Scopes(() => ({
70 [ScopeNames.SUMMARY]: (withAccount = false) => {
71 const base: FindOptions = {
72 attributes: [ 'name', 'description', 'id', 'actorId' ],
73 include: [
74 {
75 attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
76 model: ActorModel.unscoped(),
77 required: true,
78 include: [
79 {
80 attributes: [ 'host' ],
81 model: ServerModel.unscoped(),
82 required: false
83 },
84 {
85 model: AvatarModel.unscoped(),
86 required: false
87 }
88 ]
89 }
90 ]
91 }
92
93 if (withAccount === true) {
94 base.include.push({
95 model: AccountModel.scope(AccountModelScopeNames.SUMMARY),
96 required: true
97 })
98 }
99
100 return base
101 },
102 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
103 // Only list local channels OR channels that are on an instance followed by actorId
104 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
105
106 return {
107 include: [
108 {
109 attributes: {
110 exclude: unusedActorAttributesForAPI
111 },
112 model: ActorModel,
113 where: {
114 [Op.or]: [
115 {
116 serverId: null
117 },
118 {
119 serverId: {
120 [ Op.in ]: Sequelize.literal(inQueryInstanceFollow)
121 }
122 }
123 ]
124 }
125 },
126 {
127 model: AccountModel,
128 required: true,
129 include: [
130 {
131 attributes: {
132 exclude: unusedActorAttributesForAPI
133 },
134 model: ActorModel, // Default scope includes avatar and server
135 required: true
136 }
137 ]
138 }
139 ]
140 }
141 },
142 [ScopeNames.WITH_ACCOUNT]: {
143 include: [
144 {
145 model: AccountModel,
146 required: true
147 }
148 ]
149 },
150 [ScopeNames.WITH_VIDEOS]: {
151 include: [
152 VideoModel
153 ]
154 },
155 [ScopeNames.WITH_ACTOR]: {
156 include: [
157 ActorModel
158 ]
159 }
160 }))
161 @Table({
162 tableName: 'videoChannel',
163 indexes
164 })
165 export class VideoChannelModel extends Model<VideoChannelModel> {
166
167 @AllowNull(false)
168 @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelNameValid, 'name'))
169 @Column
170 name: string
171
172 @AllowNull(true)
173 @Default(null)
174 @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description', true))
175 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
176 description: string
177
178 @AllowNull(true)
179 @Default(null)
180 @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support', true))
181 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
182 support: string
183
184 @CreatedAt
185 createdAt: Date
186
187 @UpdatedAt
188 updatedAt: Date
189
190 @ForeignKey(() => ActorModel)
191 @Column
192 actorId: number
193
194 @BelongsTo(() => ActorModel, {
195 foreignKey: {
196 allowNull: false
197 },
198 onDelete: 'cascade'
199 })
200 Actor: ActorModel
201
202 @ForeignKey(() => AccountModel)
203 @Column
204 accountId: number
205
206 @BelongsTo(() => AccountModel, {
207 foreignKey: {
208 allowNull: false
209 },
210 hooks: true
211 })
212 Account: AccountModel
213
214 @HasMany(() => VideoModel, {
215 foreignKey: {
216 name: 'channelId',
217 allowNull: false
218 },
219 onDelete: 'CASCADE',
220 hooks: true
221 })
222 Videos: VideoModel[]
223
224 @HasMany(() => VideoPlaylistModel, {
225 foreignKey: {
226 allowNull: true
227 },
228 onDelete: 'CASCADE',
229 hooks: true
230 })
231 VideoPlaylists: VideoPlaylistModel[]
232
233 @BeforeDestroy
234 static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
235 if (!instance.Actor) {
236 instance.Actor = await instance.$get('Actor', { transaction: options.transaction }) as ActorModel
237 }
238
239 if (instance.Actor.isOwned()) {
240 return sendDeleteActor(instance.Actor, options.transaction)
241 }
242
243 return undefined
244 }
245
246 static countByAccount (accountId: number) {
247 const query = {
248 where: {
249 accountId
250 }
251 }
252
253 return VideoChannelModel.count(query)
254 }
255
256 static listForApi (actorId: number, start: number, count: number, sort: string) {
257 const query = {
258 offset: start,
259 limit: count,
260 order: getSort(sort)
261 }
262
263 const scopes = {
264 method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId } as AvailableForListOptions ]
265 }
266 return VideoChannelModel
267 .scope(scopes)
268 .findAndCountAll(query)
269 .then(({ rows, count }) => {
270 return { total: count, data: rows }
271 })
272 }
273
274 static listLocalsForSitemap (sort: string) {
275 const query = {
276 attributes: [ ],
277 offset: 0,
278 order: getSort(sort),
279 include: [
280 {
281 attributes: [ 'preferredUsername', 'serverId' ],
282 model: ActorModel.unscoped(),
283 where: {
284 serverId: null
285 }
286 }
287 ]
288 }
289
290 return VideoChannelModel
291 .unscoped()
292 .findAll(query)
293 }
294
295 static searchForApi (options: {
296 actorId: number
297 search: string
298 start: number
299 count: number
300 sort: string
301 }) {
302 const attributesInclude = []
303 const escapedSearch = VideoModel.sequelize.escape(options.search)
304 const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
305 attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
306
307 const query = {
308 attributes: {
309 include: attributesInclude
310 },
311 offset: options.start,
312 limit: options.count,
313 order: getSort(options.sort),
314 where: {
315 [Op.or]: [
316 Sequelize.literal(
317 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
318 ),
319 Sequelize.literal(
320 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
321 )
322 ]
323 }
324 }
325
326 const scopes = {
327 method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId: options.actorId } as AvailableForListOptions ]
328 }
329 return VideoChannelModel
330 .scope(scopes)
331 .findAndCountAll(query)
332 .then(({ rows, count }) => {
333 return { total: count, data: rows }
334 })
335 }
336
337 static listByAccount (options: {
338 accountId: number,
339 start: number,
340 count: number,
341 sort: string
342 }) {
343 const query = {
344 offset: options.start,
345 limit: options.count,
346 order: getSort(options.sort),
347 include: [
348 {
349 model: AccountModel,
350 where: {
351 id: options.accountId
352 },
353 required: true
354 }
355 ]
356 }
357
358 return VideoChannelModel
359 .findAndCountAll(query)
360 .then(({ rows, count }) => {
361 return { total: count, data: rows }
362 })
363 }
364
365 static loadByIdAndPopulateAccount (id: number) {
366 return VideoChannelModel.unscoped()
367 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
368 .findByPk(id)
369 }
370
371 static loadByIdAndAccount (id: number, accountId: number) {
372 const query = {
373 where: {
374 id,
375 accountId
376 }
377 }
378
379 return VideoChannelModel.unscoped()
380 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
381 .findOne(query)
382 }
383
384 static loadAndPopulateAccount (id: number) {
385 return VideoChannelModel.unscoped()
386 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
387 .findByPk(id)
388 }
389
390 static loadByUUIDAndPopulateAccount (uuid: string) {
391 const query = {
392 include: [
393 {
394 model: ActorModel,
395 required: true,
396 where: {
397 uuid
398 }
399 }
400 ]
401 }
402
403 return VideoChannelModel
404 .scope([ ScopeNames.WITH_ACCOUNT ])
405 .findOne(query)
406 }
407
408 static loadByUrlAndPopulateAccount (url: string) {
409 const query = {
410 include: [
411 {
412 model: ActorModel,
413 required: true,
414 where: {
415 url
416 }
417 }
418 ]
419 }
420
421 return VideoChannelModel
422 .scope([ ScopeNames.WITH_ACCOUNT ])
423 .findOne(query)
424 }
425
426 static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
427 const [ name, host ] = nameWithHost.split('@')
428
429 if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
430
431 return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
432 }
433
434 static loadLocalByNameAndPopulateAccount (name: string) {
435 const query = {
436 include: [
437 {
438 model: ActorModel,
439 required: true,
440 where: {
441 preferredUsername: name,
442 serverId: null
443 }
444 }
445 ]
446 }
447
448 return VideoChannelModel.unscoped()
449 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
450 .findOne(query)
451 }
452
453 static loadByNameAndHostAndPopulateAccount (name: string, host: string) {
454 const query = {
455 include: [
456 {
457 model: ActorModel,
458 required: true,
459 where: {
460 preferredUsername: name
461 },
462 include: [
463 {
464 model: ServerModel,
465 required: true,
466 where: { host }
467 }
468 ]
469 }
470 ]
471 }
472
473 return VideoChannelModel.unscoped()
474 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
475 .findOne(query)
476 }
477
478 static loadAndPopulateAccountAndVideos (id: number) {
479 const options = {
480 include: [
481 VideoModel
482 ]
483 }
484
485 return VideoChannelModel.unscoped()
486 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEOS ])
487 .findByPk(id, options)
488 }
489
490 toFormattedJSON (): VideoChannel {
491 const actor = this.Actor.toFormattedJSON()
492 const videoChannel = {
493 id: this.id,
494 displayName: this.getDisplayName(),
495 description: this.description,
496 support: this.support,
497 isLocal: this.Actor.isOwned(),
498 createdAt: this.createdAt,
499 updatedAt: this.updatedAt,
500 ownerAccount: undefined
501 }
502
503 if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
504
505 return Object.assign(actor, videoChannel)
506 }
507
508 toFormattedSummaryJSON (): VideoChannelSummary {
509 const actor = this.Actor.toFormattedJSON()
510
511 return {
512 id: this.id,
513 uuid: actor.uuid,
514 name: actor.name,
515 displayName: this.getDisplayName(),
516 url: actor.url,
517 host: actor.host,
518 avatar: actor.avatar
519 }
520 }
521
522 toActivityPubObject (): ActivityPubActor {
523 const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel')
524
525 return Object.assign(obj, {
526 summary: this.description,
527 support: this.support,
528 attributedTo: [
529 {
530 type: 'Person' as 'Person',
531 id: this.Account.Actor.url
532 }
533 ]
534 })
535 }
536
537 getDisplayName () {
538 return this.name
539 }
540
541 isOutdated () {
542 return this.Actor.isOutdated()
543 }
544 }