aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models
diff options
context:
space:
mode:
Diffstat (limited to 'server/models')
-rw-r--r--server/models/abuse/abuse-message.ts114
-rw-r--r--server/models/abuse/abuse.ts624
-rw-r--r--server/models/abuse/sql/abuse-query-builder.ts167
-rw-r--r--server/models/abuse/video-abuse.ts64
-rw-r--r--server/models/abuse/video-comment-abuse.ts48
-rw-r--r--server/models/account/account-blocklist.ts236
-rw-r--r--server/models/account/account-video-rate.ts259
-rw-r--r--server/models/account/account.ts468
-rw-r--r--server/models/account/actor-custom-page.ts69
-rw-r--r--server/models/actor/actor-follow.ts662
-rw-r--r--server/models/actor/actor-image.ts171
-rw-r--r--server/models/actor/actor.ts686
-rw-r--r--server/models/actor/sql/instance-list-followers-query-builder.ts69
-rw-r--r--server/models/actor/sql/instance-list-following-query-builder.ts69
-rw-r--r--server/models/actor/sql/shared/actor-follow-table-attributes.ts28
-rw-r--r--server/models/actor/sql/shared/instance-list-follows-query-builder.ts97
-rw-r--r--server/models/application/application.ts79
-rw-r--r--server/models/migrations.ts27
-rw-r--r--server/models/oauth/oauth-client.ts63
-rw-r--r--server/models/oauth/oauth-token.ts220
-rw-r--r--server/models/redundancy/video-redundancy.ts793
-rw-r--r--server/models/runner/runner-job.ts357
-rw-r--r--server/models/runner/runner-registration-token.ts103
-rw-r--r--server/models/runner/runner.ts124
-rw-r--r--server/models/server/plugin.ts305
-rw-r--r--server/models/server/server-blocklist.ts190
-rw-r--r--server/models/server/server.ts104
-rw-r--r--server/models/server/tracker.ts74
-rw-r--r--server/models/server/video-tracker.ts31
-rw-r--r--server/models/shared/abstract-run-query.ts32
-rw-r--r--server/models/shared/index.ts8
-rw-r--r--server/models/shared/model-builder.ts118
-rw-r--r--server/models/shared/model-cache.ts90
-rw-r--r--server/models/shared/query.ts82
-rw-r--r--server/models/shared/sequelize-helpers.ts39
-rw-r--r--server/models/shared/sort.ts146
-rw-r--r--server/models/shared/sql.ts68
-rw-r--r--server/models/shared/update.ts34
-rw-r--r--server/models/user/sql/user-notitication-list-query-builder.ts273
-rw-r--r--server/models/user/user-notification-setting.ts232
-rw-r--r--server/models/user/user-notification.ts534
-rw-r--r--server/models/user/user-registration.ts259
-rw-r--r--server/models/user/user-video-history.ts111
-rw-r--r--server/models/user/user.ts983
-rw-r--r--server/models/video/formatter/index.ts2
-rw-r--r--server/models/video/formatter/shared/index.ts1
-rw-r--r--server/models/video/formatter/shared/video-format-utils.ts7
-rw-r--r--server/models/video/formatter/video-activity-pub-format.ts296
-rw-r--r--server/models/video/formatter/video-api-format.ts305
-rw-r--r--server/models/video/schedule-video-update.ts95
-rw-r--r--server/models/video/sql/comment/video-comment-list-query-builder.ts400
-rw-r--r--server/models/video/sql/comment/video-comment-table-attributes.ts43
-rw-r--r--server/models/video/sql/video/index.ts3
-rw-r--r--server/models/video/sql/video/shared/abstract-video-query-builder.ts340
-rw-r--r--server/models/video/sql/video/shared/video-file-query-builder.ts75
-rw-r--r--server/models/video/sql/video/shared/video-model-builder.ts408
-rw-r--r--server/models/video/sql/video/shared/video-table-attributes.ts273
-rw-r--r--server/models/video/sql/video/video-model-get-query-builder.ts189
-rw-r--r--server/models/video/sql/video/videos-id-list-query-builder.ts728
-rw-r--r--server/models/video/sql/video/videos-model-list-query-builder.ts103
-rw-r--r--server/models/video/storyboard.ts169
-rw-r--r--server/models/video/tag.ts86
-rw-r--r--server/models/video/thumbnail.ts208
-rw-r--r--server/models/video/video-blacklist.ts134
-rw-r--r--server/models/video/video-caption.ts247
-rw-r--r--server/models/video/video-change-ownership.ts137
-rw-r--r--server/models/video/video-channel-sync.ts176
-rw-r--r--server/models/video/video-channel.ts860
-rw-r--r--server/models/video/video-comment.ts683
-rw-r--r--server/models/video/video-file.ts635
-rw-r--r--server/models/video/video-import.ts267
-rw-r--r--server/models/video/video-job-info.ts121
-rw-r--r--server/models/video/video-live-replay-setting.ts42
-rw-r--r--server/models/video/video-live-session.ts217
-rw-r--r--server/models/video/video-live.ts184
-rw-r--r--server/models/video/video-password.ts137
-rw-r--r--server/models/video/video-playlist-element.ts370
-rw-r--r--server/models/video/video-playlist.ts725
-rw-r--r--server/models/video/video-share.ts216
-rw-r--r--server/models/video/video-source.ts56
-rw-r--r--server/models/video/video-streaming-playlist.ts328
-rw-r--r--server/models/video/video-tag.ts31
-rw-r--r--server/models/video/video.ts2047
-rw-r--r--server/models/view/local-video-viewer-watch-section.ts63
-rw-r--r--server/models/view/local-video-viewer.ts368
-rw-r--r--server/models/view/video-view.ts67
86 files changed, 0 insertions, 21152 deletions
diff --git a/server/models/abuse/abuse-message.ts b/server/models/abuse/abuse-message.ts
deleted file mode 100644
index 14a5bffa2..000000000
--- a/server/models/abuse/abuse-message.ts
+++ /dev/null
@@ -1,114 +0,0 @@
1import { FindOptions } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { isAbuseMessageValid } from '@server/helpers/custom-validators/abuses'
4import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models'
5import { AbuseMessage } from '@shared/models'
6import { AttributesOnly } from '@shared/typescript-utils'
7import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
8import { getSort, throwIfNotValid } from '../shared'
9import { AbuseModel } from './abuse'
10
11@Table({
12 tableName: 'abuseMessage',
13 indexes: [
14 {
15 fields: [ 'abuseId' ]
16 },
17 {
18 fields: [ 'accountId' ]
19 }
20 ]
21})
22export class AbuseMessageModel extends Model<Partial<AttributesOnly<AbuseMessageModel>>> {
23
24 @AllowNull(false)
25 @Is('AbuseMessage', value => throwIfNotValid(value, isAbuseMessageValid, 'message'))
26 @Column(DataType.TEXT)
27 message: string
28
29 @AllowNull(false)
30 @Column
31 byModerator: boolean
32
33 @CreatedAt
34 createdAt: Date
35
36 @UpdatedAt
37 updatedAt: Date
38
39 @ForeignKey(() => AccountModel)
40 @Column
41 accountId: number
42
43 @BelongsTo(() => AccountModel, {
44 foreignKey: {
45 name: 'accountId',
46 allowNull: true
47 },
48 onDelete: 'set null'
49 })
50 Account: AccountModel
51
52 @ForeignKey(() => AbuseModel)
53 @Column
54 abuseId: number
55
56 @BelongsTo(() => AbuseModel, {
57 foreignKey: {
58 name: 'abuseId',
59 allowNull: false
60 },
61 onDelete: 'cascade'
62 })
63 Abuse: AbuseModel
64
65 static listForApi (abuseId: number) {
66 const getQuery = (forCount: boolean) => {
67 const query: FindOptions = {
68 where: { abuseId },
69 order: getSort('createdAt')
70 }
71
72 if (forCount !== true) {
73 query.include = [
74 {
75 model: AccountModel.scope(AccountScopeNames.SUMMARY),
76 required: false
77 }
78 ]
79 }
80
81 return query
82 }
83
84 return Promise.all([
85 AbuseMessageModel.count(getQuery(true)),
86 AbuseMessageModel.findAll(getQuery(false))
87 ]).then(([ total, data ]) => ({ total, data }))
88 }
89
90 static loadByIdAndAbuseId (messageId: number, abuseId: number): Promise<MAbuseMessage> {
91 return AbuseMessageModel.findOne({
92 where: {
93 id: messageId,
94 abuseId
95 }
96 })
97 }
98
99 toFormattedJSON (this: MAbuseMessageFormattable): AbuseMessage {
100 const account = this.Account
101 ? this.Account.toFormattedSummaryJSON()
102 : null
103
104 return {
105 id: this.id,
106 createdAt: this.createdAt,
107
108 byModerator: this.byModerator,
109 message: this.message,
110
111 account
112 }
113 }
114}
diff --git a/server/models/abuse/abuse.ts b/server/models/abuse/abuse.ts
deleted file mode 100644
index 4ce40bf2f..000000000
--- a/server/models/abuse/abuse.ts
+++ /dev/null
@@ -1,624 +0,0 @@
1import { invert } from 'lodash'
2import { literal, Op, QueryTypes } from 'sequelize'
3import {
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 Default,
10 ForeignKey,
11 HasOne,
12 Is,
13 Model,
14 Scopes,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
18import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses'
19import { abusePredefinedReasonsMap } from '@shared/core-utils'
20import {
21 AbuseFilter,
22 AbuseObject,
23 AbusePredefinedReasons,
24 AbusePredefinedReasonsString,
25 AbuseState,
26 AbuseVideoIs,
27 AdminAbuse,
28 AdminVideoAbuse,
29 AdminVideoCommentAbuse,
30 UserAbuse,
31 UserVideoAbuse
32} from '@shared/models'
33import { AttributesOnly } from '@shared/typescript-utils'
34import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
35import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models'
36import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
37import { getSort, throwIfNotValid } from '../shared'
38import { ThumbnailModel } from '../video/thumbnail'
39import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video'
40import { VideoBlacklistModel } from '../video/video-blacklist'
41import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
42import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment'
43import { buildAbuseListQuery, BuildAbusesQueryOptions } from './sql/abuse-query-builder'
44import { VideoAbuseModel } from './video-abuse'
45import { VideoCommentAbuseModel } from './video-comment-abuse'
46
47export enum ScopeNames {
48 FOR_API = 'FOR_API'
49}
50
51@Scopes(() => ({
52 [ScopeNames.FOR_API]: () => {
53 return {
54 attributes: {
55 include: [
56 [
57 literal(
58 '(' +
59 'SELECT count(*) ' +
60 'FROM "abuseMessage" ' +
61 'WHERE "abuseId" = "AbuseModel"."id"' +
62 ')'
63 ),
64 'countMessages'
65 ],
66 [
67 // we don't care about this count for deleted videos, so there are not included
68 literal(
69 '(' +
70 'SELECT count(*) ' +
71 'FROM "videoAbuse" ' +
72 'WHERE "videoId" = "VideoAbuse"."videoId" AND "videoId" IS NOT NULL' +
73 ')'
74 ),
75 'countReportsForVideo'
76 ],
77 [
78 // we don't care about this count for deleted videos, so there are not included
79 literal(
80 '(' +
81 'SELECT t.nth ' +
82 'FROM ( ' +
83 'SELECT id, ' +
84 'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
85 'FROM "videoAbuse" ' +
86 ') t ' +
87 'WHERE t.id = "VideoAbuse".id AND t.id IS NOT NULL' +
88 ')'
89 ),
90 'nthReportForVideo'
91 ],
92 [
93 literal(
94 '(' +
95 'SELECT count("abuse"."id") ' +
96 'FROM "abuse" ' +
97 'WHERE "abuse"."reporterAccountId" = "AbuseModel"."reporterAccountId"' +
98 ')'
99 ),
100 'countReportsForReporter'
101 ],
102 [
103 literal(
104 '(' +
105 'SELECT count("abuse"."id") ' +
106 'FROM "abuse" ' +
107 'WHERE "abuse"."flaggedAccountId" = "AbuseModel"."flaggedAccountId"' +
108 ')'
109 ),
110 'countReportsForReportee'
111 ]
112 ]
113 },
114 include: [
115 {
116 model: AccountModel.scope({
117 method: [
118 AccountScopeNames.SUMMARY,
119 { actorRequired: false } as AccountSummaryOptions
120 ]
121 }),
122 as: 'ReporterAccount'
123 },
124 {
125 model: AccountModel.scope({
126 method: [
127 AccountScopeNames.SUMMARY,
128 { actorRequired: false } as AccountSummaryOptions
129 ]
130 }),
131 as: 'FlaggedAccount'
132 },
133 {
134 model: VideoCommentAbuseModel.unscoped(),
135 include: [
136 {
137 model: VideoCommentModel.unscoped(),
138 include: [
139 {
140 model: VideoModel.unscoped(),
141 attributes: [ 'name', 'id', 'uuid' ]
142 }
143 ]
144 }
145 ]
146 },
147 {
148 model: VideoAbuseModel.unscoped(),
149 include: [
150 {
151 attributes: [ 'id', 'uuid', 'name', 'nsfw' ],
152 model: VideoModel.unscoped(),
153 include: [
154 {
155 attributes: [ 'filename', 'fileUrl', 'type' ],
156 model: ThumbnailModel
157 },
158 {
159 model: VideoChannelModel.scope({
160 method: [
161 VideoChannelScopeNames.SUMMARY,
162 { withAccount: false, actorRequired: false } as ChannelSummaryOptions
163 ]
164 }),
165 required: false
166 },
167 {
168 attributes: [ 'id', 'reason', 'unfederated' ],
169 required: false,
170 model: VideoBlacklistModel
171 }
172 ]
173 }
174 ]
175 }
176 ]
177 }
178 }
179}))
180@Table({
181 tableName: 'abuse',
182 indexes: [
183 {
184 fields: [ 'reporterAccountId' ]
185 },
186 {
187 fields: [ 'flaggedAccountId' ]
188 }
189 ]
190})
191export class AbuseModel extends Model<Partial<AttributesOnly<AbuseModel>>> {
192
193 @AllowNull(false)
194 @Default(null)
195 @Is('AbuseReason', value => throwIfNotValid(value, isAbuseReasonValid, 'reason'))
196 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.REASON.max))
197 reason: string
198
199 @AllowNull(false)
200 @Default(null)
201 @Is('AbuseState', value => throwIfNotValid(value, isAbuseStateValid, 'state'))
202 @Column
203 state: AbuseState
204
205 @AllowNull(true)
206 @Default(null)
207 @Is('AbuseModerationComment', value => throwIfNotValid(value, isAbuseModerationCommentValid, 'moderationComment', true))
208 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.MODERATION_COMMENT.max))
209 moderationComment: string
210
211 @AllowNull(true)
212 @Default(null)
213 @Column(DataType.ARRAY(DataType.INTEGER))
214 predefinedReasons: AbusePredefinedReasons[]
215
216 @CreatedAt
217 createdAt: Date
218
219 @UpdatedAt
220 updatedAt: Date
221
222 @ForeignKey(() => AccountModel)
223 @Column
224 reporterAccountId: number
225
226 @BelongsTo(() => AccountModel, {
227 foreignKey: {
228 name: 'reporterAccountId',
229 allowNull: true
230 },
231 as: 'ReporterAccount',
232 onDelete: 'set null'
233 })
234 ReporterAccount: AccountModel
235
236 @ForeignKey(() => AccountModel)
237 @Column
238 flaggedAccountId: number
239
240 @BelongsTo(() => AccountModel, {
241 foreignKey: {
242 name: 'flaggedAccountId',
243 allowNull: true
244 },
245 as: 'FlaggedAccount',
246 onDelete: 'set null'
247 })
248 FlaggedAccount: AccountModel
249
250 @HasOne(() => VideoCommentAbuseModel, {
251 foreignKey: {
252 name: 'abuseId',
253 allowNull: false
254 },
255 onDelete: 'cascade'
256 })
257 VideoCommentAbuse: VideoCommentAbuseModel
258
259 @HasOne(() => VideoAbuseModel, {
260 foreignKey: {
261 name: 'abuseId',
262 allowNull: false
263 },
264 onDelete: 'cascade'
265 })
266 VideoAbuse: VideoAbuseModel
267
268 static loadByIdWithReporter (id: number): Promise<MAbuseReporter> {
269 const query = {
270 where: {
271 id
272 },
273 include: [
274 {
275 model: AccountModel,
276 as: 'ReporterAccount'
277 }
278 ]
279 }
280
281 return AbuseModel.findOne(query)
282 }
283
284 static loadFull (id: number): Promise<MAbuseFull> {
285 const query = {
286 where: {
287 id
288 },
289 include: [
290 {
291 model: AccountModel.scope(AccountScopeNames.SUMMARY),
292 required: false,
293 as: 'ReporterAccount'
294 },
295 {
296 model: AccountModel.scope(AccountScopeNames.SUMMARY),
297 as: 'FlaggedAccount'
298 },
299 {
300 model: VideoAbuseModel,
301 required: false,
302 include: [
303 {
304 model: VideoModel.scope([ VideoScopeNames.WITH_ACCOUNT_DETAILS ])
305 }
306 ]
307 },
308 {
309 model: VideoCommentAbuseModel,
310 required: false,
311 include: [
312 {
313 model: VideoCommentModel.scope([
314 CommentScopeNames.WITH_ACCOUNT
315 ]),
316 include: [
317 {
318 model: VideoModel
319 }
320 ]
321 }
322 ]
323 }
324 ]
325 }
326
327 return AbuseModel.findOne(query)
328 }
329
330 static async listForAdminApi (parameters: {
331 start: number
332 count: number
333 sort: string
334
335 filter?: AbuseFilter
336
337 serverAccountId: number
338 user?: MUserAccountId
339
340 id?: number
341 predefinedReason?: AbusePredefinedReasonsString
342 state?: AbuseState
343 videoIs?: AbuseVideoIs
344
345 search?: string
346 searchReporter?: string
347 searchReportee?: string
348 searchVideo?: string
349 searchVideoChannel?: string
350 }) {
351 const {
352 start,
353 count,
354 sort,
355 search,
356 user,
357 serverAccountId,
358 state,
359 videoIs,
360 predefinedReason,
361 searchReportee,
362 searchVideo,
363 filter,
364 searchVideoChannel,
365 searchReporter,
366 id
367 } = parameters
368
369 const userAccountId = user ? user.Account.id : undefined
370 const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
371
372 const queryOptions: BuildAbusesQueryOptions = {
373 start,
374 count,
375 sort,
376 id,
377 filter,
378 predefinedReasonId,
379 search,
380 state,
381 videoIs,
382 searchReportee,
383 searchVideo,
384 searchVideoChannel,
385 searchReporter,
386 serverAccountId,
387 userAccountId
388 }
389
390 const [ total, data ] = await Promise.all([
391 AbuseModel.internalCountForApi(queryOptions),
392 AbuseModel.internalListForApi(queryOptions)
393 ])
394
395 return { total, data }
396 }
397
398 static async listForUserApi (parameters: {
399 user: MUserAccountId
400
401 start: number
402 count: number
403 sort: string
404
405 id?: number
406 search?: string
407 state?: AbuseState
408 }) {
409 const {
410 start,
411 count,
412 sort,
413 search,
414 user,
415 state,
416 id
417 } = parameters
418
419 const queryOptions: BuildAbusesQueryOptions = {
420 start,
421 count,
422 sort,
423 id,
424 search,
425 state,
426 reporterAccountId: user.Account.id
427 }
428
429 const [ total, data ] = await Promise.all([
430 AbuseModel.internalCountForApi(queryOptions),
431 AbuseModel.internalListForApi(queryOptions)
432 ])
433
434 return { total, data }
435 }
436
437 buildBaseVideoCommentAbuse (this: MAbuseUserFormattable) {
438 // Associated video comment could have been destroyed if the video has been deleted
439 if (!this.VideoCommentAbuse?.VideoComment) return null
440
441 const entity = this.VideoCommentAbuse.VideoComment
442
443 return {
444 id: entity.id,
445 threadId: entity.getThreadId(),
446
447 text: entity.text ?? '',
448
449 deleted: entity.isDeleted(),
450
451 video: {
452 id: entity.Video.id,
453 name: entity.Video.name,
454 uuid: entity.Video.uuid
455 }
456 }
457 }
458
459 buildBaseVideoAbuse (this: MAbuseUserFormattable): UserVideoAbuse {
460 if (!this.VideoAbuse) return null
461
462 const abuseModel = this.VideoAbuse
463 const entity = abuseModel.Video || abuseModel.deletedVideo
464
465 return {
466 id: entity.id,
467 uuid: entity.uuid,
468 name: entity.name,
469 nsfw: entity.nsfw,
470
471 startAt: abuseModel.startAt,
472 endAt: abuseModel.endAt,
473
474 deleted: !abuseModel.Video,
475 blacklisted: abuseModel.Video?.isBlacklisted() || false,
476 thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(),
477
478 channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel
479 }
480 }
481
482 buildBaseAbuse (this: MAbuseUserFormattable, countMessages: number): UserAbuse {
483 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
484
485 return {
486 id: this.id,
487 reason: this.reason,
488 predefinedReasons,
489
490 flaggedAccount: this.FlaggedAccount
491 ? this.FlaggedAccount.toFormattedJSON()
492 : null,
493
494 state: {
495 id: this.state,
496 label: AbuseModel.getStateLabel(this.state)
497 },
498
499 countMessages,
500
501 createdAt: this.createdAt,
502 updatedAt: this.updatedAt
503 }
504 }
505
506 toFormattedAdminJSON (this: MAbuseAdminFormattable): AdminAbuse {
507 const countReportsForVideo = this.get('countReportsForVideo') as number
508 const nthReportForVideo = this.get('nthReportForVideo') as number
509
510 const countReportsForReporter = this.get('countReportsForReporter') as number
511 const countReportsForReportee = this.get('countReportsForReportee') as number
512
513 const countMessages = this.get('countMessages') as number
514
515 const baseVideo = this.buildBaseVideoAbuse()
516 const video: AdminVideoAbuse = baseVideo
517 ? Object.assign(baseVideo, {
518 countReports: countReportsForVideo,
519 nthReport: nthReportForVideo
520 })
521 : null
522
523 const comment: AdminVideoCommentAbuse = this.buildBaseVideoCommentAbuse()
524
525 const abuse = this.buildBaseAbuse(countMessages || 0)
526
527 return Object.assign(abuse, {
528 video,
529 comment,
530
531 moderationComment: this.moderationComment,
532
533 reporterAccount: this.ReporterAccount
534 ? this.ReporterAccount.toFormattedJSON()
535 : null,
536
537 countReportsForReporter: (countReportsForReporter || 0),
538 countReportsForReportee: (countReportsForReportee || 0)
539 })
540 }
541
542 toFormattedUserJSON (this: MAbuseUserFormattable): UserAbuse {
543 const countMessages = this.get('countMessages') as number
544
545 const video = this.buildBaseVideoAbuse()
546 const comment = this.buildBaseVideoCommentAbuse()
547 const abuse = this.buildBaseAbuse(countMessages || 0)
548
549 return Object.assign(abuse, {
550 video,
551 comment
552 })
553 }
554
555 toActivityPubObject (this: MAbuseAP): AbuseObject {
556 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
557
558 const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url
559
560 const startAt = this.VideoAbuse?.startAt
561 const endAt = this.VideoAbuse?.endAt
562
563 return {
564 type: 'Flag' as 'Flag',
565 content: this.reason,
566 mediaType: 'text/markdown',
567 object,
568 tag: predefinedReasons.map(r => ({
569 type: 'Hashtag' as 'Hashtag',
570 name: r
571 })),
572 startAt,
573 endAt
574 }
575 }
576
577 private static async internalCountForApi (parameters: BuildAbusesQueryOptions) {
578 const { query, replacements } = buildAbuseListQuery(parameters, 'count')
579 const options = {
580 type: QueryTypes.SELECT as QueryTypes.SELECT,
581 replacements
582 }
583
584 const [ { total } ] = await AbuseModel.sequelize.query<{ total: string }>(query, options)
585 if (total === null) return 0
586
587 return parseInt(total, 10)
588 }
589
590 private static async internalListForApi (parameters: BuildAbusesQueryOptions) {
591 const { query, replacements } = buildAbuseListQuery(parameters, 'id')
592 const options = {
593 type: QueryTypes.SELECT as QueryTypes.SELECT,
594 replacements
595 }
596
597 const rows = await AbuseModel.sequelize.query<{ id: string }>(query, options)
598 const ids = rows.map(r => r.id)
599
600 if (ids.length === 0) return []
601
602 return AbuseModel.scope(ScopeNames.FOR_API)
603 .findAll({
604 order: getSort(parameters.sort),
605 where: {
606 id: {
607 [Op.in]: ids
608 }
609 }
610 })
611 }
612
613 private static getStateLabel (id: number) {
614 return ABUSE_STATES[id] || 'Unknown'
615 }
616
617 private static getPredefinedReasonsStrings (predefinedReasons: AbusePredefinedReasons[]): AbusePredefinedReasonsString[] {
618 const invertedPredefinedReasons = invert(abusePredefinedReasonsMap)
619
620 return (predefinedReasons || [])
621 .map(r => invertedPredefinedReasons[r] as AbusePredefinedReasonsString)
622 .filter(v => !!v)
623 }
624}
diff --git a/server/models/abuse/sql/abuse-query-builder.ts b/server/models/abuse/sql/abuse-query-builder.ts
deleted file mode 100644
index 282d4541a..000000000
--- a/server/models/abuse/sql/abuse-query-builder.ts
+++ /dev/null
@@ -1,167 +0,0 @@
1
2import { exists } from '@server/helpers/custom-validators/misc'
3import { forceNumber } from '@shared/core-utils'
4import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models'
5import { buildBlockedAccountSQL, buildSortDirectionAndField } from '../../shared'
6
7export type BuildAbusesQueryOptions = {
8 start: number
9 count: number
10 sort: string
11
12 // search
13 search?: string
14 searchReporter?: string
15 searchReportee?: string
16
17 // video related
18 searchVideo?: string
19 searchVideoChannel?: string
20 videoIs?: AbuseVideoIs
21
22 // filters
23 id?: number
24 predefinedReasonId?: number
25 filter?: AbuseFilter
26
27 state?: AbuseState
28
29 // accountIds
30 serverAccountId?: number
31 userAccountId?: number
32
33 reporterAccountId?: number
34}
35
36function buildAbuseListQuery (options: BuildAbusesQueryOptions, type: 'count' | 'id') {
37 const whereAnd: string[] = []
38 const replacements: any = {}
39
40 const joins = [
41 'LEFT JOIN "videoAbuse" ON "videoAbuse"."abuseId" = "abuse"."id"',
42 'LEFT JOIN "video" ON "videoAbuse"."videoId" = "video"."id"',
43 'LEFT JOIN "videoBlacklist" ON "videoBlacklist"."videoId" = "video"."id"',
44 'LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id"',
45 'LEFT JOIN "account" "reporterAccount" ON "reporterAccount"."id" = "abuse"."reporterAccountId"',
46 'LEFT JOIN "account" "flaggedAccount" ON "flaggedAccount"."id" = "abuse"."flaggedAccountId"',
47 'LEFT JOIN "commentAbuse" ON "commentAbuse"."abuseId" = "abuse"."id"',
48 'LEFT JOIN "videoComment" ON "commentAbuse"."videoCommentId" = "videoComment"."id"'
49 ]
50
51 if (options.serverAccountId || options.userAccountId) {
52 whereAnd.push(
53 '"abuse"."reporterAccountId" IS NULL OR ' +
54 '"abuse"."reporterAccountId" NOT IN (' + buildBlockedAccountSQL([ options.serverAccountId, options.userAccountId ]) + ')'
55 )
56 }
57
58 if (options.reporterAccountId) {
59 whereAnd.push('"abuse"."reporterAccountId" = :reporterAccountId')
60 replacements.reporterAccountId = options.reporterAccountId
61 }
62
63 if (options.search) {
64 const searchWhereOr = [
65 '"video"."name" ILIKE :search',
66 '"videoChannel"."name" ILIKE :search',
67 `"videoAbuse"."deletedVideo"->>'name' ILIKE :search`,
68 `"videoAbuse"."deletedVideo"->'channel'->>'displayName' ILIKE :search`,
69 '"reporterAccount"."name" ILIKE :search',
70 '"flaggedAccount"."name" ILIKE :search'
71 ]
72
73 replacements.search = `%${options.search}%`
74 whereAnd.push('(' + searchWhereOr.join(' OR ') + ')')
75 }
76
77 if (options.searchVideo) {
78 whereAnd.push('"video"."name" ILIKE :searchVideo')
79 replacements.searchVideo = `%${options.searchVideo}%`
80 }
81
82 if (options.searchVideoChannel) {
83 whereAnd.push('"videoChannel"."name" ILIKE :searchVideoChannel')
84 replacements.searchVideoChannel = `%${options.searchVideoChannel}%`
85 }
86
87 if (options.id) {
88 whereAnd.push('"abuse"."id" = :id')
89 replacements.id = options.id
90 }
91
92 if (options.state) {
93 whereAnd.push('"abuse"."state" = :state')
94 replacements.state = options.state
95 }
96
97 if (options.videoIs === 'deleted') {
98 whereAnd.push('"videoAbuse"."deletedVideo" IS NOT NULL')
99 } else if (options.videoIs === 'blacklisted') {
100 whereAnd.push('"videoBlacklist"."id" IS NOT NULL')
101 }
102
103 if (options.predefinedReasonId) {
104 whereAnd.push(':predefinedReasonId = ANY("abuse"."predefinedReasons")')
105 replacements.predefinedReasonId = options.predefinedReasonId
106 }
107
108 if (options.filter === 'video') {
109 whereAnd.push('"videoAbuse"."id" IS NOT NULL')
110 } else if (options.filter === 'comment') {
111 whereAnd.push('"commentAbuse"."id" IS NOT NULL')
112 } else if (options.filter === 'account') {
113 whereAnd.push('"videoAbuse"."id" IS NULL AND "commentAbuse"."id" IS NULL')
114 }
115
116 if (options.searchReporter) {
117 whereAnd.push('"reporterAccount"."name" ILIKE :searchReporter')
118 replacements.searchReporter = `%${options.searchReporter}%`
119 }
120
121 if (options.searchReportee) {
122 whereAnd.push('"flaggedAccount"."name" ILIKE :searchReportee')
123 replacements.searchReportee = `%${options.searchReportee}%`
124 }
125
126 const prefix = type === 'count'
127 ? 'SELECT COUNT("abuse"."id") AS "total"'
128 : 'SELECT "abuse"."id" '
129
130 let suffix = ''
131 if (type !== 'count') {
132
133 if (options.sort) {
134 const order = buildAbuseOrder(options.sort)
135 suffix += `${order} `
136 }
137
138 if (exists(options.count)) {
139 const count = forceNumber(options.count)
140 suffix += `LIMIT ${count} `
141 }
142
143 if (exists(options.start)) {
144 const start = forceNumber(options.start)
145 suffix += `OFFSET ${start} `
146 }
147 }
148
149 const where = whereAnd.length !== 0
150 ? `WHERE ${whereAnd.join(' AND ')}`
151 : ''
152
153 return {
154 query: `${prefix} FROM "abuse" ${joins.join(' ')} ${where} ${suffix}`,
155 replacements
156 }
157}
158
159function buildAbuseOrder (value: string) {
160 const { direction, field } = buildSortDirectionAndField(value)
161
162 return `ORDER BY "abuse"."${field}" ${direction}`
163}
164
165export {
166 buildAbuseListQuery
167}
diff --git a/server/models/abuse/video-abuse.ts b/server/models/abuse/video-abuse.ts
deleted file mode 100644
index 773a9ebba..000000000
--- a/server/models/abuse/video-abuse.ts
+++ /dev/null
@@ -1,64 +0,0 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/typescript-utils'
3import { VideoDetails } from '@shared/models'
4import { VideoModel } from '../video/video'
5import { AbuseModel } from './abuse'
6
7@Table({
8 tableName: 'videoAbuse',
9 indexes: [
10 {
11 fields: [ 'abuseId' ]
12 },
13 {
14 fields: [ 'videoId' ]
15 }
16 ]
17})
18export class VideoAbuseModel extends Model<Partial<AttributesOnly<VideoAbuseModel>>> {
19
20 @CreatedAt
21 createdAt: Date
22
23 @UpdatedAt
24 updatedAt: Date
25
26 @AllowNull(true)
27 @Default(null)
28 @Column
29 startAt: number
30
31 @AllowNull(true)
32 @Default(null)
33 @Column
34 endAt: number
35
36 @AllowNull(true)
37 @Default(null)
38 @Column(DataType.JSONB)
39 deletedVideo: VideoDetails
40
41 @ForeignKey(() => AbuseModel)
42 @Column
43 abuseId: number
44
45 @BelongsTo(() => AbuseModel, {
46 foreignKey: {
47 allowNull: false
48 },
49 onDelete: 'cascade'
50 })
51 Abuse: AbuseModel
52
53 @ForeignKey(() => VideoModel)
54 @Column
55 videoId: number
56
57 @BelongsTo(() => VideoModel, {
58 foreignKey: {
59 allowNull: true
60 },
61 onDelete: 'set null'
62 })
63 Video: VideoModel
64}
diff --git a/server/models/abuse/video-comment-abuse.ts b/server/models/abuse/video-comment-abuse.ts
deleted file mode 100644
index 337aaaa58..000000000
--- a/server/models/abuse/video-comment-abuse.ts
+++ /dev/null
@@ -1,48 +0,0 @@
1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/typescript-utils'
3import { VideoCommentModel } from '../video/video-comment'
4import { AbuseModel } from './abuse'
5
6@Table({
7 tableName: 'commentAbuse',
8 indexes: [
9 {
10 fields: [ 'abuseId' ]
11 },
12 {
13 fields: [ 'videoCommentId' ]
14 }
15 ]
16})
17export class VideoCommentAbuseModel extends Model<Partial<AttributesOnly<VideoCommentAbuseModel>>> {
18
19 @CreatedAt
20 createdAt: Date
21
22 @UpdatedAt
23 updatedAt: Date
24
25 @ForeignKey(() => AbuseModel)
26 @Column
27 abuseId: number
28
29 @BelongsTo(() => AbuseModel, {
30 foreignKey: {
31 allowNull: false
32 },
33 onDelete: 'cascade'
34 })
35 Abuse: AbuseModel
36
37 @ForeignKey(() => VideoCommentModel)
38 @Column
39 videoCommentId: number
40
41 @BelongsTo(() => VideoCommentModel, {
42 foreignKey: {
43 allowNull: true
44 },
45 onDelete: 'set null'
46 })
47 VideoComment: VideoCommentModel
48}
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
deleted file mode 100644
index f6212ff6e..000000000
--- a/server/models/account/account-blocklist.ts
+++ /dev/null
@@ -1,236 +0,0 @@
1import { FindOptions, Op, QueryTypes } from 'sequelize'
2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { handlesToNameAndHost } from '@server/helpers/actors'
4import { MAccountBlocklist, MAccountBlocklistFormattable } from '@server/types/models'
5import { AttributesOnly } from '@shared/typescript-utils'
6import { AccountBlock } from '../../../shared/models'
7import { ActorModel } from '../actor/actor'
8import { ServerModel } from '../server/server'
9import { createSafeIn, getSort, searchAttribute } from '../shared'
10import { AccountModel } from './account'
11
12@Table({
13 tableName: 'accountBlocklist',
14 indexes: [
15 {
16 fields: [ 'accountId', 'targetAccountId' ],
17 unique: true
18 },
19 {
20 fields: [ 'targetAccountId' ]
21 }
22 ]
23})
24export class AccountBlocklistModel extends Model<Partial<AttributesOnly<AccountBlocklistModel>>> {
25
26 @CreatedAt
27 createdAt: Date
28
29 @UpdatedAt
30 updatedAt: Date
31
32 @ForeignKey(() => AccountModel)
33 @Column
34 accountId: number
35
36 @BelongsTo(() => AccountModel, {
37 foreignKey: {
38 name: 'accountId',
39 allowNull: false
40 },
41 as: 'ByAccount',
42 onDelete: 'CASCADE'
43 })
44 ByAccount: AccountModel
45
46 @ForeignKey(() => AccountModel)
47 @Column
48 targetAccountId: number
49
50 @BelongsTo(() => AccountModel, {
51 foreignKey: {
52 name: 'targetAccountId',
53 allowNull: false
54 },
55 as: 'BlockedAccount',
56 onDelete: 'CASCADE'
57 })
58 BlockedAccount: AccountModel
59
60 static isAccountMutedByAccounts (accountIds: number[], targetAccountId: number) {
61 const query = {
62 attributes: [ 'accountId', 'id' ],
63 where: {
64 accountId: {
65 [Op.in]: accountIds
66 },
67 targetAccountId
68 },
69 raw: true
70 }
71
72 return AccountBlocklistModel.unscoped()
73 .findAll(query)
74 .then(rows => {
75 const result: { [accountId: number]: boolean } = {}
76
77 for (const accountId of accountIds) {
78 result[accountId] = !!rows.find(r => r.accountId === accountId)
79 }
80
81 return result
82 })
83 }
84
85 static loadByAccountAndTarget (accountId: number, targetAccountId: number): Promise<MAccountBlocklist> {
86 const query = {
87 where: {
88 accountId,
89 targetAccountId
90 }
91 }
92
93 return AccountBlocklistModel.findOne(query)
94 }
95
96 static listForApi (parameters: {
97 start: number
98 count: number
99 sort: string
100 search?: string
101 accountId: number
102 }) {
103 const { start, count, sort, search, accountId } = parameters
104
105 const getQuery = (forCount: boolean) => {
106 const query: FindOptions = {
107 offset: start,
108 limit: count,
109 order: getSort(sort),
110 where: { accountId }
111 }
112
113 if (search) {
114 Object.assign(query.where, {
115 [Op.or]: [
116 searchAttribute(search, '$BlockedAccount.name$'),
117 searchAttribute(search, '$BlockedAccount.Actor.url$')
118 ]
119 })
120 }
121
122 if (forCount !== true) {
123 query.include = [
124 {
125 model: AccountModel,
126 required: true,
127 as: 'ByAccount'
128 },
129 {
130 model: AccountModel,
131 required: true,
132 as: 'BlockedAccount'
133 }
134 ]
135 } else if (search) { // We need some joins when counting with search
136 query.include = [
137 {
138 model: AccountModel.unscoped(),
139 required: true,
140 as: 'BlockedAccount',
141 include: [
142 {
143 model: ActorModel.unscoped(),
144 required: true
145 }
146 ]
147 }
148 ]
149 }
150
151 return query
152 }
153
154 return Promise.all([
155 AccountBlocklistModel.count(getQuery(true)),
156 AccountBlocklistModel.findAll(getQuery(false))
157 ]).then(([ total, data ]) => ({ total, data }))
158 }
159
160 static listHandlesBlockedBy (accountIds: number[]): Promise<string[]> {
161 const query = {
162 attributes: [ 'id' ],
163 where: {
164 accountId: {
165 [Op.in]: accountIds
166 }
167 },
168 include: [
169 {
170 attributes: [ 'id' ],
171 model: AccountModel.unscoped(),
172 required: true,
173 as: 'BlockedAccount',
174 include: [
175 {
176 attributes: [ 'preferredUsername' ],
177 model: ActorModel.unscoped(),
178 required: true,
179 include: [
180 {
181 attributes: [ 'host' ],
182 model: ServerModel.unscoped(),
183 required: true
184 }
185 ]
186 }
187 ]
188 }
189 ]
190 }
191
192 return AccountBlocklistModel.findAll(query)
193 .then(entries => entries.map(e => `${e.BlockedAccount.Actor.preferredUsername}@${e.BlockedAccount.Actor.Server.host}`))
194 }
195
196 static getBlockStatus (byAccountIds: number[], handles: string[]): Promise<{ name: string, host: string, accountId: number }[]> {
197 const sanitizedHandles = handlesToNameAndHost(handles)
198
199 const localHandles = sanitizedHandles.filter(h => !h.host)
200 .map(h => h.name)
201
202 const remoteHandles = sanitizedHandles.filter(h => !!h.host)
203 .map(h => ([ h.name, h.host ]))
204
205 const handlesWhere: string[] = []
206
207 if (localHandles.length !== 0) {
208 handlesWhere.push(`("actor"."preferredUsername" IN (:localHandles) AND "server"."id" IS NULL)`)
209 }
210
211 if (remoteHandles.length !== 0) {
212 handlesWhere.push(`(("actor"."preferredUsername", "server"."host") IN (:remoteHandles))`)
213 }
214
215 const rawQuery = `SELECT "accountBlocklist"."accountId", "actor"."preferredUsername" AS "name", "server"."host" ` +
216 `FROM "accountBlocklist" ` +
217 `INNER JOIN "account" ON "account"."id" = "accountBlocklist"."targetAccountId" ` +
218 `INNER JOIN "actor" ON "actor"."id" = "account"."actorId" ` +
219 `LEFT JOIN "server" ON "server"."id" = "actor"."serverId" ` +
220 `WHERE "accountBlocklist"."accountId" IN (${createSafeIn(AccountBlocklistModel.sequelize, byAccountIds)}) ` +
221 `AND (${handlesWhere.join(' OR ')})`
222
223 return AccountBlocklistModel.sequelize.query(rawQuery, {
224 type: QueryTypes.SELECT as QueryTypes.SELECT,
225 replacements: { byAccountIds, localHandles, remoteHandles }
226 })
227 }
228
229 toFormattedJSON (this: MAccountBlocklistFormattable): AccountBlock {
230 return {
231 byAccount: this.ByAccount.toFormattedJSON(),
232 blockedAccount: this.BlockedAccount.toFormattedJSON(),
233 createdAt: this.createdAt
234 }
235 }
236}
diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts
deleted file mode 100644
index 18ff07d53..000000000
--- a/server/models/account/account-video-rate.ts
+++ /dev/null
@@ -1,259 +0,0 @@
1import { FindOptions, Op, QueryTypes, Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import {
4 MAccountVideoRate,
5 MAccountVideoRateAccountUrl,
6 MAccountVideoRateAccountVideo,
7 MAccountVideoRateFormattable
8} from '@server/types/models'
9import { AccountVideoRate, VideoRateType } from '@shared/models'
10import { AttributesOnly } from '@shared/typescript-utils'
11import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
12import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants'
13import { ActorModel } from '../actor/actor'
14import { getSort, throwIfNotValid } from '../shared'
15import { VideoModel } from '../video/video'
16import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
17import { AccountModel } from './account'
18
19/*
20 Account rates per video.
21*/
22@Table({
23 tableName: 'accountVideoRate',
24 indexes: [
25 {
26 fields: [ 'videoId', 'accountId' ],
27 unique: true
28 },
29 {
30 fields: [ 'videoId' ]
31 },
32 {
33 fields: [ 'accountId' ]
34 },
35 {
36 fields: [ 'videoId', 'type' ]
37 },
38 {
39 fields: [ 'url' ],
40 unique: true
41 }
42 ]
43})
44export class AccountVideoRateModel extends Model<Partial<AttributesOnly<AccountVideoRateModel>>> {
45
46 @AllowNull(false)
47 @Column(DataType.ENUM(...Object.values(VIDEO_RATE_TYPES)))
48 type: VideoRateType
49
50 @AllowNull(false)
51 @Is('AccountVideoRateUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
52 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_RATES.URL.max))
53 url: string
54
55 @CreatedAt
56 createdAt: Date
57
58 @UpdatedAt
59 updatedAt: Date
60
61 @ForeignKey(() => VideoModel)
62 @Column
63 videoId: number
64
65 @BelongsTo(() => VideoModel, {
66 foreignKey: {
67 allowNull: false
68 },
69 onDelete: 'CASCADE'
70 })
71 Video: VideoModel
72
73 @ForeignKey(() => AccountModel)
74 @Column
75 accountId: number
76
77 @BelongsTo(() => AccountModel, {
78 foreignKey: {
79 allowNull: false
80 },
81 onDelete: 'CASCADE'
82 })
83 Account: AccountModel
84
85 static load (accountId: number, videoId: number, transaction?: Transaction): Promise<MAccountVideoRate> {
86 const options: FindOptions = {
87 where: {
88 accountId,
89 videoId
90 }
91 }
92 if (transaction) options.transaction = transaction
93
94 return AccountVideoRateModel.findOne(options)
95 }
96
97 static loadByAccountAndVideoOrUrl (accountId: number, videoId: number, url: string, t?: Transaction): Promise<MAccountVideoRate> {
98 const options: FindOptions = {
99 where: {
100 [Op.or]: [
101 {
102 accountId,
103 videoId
104 },
105 {
106 url
107 }
108 ]
109 }
110 }
111 if (t) options.transaction = t
112
113 return AccountVideoRateModel.findOne(options)
114 }
115
116 static listByAccountForApi (options: {
117 start: number
118 count: number
119 sort: string
120 type?: string
121 accountId: number
122 }) {
123 const getQuery = (forCount: boolean) => {
124 const query: FindOptions = {
125 offset: options.start,
126 limit: options.count,
127 order: getSort(options.sort),
128 where: {
129 accountId: options.accountId
130 }
131 }
132
133 if (options.type) query.where['type'] = options.type
134
135 if (forCount !== true) {
136 query.include = [
137 {
138 model: VideoModel,
139 required: true,
140 include: [
141 {
142 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
143 required: true
144 }
145 ]
146 }
147 ]
148 }
149
150 return query
151 }
152
153 return Promise.all([
154 AccountVideoRateModel.count(getQuery(true)),
155 AccountVideoRateModel.findAll(getQuery(false))
156 ]).then(([ total, data ]) => ({ total, data }))
157 }
158
159 static listRemoteRateUrlsOfLocalVideos () {
160 const query = `SELECT "accountVideoRate".url FROM "accountVideoRate" ` +
161 `INNER JOIN account ON account.id = "accountVideoRate"."accountId" ` +
162 `INNER JOIN actor ON actor.id = account."actorId" AND actor."serverId" IS NOT NULL ` +
163 `INNER JOIN video ON video.id = "accountVideoRate"."videoId" AND video.remote IS FALSE`
164
165 return AccountVideoRateModel.sequelize.query<{ url: string }>(query, {
166 type: QueryTypes.SELECT,
167 raw: true
168 }).then(rows => rows.map(r => r.url))
169 }
170
171 static loadLocalAndPopulateVideo (
172 rateType: VideoRateType,
173 accountName: string,
174 videoId: number,
175 t?: Transaction
176 ): Promise<MAccountVideoRateAccountVideo> {
177 const options: FindOptions = {
178 where: {
179 videoId,
180 type: rateType
181 },
182 include: [
183 {
184 model: AccountModel.unscoped(),
185 required: true,
186 include: [
187 {
188 attributes: [ 'id', 'url', 'followersUrl', 'preferredUsername' ],
189 model: ActorModel.unscoped(),
190 required: true,
191 where: {
192 [Op.and]: [
193 ActorModel.wherePreferredUsername(accountName),
194 { serverId: null }
195 ]
196 }
197 }
198 ]
199 },
200 {
201 model: VideoModel.unscoped(),
202 required: true
203 }
204 ]
205 }
206 if (t) options.transaction = t
207
208 return AccountVideoRateModel.findOne(options)
209 }
210
211 static loadByUrl (url: string, transaction: Transaction) {
212 const options: FindOptions = {
213 where: {
214 url
215 }
216 }
217 if (transaction) options.transaction = transaction
218
219 return AccountVideoRateModel.findOne(options)
220 }
221
222 static listAndCountAccountUrlsByVideoId (rateType: VideoRateType, videoId: number, start: number, count: number, t?: Transaction) {
223 const query = {
224 offset: start,
225 limit: count,
226 where: {
227 videoId,
228 type: rateType
229 },
230 transaction: t,
231 include: [
232 {
233 attributes: [ 'actorId' ],
234 model: AccountModel.unscoped(),
235 required: true,
236 include: [
237 {
238 attributes: [ 'url' ],
239 model: ActorModel.unscoped(),
240 required: true
241 }
242 ]
243 }
244 ]
245 }
246
247 return Promise.all([
248 AccountVideoRateModel.count(query),
249 AccountVideoRateModel.findAll<MAccountVideoRateAccountUrl>(query)
250 ]).then(([ total, data ]) => ({ total, data }))
251 }
252
253 toFormattedJSON (this: MAccountVideoRateFormattable): AccountVideoRate {
254 return {
255 video: this.Video.toFormattedJSON(),
256 rating: this.type
257 }
258 }
259}
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
deleted file mode 100644
index 8593f2f28..000000000
--- a/server/models/account/account.ts
+++ /dev/null
@@ -1,468 +0,0 @@
1import { FindOptions, Includeable, IncludeOptions, Op, Transaction, WhereOptions } from 'sequelize'
2import {
3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 Default,
10 DefaultScope,
11 ForeignKey,
12 HasMany,
13 Is,
14 Model,
15 Scopes,
16 Table,
17 UpdatedAt
18} from 'sequelize-typescript'
19import { ModelCache } from '@server/models/shared/model-cache'
20import { AttributesOnly } from '@shared/typescript-utils'
21import { Account, AccountSummary } from '../../../shared/models/actors'
22import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
23import { CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants'
24import { sendDeleteActor } from '../../lib/activitypub/send/send-delete'
25import {
26 MAccount,
27 MAccountActor,
28 MAccountAP,
29 MAccountDefault,
30 MAccountFormattable,
31 MAccountHost,
32 MAccountSummaryFormattable,
33 MChannelHost
34} from '../../types/models'
35import { ActorModel } from '../actor/actor'
36import { ActorFollowModel } from '../actor/actor-follow'
37import { ActorImageModel } from '../actor/actor-image'
38import { ApplicationModel } from '../application/application'
39import { ServerModel } from '../server/server'
40import { ServerBlocklistModel } from '../server/server-blocklist'
41import { buildSQLAttributes, getSort, throwIfNotValid } from '../shared'
42import { UserModel } from '../user/user'
43import { VideoModel } from '../video/video'
44import { VideoChannelModel } from '../video/video-channel'
45import { VideoCommentModel } from '../video/video-comment'
46import { VideoPlaylistModel } from '../video/video-playlist'
47import { AccountBlocklistModel } from './account-blocklist'
48
49export enum ScopeNames {
50 SUMMARY = 'SUMMARY'
51}
52
53export type SummaryOptions = {
54 actorRequired?: boolean // Default: true
55 whereActor?: WhereOptions
56 whereServer?: WhereOptions
57 withAccountBlockerIds?: number[]
58 forCount?: boolean
59}
60
61@DefaultScope(() => ({
62 include: [
63 {
64 model: ActorModel, // Default scope includes avatar and server
65 required: true
66 }
67 ]
68}))
69@Scopes(() => ({
70 [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
71 const serverInclude: IncludeOptions = {
72 attributes: [ 'host' ],
73 model: ServerModel.unscoped(),
74 required: !!options.whereServer,
75 where: options.whereServer
76 }
77
78 const actorInclude: Includeable = {
79 attributes: [ 'id', 'preferredUsername', 'url', 'serverId' ],
80 model: ActorModel.unscoped(),
81 required: options.actorRequired ?? true,
82 where: options.whereActor,
83 include: [ serverInclude ]
84 }
85
86 if (options.forCount !== true) {
87 actorInclude.include.push({
88 model: ActorImageModel,
89 as: 'Avatars',
90 required: false
91 })
92 }
93
94 const queryInclude: Includeable[] = [
95 actorInclude
96 ]
97
98 const query: FindOptions = {
99 attributes: [ 'id', 'name', 'actorId' ]
100 }
101
102 if (options.withAccountBlockerIds) {
103 queryInclude.push({
104 attributes: [ 'id' ],
105 model: AccountBlocklistModel.unscoped(),
106 as: 'BlockedBy',
107 required: false,
108 where: {
109 accountId: {
110 [Op.in]: options.withAccountBlockerIds
111 }
112 }
113 })
114
115 serverInclude.include = [
116 {
117 attributes: [ 'id' ],
118 model: ServerBlocklistModel.unscoped(),
119 required: false,
120 where: {
121 accountId: {
122 [Op.in]: options.withAccountBlockerIds
123 }
124 }
125 }
126 ]
127 }
128
129 query.include = queryInclude
130
131 return query
132 }
133}))
134@Table({
135 tableName: 'account',
136 indexes: [
137 {
138 fields: [ 'actorId' ],
139 unique: true
140 },
141 {
142 fields: [ 'applicationId' ]
143 },
144 {
145 fields: [ 'userId' ]
146 }
147 ]
148})
149export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
150
151 @AllowNull(false)
152 @Column
153 name: string
154
155 @AllowNull(true)
156 @Default(null)
157 @Is('AccountDescription', value => throwIfNotValid(value, isAccountDescriptionValid, 'description', true))
158 @Column(DataType.STRING(CONSTRAINTS_FIELDS.USERS.DESCRIPTION.max))
159 description: string
160
161 @CreatedAt
162 createdAt: Date
163
164 @UpdatedAt
165 updatedAt: Date
166
167 @ForeignKey(() => ActorModel)
168 @Column
169 actorId: number
170
171 @BelongsTo(() => ActorModel, {
172 foreignKey: {
173 allowNull: false
174 },
175 onDelete: 'cascade'
176 })
177 Actor: ActorModel
178
179 @ForeignKey(() => UserModel)
180 @Column
181 userId: number
182
183 @BelongsTo(() => UserModel, {
184 foreignKey: {
185 allowNull: true
186 },
187 onDelete: 'cascade'
188 })
189 User: UserModel
190
191 @ForeignKey(() => ApplicationModel)
192 @Column
193 applicationId: number
194
195 @BelongsTo(() => ApplicationModel, {
196 foreignKey: {
197 allowNull: true
198 },
199 onDelete: 'cascade'
200 })
201 Application: ApplicationModel
202
203 @HasMany(() => VideoChannelModel, {
204 foreignKey: {
205 allowNull: false
206 },
207 onDelete: 'cascade',
208 hooks: true
209 })
210 VideoChannels: VideoChannelModel[]
211
212 @HasMany(() => VideoPlaylistModel, {
213 foreignKey: {
214 allowNull: false
215 },
216 onDelete: 'cascade',
217 hooks: true
218 })
219 VideoPlaylists: VideoPlaylistModel[]
220
221 @HasMany(() => VideoCommentModel, {
222 foreignKey: {
223 allowNull: true
224 },
225 onDelete: 'cascade',
226 hooks: true
227 })
228 VideoComments: VideoCommentModel[]
229
230 @HasMany(() => AccountBlocklistModel, {
231 foreignKey: {
232 name: 'targetAccountId',
233 allowNull: false
234 },
235 as: 'BlockedBy',
236 onDelete: 'CASCADE'
237 })
238 BlockedBy: AccountBlocklistModel[]
239
240 @BeforeDestroy
241 static async sendDeleteIfOwned (instance: AccountModel, options) {
242 if (!instance.Actor) {
243 instance.Actor = await instance.$get('Actor', { transaction: options.transaction })
244 }
245
246 await ActorFollowModel.removeFollowsOf(instance.Actor.id, options.transaction)
247
248 if (instance.isOwned()) {
249 return sendDeleteActor(instance.Actor, options.transaction)
250 }
251
252 return undefined
253 }
254
255 // ---------------------------------------------------------------------------
256
257 static getSQLAttributes (tableName: string, aliasPrefix = '') {
258 return buildSQLAttributes({
259 model: this,
260 tableName,
261 aliasPrefix
262 })
263 }
264
265 // ---------------------------------------------------------------------------
266
267 static load (id: number, transaction?: Transaction): Promise<MAccountDefault> {
268 return AccountModel.findByPk(id, { transaction })
269 }
270
271 static loadByNameWithHost (nameWithHost: string): Promise<MAccountDefault> {
272 const [ accountName, host ] = nameWithHost.split('@')
273
274 if (!host || host === WEBSERVER.HOST) return AccountModel.loadLocalByName(accountName)
275
276 return AccountModel.loadByNameAndHost(accountName, host)
277 }
278
279 static loadLocalByName (name: string): Promise<MAccountDefault> {
280 const fun = () => {
281 const query = {
282 where: {
283 [Op.or]: [
284 {
285 userId: {
286 [Op.ne]: null
287 }
288 },
289 {
290 applicationId: {
291 [Op.ne]: null
292 }
293 }
294 ]
295 },
296 include: [
297 {
298 model: ActorModel,
299 required: true,
300 where: ActorModel.wherePreferredUsername(name)
301 }
302 ]
303 }
304
305 return AccountModel.findOne(query)
306 }
307
308 return ModelCache.Instance.doCache({
309 cacheType: 'local-account-name',
310 key: name,
311 fun,
312 // The server actor never change, so we can easily cache it
313 whitelist: () => name === SERVER_ACTOR_NAME
314 })
315 }
316
317 static loadByNameAndHost (name: string, host: string): Promise<MAccountDefault> {
318 const query = {
319 include: [
320 {
321 model: ActorModel,
322 required: true,
323 where: ActorModel.wherePreferredUsername(name),
324 include: [
325 {
326 model: ServerModel,
327 required: true,
328 where: {
329 host
330 }
331 }
332 ]
333 }
334 ]
335 }
336
337 return AccountModel.findOne(query)
338 }
339
340 static loadByUrl (url: string, transaction?: Transaction): Promise<MAccountDefault> {
341 const query = {
342 include: [
343 {
344 model: ActorModel,
345 required: true,
346 where: {
347 url
348 }
349 }
350 ],
351 transaction
352 }
353
354 return AccountModel.findOne(query)
355 }
356
357 static listForApi (start: number, count: number, sort: string) {
358 const query = {
359 offset: start,
360 limit: count,
361 order: getSort(sort)
362 }
363
364 return Promise.all([
365 AccountModel.count(),
366 AccountModel.findAll(query)
367 ]).then(([ total, data ]) => ({ total, data }))
368 }
369
370 static loadAccountIdFromVideo (videoId: number): Promise<MAccount> {
371 const query = {
372 include: [
373 {
374 attributes: [ 'id', 'accountId' ],
375 model: VideoChannelModel.unscoped(),
376 required: true,
377 include: [
378 {
379 attributes: [ 'id', 'channelId' ],
380 model: VideoModel.unscoped(),
381 where: {
382 id: videoId
383 }
384 }
385 ]
386 }
387 ]
388 }
389
390 return AccountModel.findOne(query)
391 }
392
393 static listLocalsForSitemap (sort: string): Promise<MAccountActor[]> {
394 const query = {
395 attributes: [ ],
396 offset: 0,
397 order: getSort(sort),
398 include: [
399 {
400 attributes: [ 'preferredUsername', 'serverId' ],
401 model: ActorModel.unscoped(),
402 where: {
403 serverId: null
404 }
405 }
406 ]
407 }
408
409 return AccountModel
410 .unscoped()
411 .findAll(query)
412 }
413
414 toFormattedJSON (this: MAccountFormattable): Account {
415 return {
416 ...this.Actor.toFormattedJSON(),
417
418 id: this.id,
419 displayName: this.getDisplayName(),
420 description: this.description,
421 updatedAt: this.updatedAt,
422 userId: this.userId ?? undefined
423 }
424 }
425
426 toFormattedSummaryJSON (this: MAccountSummaryFormattable): AccountSummary {
427 const actor = this.Actor.toFormattedSummaryJSON()
428
429 return {
430 id: this.id,
431 displayName: this.getDisplayName(),
432
433 name: actor.name,
434 url: actor.url,
435 host: actor.host,
436 avatars: actor.avatars
437 }
438 }
439
440 async toActivityPubObject (this: MAccountAP) {
441 const obj = await this.Actor.toActivityPubObject(this.name)
442
443 return Object.assign(obj, {
444 summary: this.description
445 })
446 }
447
448 isOwned () {
449 return this.Actor.isOwned()
450 }
451
452 isOutdated () {
453 return this.Actor.isOutdated()
454 }
455
456 getDisplayName () {
457 return this.name
458 }
459
460 // Avoid error when running this method on MAccount... | MChannel...
461 getClientUrl (this: MAccountHost | MChannelHost) {
462 return WEBSERVER.URL + '/a/' + this.Actor.getIdentifier()
463 }
464
465 isBlocked () {
466 return this.BlockedBy && this.BlockedBy.length !== 0
467 }
468}
diff --git a/server/models/account/actor-custom-page.ts b/server/models/account/actor-custom-page.ts
deleted file mode 100644
index 893023181..000000000
--- a/server/models/account/actor-custom-page.ts
+++ /dev/null
@@ -1,69 +0,0 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { CustomPage } from '@shared/models'
3import { ActorModel } from '../actor/actor'
4import { getServerActor } from '../application/application'
5
6@Table({
7 tableName: 'actorCustomPage',
8 indexes: [
9 {
10 fields: [ 'actorId', 'type' ],
11 unique: true
12 }
13 ]
14})
15export class ActorCustomPageModel extends Model {
16
17 @AllowNull(true)
18 @Column(DataType.TEXT)
19 content: string
20
21 @AllowNull(false)
22 @Column
23 type: 'homepage'
24
25 @CreatedAt
26 createdAt: Date
27
28 @UpdatedAt
29 updatedAt: Date
30
31 @ForeignKey(() => ActorModel)
32 @Column
33 actorId: number
34
35 @BelongsTo(() => ActorModel, {
36 foreignKey: {
37 name: 'actorId',
38 allowNull: false
39 },
40 onDelete: 'cascade'
41 })
42 Actor: ActorModel
43
44 static async updateInstanceHomepage (content: string) {
45 const serverActor = await getServerActor()
46
47 return ActorCustomPageModel.upsert({
48 content,
49 actorId: serverActor.id,
50 type: 'homepage'
51 })
52 }
53
54 static async loadInstanceHomepage () {
55 const serverActor = await getServerActor()
56
57 return ActorCustomPageModel.findOne({
58 where: {
59 actorId: serverActor.id
60 }
61 })
62 }
63
64 toFormattedJSON (): CustomPage {
65 return {
66 content: this.content
67 }
68 }
69}
diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts
deleted file mode 100644
index 71ce9fa6f..000000000
--- a/server/models/actor/actor-follow.ts
+++ /dev/null
@@ -1,662 +0,0 @@
1import { difference } from 'lodash'
2import { Attributes, FindOptions, Includeable, IncludeOptions, Op, QueryTypes, Transaction, WhereAttributeHash } from 'sequelize'
3import {
4 AfterCreate,
5 AfterDestroy,
6 AfterUpdate,
7 AllowNull,
8 BelongsTo,
9 Column,
10 CreatedAt,
11 DataType,
12 Default,
13 ForeignKey,
14 Is,
15 IsInt,
16 Max,
17 Model,
18 Table,
19 UpdatedAt
20} from 'sequelize-typescript'
21import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc'
22import { afterCommitIfTransaction } from '@server/helpers/database-utils'
23import { getServerActor } from '@server/models/application/application'
24import {
25 MActor,
26 MActorFollowActors,
27 MActorFollowActorsDefault,
28 MActorFollowActorsDefaultSubscription,
29 MActorFollowFollowingHost,
30 MActorFollowFormattable,
31 MActorFollowSubscriptions
32} from '@server/types/models'
33import { AttributesOnly } from '@shared/typescript-utils'
34import { FollowState } from '../../../shared/models/actors'
35import { ActorFollow } from '../../../shared/models/actors/follow.model'
36import { logger } from '../../helpers/logger'
37import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME, SORTABLE_COLUMNS } from '../../initializers/constants'
38import { AccountModel } from '../account/account'
39import { ServerModel } from '../server/server'
40import { buildSQLAttributes, createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../shared'
41import { doesExist } from '../shared/query'
42import { VideoChannelModel } from '../video/video-channel'
43import { ActorModel, unusedActorAttributesForAPI } from './actor'
44import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder'
45import { InstanceListFollowingQueryBuilder, ListFollowingOptions } from './sql/instance-list-following-query-builder'
46
47@Table({
48 tableName: 'actorFollow',
49 indexes: [
50 {
51 fields: [ 'actorId' ]
52 },
53 {
54 fields: [ 'targetActorId' ]
55 },
56 {
57 fields: [ 'actorId', 'targetActorId' ],
58 unique: true
59 },
60 {
61 fields: [ 'score' ]
62 },
63 {
64 fields: [ 'url' ],
65 unique: true
66 }
67 ]
68})
69export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowModel>>> {
70
71 @AllowNull(false)
72 @Column(DataType.ENUM(...Object.values(FOLLOW_STATES)))
73 state: FollowState
74
75 @AllowNull(false)
76 @Default(ACTOR_FOLLOW_SCORE.BASE)
77 @IsInt
78 @Max(ACTOR_FOLLOW_SCORE.MAX)
79 @Column
80 score: number
81
82 // Allow null because we added this column in PeerTube v3, and don't want to generate fake URLs of remote follows
83 @AllowNull(true)
84 @Is('ActorFollowUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
85 @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
86 url: string
87
88 @CreatedAt
89 createdAt: Date
90
91 @UpdatedAt
92 updatedAt: Date
93
94 @ForeignKey(() => ActorModel)
95 @Column
96 actorId: number
97
98 @BelongsTo(() => ActorModel, {
99 foreignKey: {
100 name: 'actorId',
101 allowNull: false
102 },
103 as: 'ActorFollower',
104 onDelete: 'CASCADE'
105 })
106 ActorFollower: ActorModel
107
108 @ForeignKey(() => ActorModel)
109 @Column
110 targetActorId: number
111
112 @BelongsTo(() => ActorModel, {
113 foreignKey: {
114 name: 'targetActorId',
115 allowNull: false
116 },
117 as: 'ActorFollowing',
118 onDelete: 'CASCADE'
119 })
120 ActorFollowing: ActorModel
121
122 @AfterCreate
123 @AfterUpdate
124 static incrementFollowerAndFollowingCount (instance: ActorFollowModel, options: any) {
125 return afterCommitIfTransaction(options.transaction, () => {
126 return Promise.all([
127 ActorModel.rebuildFollowsCount(instance.actorId, 'following'),
128 ActorModel.rebuildFollowsCount(instance.targetActorId, 'followers')
129 ])
130 })
131 }
132
133 @AfterDestroy
134 static decrementFollowerAndFollowingCount (instance: ActorFollowModel, options: any) {
135 return afterCommitIfTransaction(options.transaction, () => {
136 return Promise.all([
137 ActorModel.rebuildFollowsCount(instance.actorId, 'following'),
138 ActorModel.rebuildFollowsCount(instance.targetActorId, 'followers')
139 ])
140 })
141 }
142
143 // ---------------------------------------------------------------------------
144
145 static getSQLAttributes (tableName: string, aliasPrefix = '') {
146 return buildSQLAttributes({
147 model: this,
148 tableName,
149 aliasPrefix
150 })
151 }
152
153 // ---------------------------------------------------------------------------
154
155 /*
156 * @deprecated Use `findOrCreateCustom` instead
157 */
158 static findOrCreate (): any {
159 throw new Error('Must not be called')
160 }
161
162 // findOrCreate has issues with actor follow hooks
163 static async findOrCreateCustom (options: {
164 byActor: MActor
165 targetActor: MActor
166 activityId: string
167 state: FollowState
168 transaction: Transaction
169 }): Promise<[ MActorFollowActors, boolean ]> {
170 const { byActor, targetActor, activityId, state, transaction } = options
171
172 let created = false
173 let actorFollow: MActorFollowActors = await ActorFollowModel.loadByActorAndTarget(byActor.id, targetActor.id, transaction)
174
175 if (!actorFollow) {
176 created = true
177
178 actorFollow = await ActorFollowModel.create({
179 actorId: byActor.id,
180 targetActorId: targetActor.id,
181 url: activityId,
182
183 state
184 }, { transaction })
185
186 actorFollow.ActorFollowing = targetActor
187 actorFollow.ActorFollower = byActor
188 }
189
190 return [ actorFollow, created ]
191 }
192
193 static removeFollowsOf (actorId: number, t?: Transaction) {
194 const query = {
195 where: {
196 [Op.or]: [
197 {
198 actorId
199 },
200 {
201 targetActorId: actorId
202 }
203 ]
204 },
205 transaction: t
206 }
207
208 return ActorFollowModel.destroy(query)
209 }
210
211 // Remove actor follows with a score of 0 (too many requests where they were unreachable)
212 static async removeBadActorFollows () {
213 const actorFollows = await ActorFollowModel.listBadActorFollows()
214
215 const actorFollowsRemovePromises = actorFollows.map(actorFollow => actorFollow.destroy())
216 await Promise.all(actorFollowsRemovePromises)
217
218 const numberOfActorFollowsRemoved = actorFollows.length
219
220 if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved)
221 }
222
223 static isFollowedBy (actorId: number, followerActorId: number) {
224 const query = `SELECT 1 FROM "actorFollow" ` +
225 `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` +
226 `LIMIT 1`
227
228 return doesExist(this.sequelize, query, { actorId, followerActorId })
229 }
230
231 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> {
232 const query = {
233 where: {
234 actorId,
235 targetActorId
236 },
237 include: [
238 {
239 model: ActorModel,
240 required: true,
241 as: 'ActorFollower'
242 },
243 {
244 model: ActorModel,
245 required: true,
246 as: 'ActorFollowing'
247 }
248 ],
249 transaction: t
250 }
251
252 return ActorFollowModel.findOne(query)
253 }
254
255 static loadByActorAndTargetNameAndHostForAPI (options: {
256 actorId: number
257 targetName: string
258 targetHost: string
259 state?: FollowState
260 transaction?: Transaction
261 }): Promise<MActorFollowActorsDefaultSubscription> {
262 const { actorId, targetHost, targetName, state, transaction } = options
263
264 const actorFollowingPartInclude: IncludeOptions = {
265 model: ActorModel,
266 required: true,
267 as: 'ActorFollowing',
268 where: ActorModel.wherePreferredUsername(targetName),
269 include: [
270 {
271 model: VideoChannelModel.unscoped(),
272 required: false
273 }
274 ]
275 }
276
277 if (targetHost === null) {
278 actorFollowingPartInclude.where['serverId'] = null
279 } else {
280 actorFollowingPartInclude.include.push({
281 model: ServerModel,
282 required: true,
283 where: {
284 host: targetHost
285 }
286 })
287 }
288
289 const where: WhereAttributeHash<Attributes<ActorFollowModel>> = { actorId }
290 if (state) where.state = state
291
292 const query: FindOptions<Attributes<ActorFollowModel>> = {
293 where,
294 include: [
295 actorFollowingPartInclude,
296 {
297 model: ActorModel,
298 required: true,
299 as: 'ActorFollower'
300 }
301 ],
302 transaction
303 }
304
305 return ActorFollowModel.findOne(query)
306 }
307
308 static listSubscriptionsOf (actorId: number, targets: { name: string, host?: string }[]): Promise<MActorFollowFollowingHost[]> {
309 const whereTab = targets
310 .map(t => {
311 if (t.host) {
312 return {
313 [Op.and]: [
314 ActorModel.wherePreferredUsername(t.name),
315 { $host$: t.host }
316 ]
317 }
318 }
319
320 return {
321 [Op.and]: [
322 ActorModel.wherePreferredUsername(t.name),
323 { $serverId$: null }
324 ]
325 }
326 })
327
328 const query = {
329 attributes: [ 'id' ],
330 where: {
331 [Op.and]: [
332 {
333 [Op.or]: whereTab
334 },
335 {
336 state: 'accepted',
337 actorId
338 }
339 ]
340 },
341 include: [
342 {
343 attributes: [ 'preferredUsername' ],
344 model: ActorModel.unscoped(),
345 required: true,
346 as: 'ActorFollowing',
347 include: [
348 {
349 attributes: [ 'host' ],
350 model: ServerModel.unscoped(),
351 required: false
352 }
353 ]
354 }
355 ]
356 }
357
358 return ActorFollowModel.findAll(query)
359 }
360
361 static listInstanceFollowingForApi (options: ListFollowingOptions) {
362 return Promise.all([
363 new InstanceListFollowingQueryBuilder(this.sequelize, options).countFollowing(),
364 new InstanceListFollowingQueryBuilder(this.sequelize, options).listFollowing()
365 ]).then(([ total, data ]) => ({ total, data }))
366 }
367
368 static listFollowersForApi (options: ListFollowersOptions) {
369 return Promise.all([
370 new InstanceListFollowersQueryBuilder(this.sequelize, options).countFollowers(),
371 new InstanceListFollowersQueryBuilder(this.sequelize, options).listFollowers()
372 ]).then(([ total, data ]) => ({ total, data }))
373 }
374
375 static listSubscriptionsForApi (options: {
376 actorId: number
377 start: number
378 count: number
379 sort: string
380 search?: string
381 }) {
382 const { actorId, start, count, sort } = options
383 const where = {
384 state: 'accepted',
385 actorId
386 }
387
388 if (options.search) {
389 Object.assign(where, {
390 [Op.or]: [
391 searchAttribute(options.search, '$ActorFollowing.preferredUsername$'),
392 searchAttribute(options.search, '$ActorFollowing.VideoChannel.name$')
393 ]
394 })
395 }
396
397 const getQuery = (forCount: boolean) => {
398 let channelInclude: Includeable[] = []
399
400 if (forCount !== true) {
401 channelInclude = [
402 {
403 attributes: {
404 exclude: unusedActorAttributesForAPI
405 },
406 model: ActorModel,
407 required: true
408 },
409 {
410 model: AccountModel.unscoped(),
411 required: true,
412 include: [
413 {
414 attributes: {
415 exclude: unusedActorAttributesForAPI
416 },
417 model: ActorModel,
418 required: true
419 }
420 ]
421 }
422 ]
423 }
424
425 return {
426 attributes: forCount === true
427 ? []
428 : SORTABLE_COLUMNS.USER_SUBSCRIPTIONS,
429 distinct: true,
430 offset: start,
431 limit: count,
432 order: getSort(sort),
433 where,
434 include: [
435 {
436 attributes: [ 'id' ],
437 model: ActorModel.unscoped(),
438 as: 'ActorFollowing',
439 required: true,
440 include: [
441 {
442 model: VideoChannelModel.unscoped(),
443 required: true,
444 include: channelInclude
445 }
446 ]
447 }
448 ]
449 }
450 }
451
452 return Promise.all([
453 ActorFollowModel.count(getQuery(true)),
454 ActorFollowModel.findAll<MActorFollowSubscriptions>(getQuery(false))
455 ]).then(([ total, rows ]) => ({
456 total,
457 data: rows.map(r => r.ActorFollowing.VideoChannel)
458 }))
459 }
460
461 static async keepUnfollowedInstance (hosts: string[]) {
462 const followerId = (await getServerActor()).id
463
464 const query = {
465 attributes: [ 'id' ],
466 where: {
467 actorId: followerId
468 },
469 include: [
470 {
471 attributes: [ 'id' ],
472 model: ActorModel.unscoped(),
473 required: true,
474 as: 'ActorFollowing',
475 where: {
476 preferredUsername: SERVER_ACTOR_NAME
477 },
478 include: [
479 {
480 attributes: [ 'host' ],
481 model: ServerModel.unscoped(),
482 required: true,
483 where: {
484 host: {
485 [Op.in]: hosts
486 }
487 }
488 }
489 ]
490 }
491 ]
492 }
493
494 const res = await ActorFollowModel.findAll(query)
495 const followedHosts = res.map(row => row.ActorFollowing.Server.host)
496
497 return difference(hosts, followedHosts)
498 }
499
500 static listAcceptedFollowerUrlsForAP (actorIds: number[], t: Transaction, start?: number, count?: number) {
501 return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
502 }
503
504 static listAcceptedFollowerSharedInboxUrls (actorIds: number[], t: Transaction) {
505 return ActorFollowModel.createListAcceptedFollowForApiQuery(
506 'followers',
507 actorIds,
508 t,
509 undefined,
510 undefined,
511 'sharedInboxUrl',
512 true
513 )
514 }
515
516 static listAcceptedFollowingUrlsForApi (actorIds: number[], t: Transaction, start?: number, count?: number) {
517 return ActorFollowModel.createListAcceptedFollowForApiQuery('following', actorIds, t, start, count)
518 }
519
520 static async getStats () {
521 const serverActor = await getServerActor()
522
523 const totalInstanceFollowing = await ActorFollowModel.count({
524 where: {
525 actorId: serverActor.id,
526 state: 'accepted'
527 }
528 })
529
530 const totalInstanceFollowers = await ActorFollowModel.count({
531 where: {
532 targetActorId: serverActor.id,
533 state: 'accepted'
534 }
535 })
536
537 return {
538 totalInstanceFollowing,
539 totalInstanceFollowers
540 }
541 }
542
543 static updateScore (inboxUrl: string, value: number, t?: Transaction) {
544 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
545 'WHERE id IN (' +
546 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
547 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
548 `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` +
549 ')'
550
551 const options = {
552 type: QueryTypes.BULKUPDATE,
553 transaction: t
554 }
555
556 return ActorFollowModel.sequelize.query(query, options)
557 }
558
559 static async updateScoreByFollowingServers (serverIds: number[], value: number, t?: Transaction) {
560 if (serverIds.length === 0) return
561
562 const me = await getServerActor()
563 const serverIdsString = createSafeIn(ActorFollowModel.sequelize, serverIds)
564
565 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
566 'WHERE id IN (' +
567 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
568 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."targetActorId" ' +
569 `WHERE "actorFollow"."actorId" = ${me.Account.actorId} ` + // I'm the follower
570 `AND "actor"."serverId" IN (${serverIdsString})` + // Criteria on followings
571 ')'
572
573 const options = {
574 type: QueryTypes.BULKUPDATE,
575 transaction: t
576 }
577
578 return ActorFollowModel.sequelize.query(query, options)
579 }
580
581 private static async createListAcceptedFollowForApiQuery (
582 type: 'followers' | 'following',
583 actorIds: number[],
584 t: Transaction,
585 start?: number,
586 count?: number,
587 columnUrl = 'url',
588 distinct = false
589 ) {
590 let firstJoin: string
591 let secondJoin: string
592
593 if (type === 'followers') {
594 firstJoin = 'targetActorId'
595 secondJoin = 'actorId'
596 } else {
597 firstJoin = 'actorId'
598 secondJoin = 'targetActorId'
599 }
600
601 const selections: string[] = []
602 if (distinct === true) selections.push(`DISTINCT("Follows"."${columnUrl}") AS "selectionUrl"`)
603 else selections.push(`"Follows"."${columnUrl}" AS "selectionUrl"`)
604
605 selections.push('COUNT(*) AS "total"')
606
607 const tasks: Promise<any>[] = []
608
609 for (const selection of selections) {
610 let query = 'SELECT ' + selection + ' FROM "actor" ' +
611 'INNER JOIN "actorFollow" ON "actorFollow"."' + firstJoin + '" = "actor"."id" ' +
612 'INNER JOIN "actor" AS "Follows" ON "actorFollow"."' + secondJoin + '" = "Follows"."id" ' +
613 `WHERE "actor"."id" = ANY ($actorIds) AND "actorFollow"."state" = 'accepted' AND "Follows"."${columnUrl}" IS NOT NULL `
614
615 if (count !== undefined) query += 'LIMIT ' + count
616 if (start !== undefined) query += ' OFFSET ' + start
617
618 const options = {
619 bind: { actorIds },
620 type: QueryTypes.SELECT,
621 transaction: t
622 }
623 tasks.push(ActorFollowModel.sequelize.query(query, options))
624 }
625
626 const [ followers, [ dataTotal ] ] = await Promise.all(tasks)
627 const urls: string[] = followers.map(f => f.selectionUrl)
628
629 return {
630 data: urls,
631 total: dataTotal ? parseInt(dataTotal.total, 10) : 0
632 }
633 }
634
635 private static listBadActorFollows () {
636 const query = {
637 where: {
638 score: {
639 [Op.lte]: 0
640 }
641 },
642 logging: false
643 }
644
645 return ActorFollowModel.findAll(query)
646 }
647
648 toFormattedJSON (this: MActorFollowFormattable): ActorFollow {
649 const follower = this.ActorFollower.toFormattedJSON()
650 const following = this.ActorFollowing.toFormattedJSON()
651
652 return {
653 id: this.id,
654 follower,
655 following,
656 score: this.score,
657 state: this.state,
658 createdAt: this.createdAt,
659 updatedAt: this.updatedAt
660 }
661 }
662}
diff --git a/server/models/actor/actor-image.ts b/server/models/actor/actor-image.ts
deleted file mode 100644
index 51085a16d..000000000
--- a/server/models/actor/actor-image.ts
+++ /dev/null
@@ -1,171 +0,0 @@
1import { remove } from 'fs-extra'
2import { join } from 'path'
3import {
4 AfterDestroy,
5 AllowNull,
6 BelongsTo,
7 Column,
8 CreatedAt,
9 Default,
10 ForeignKey,
11 Is,
12 Model,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import { MActorImage, MActorImageFormattable } from '@server/types/models'
17import { getLowercaseExtension } from '@shared/core-utils'
18import { ActivityIconObject, ActorImageType } from '@shared/models'
19import { AttributesOnly } from '@shared/typescript-utils'
20import { ActorImage } from '../../../shared/models/actors/actor-image.model'
21import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
22import { logger } from '../../helpers/logger'
23import { CONFIG } from '../../initializers/config'
24import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants'
25import { buildSQLAttributes, throwIfNotValid } from '../shared'
26import { ActorModel } from './actor'
27
28@Table({
29 tableName: 'actorImage',
30 indexes: [
31 {
32 fields: [ 'filename' ],
33 unique: true
34 },
35 {
36 fields: [ 'actorId', 'type', 'width' ],
37 unique: true
38 }
39 ]
40})
41export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageModel>>> {
42
43 @AllowNull(false)
44 @Column
45 filename: string
46
47 @AllowNull(true)
48 @Default(null)
49 @Column
50 height: number
51
52 @AllowNull(true)
53 @Default(null)
54 @Column
55 width: number
56
57 @AllowNull(true)
58 @Is('ActorImageFileUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'fileUrl', true))
59 @Column
60 fileUrl: string
61
62 @AllowNull(false)
63 @Column
64 onDisk: boolean
65
66 @AllowNull(false)
67 @Column
68 type: ActorImageType
69
70 @CreatedAt
71 createdAt: Date
72
73 @UpdatedAt
74 updatedAt: Date
75
76 @ForeignKey(() => ActorModel)
77 @Column
78 actorId: number
79
80 @BelongsTo(() => ActorModel, {
81 foreignKey: {
82 allowNull: false
83 },
84 onDelete: 'CASCADE'
85 })
86 Actor: ActorModel
87
88 @AfterDestroy
89 static removeFilesAndSendDelete (instance: ActorImageModel) {
90 logger.info('Removing actor image file %s.', instance.filename)
91
92 // Don't block the transaction
93 instance.removeImage()
94 .catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, { err }))
95 }
96
97 // ---------------------------------------------------------------------------
98
99 static getSQLAttributes (tableName: string, aliasPrefix = '') {
100 return buildSQLAttributes({
101 model: this,
102 tableName,
103 aliasPrefix
104 })
105 }
106
107 // ---------------------------------------------------------------------------
108
109 static loadByName (filename: string) {
110 const query = {
111 where: {
112 filename
113 }
114 }
115
116 return ActorImageModel.findOne(query)
117 }
118
119 static getImageUrl (image: MActorImage) {
120 if (!image) return undefined
121
122 return WEBSERVER.URL + image.getStaticPath()
123 }
124
125 toFormattedJSON (this: MActorImageFormattable): ActorImage {
126 return {
127 width: this.width,
128 path: this.getStaticPath(),
129 createdAt: this.createdAt,
130 updatedAt: this.updatedAt
131 }
132 }
133
134 toActivityPubObject (): ActivityIconObject {
135 const extension = getLowercaseExtension(this.filename)
136
137 return {
138 type: 'Image',
139 mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
140 height: this.height,
141 width: this.width,
142 url: ActorImageModel.getImageUrl(this)
143 }
144 }
145
146 getStaticPath () {
147 switch (this.type) {
148 case ActorImageType.AVATAR:
149 return join(LAZY_STATIC_PATHS.AVATARS, this.filename)
150
151 case ActorImageType.BANNER:
152 return join(LAZY_STATIC_PATHS.BANNERS, this.filename)
153
154 default:
155 throw new Error('Unknown actor image type: ' + this.type)
156 }
157 }
158
159 getPath () {
160 return join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, this.filename)
161 }
162
163 removeImage () {
164 const imagePath = join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, this.filename)
165 return remove(imagePath)
166 }
167
168 isOwned () {
169 return !this.fileUrl
170 }
171}
diff --git a/server/models/actor/actor.ts b/server/models/actor/actor.ts
deleted file mode 100644
index e2e85f3d6..000000000
--- a/server/models/actor/actor.ts
+++ /dev/null
@@ -1,686 +0,0 @@
1import { col, fn, literal, Op, QueryTypes, Transaction, where } from 'sequelize'
2import {
3 AllowNull,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 DefaultScope,
9 ForeignKey,
10 HasMany,
11 HasOne,
12 Is,
13 Model,
14 Scopes,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
18import { activityPubContextify } from '@server/lib/activitypub/context'
19import { getBiggestActorImage } from '@server/lib/actor-image'
20import { ModelCache } from '@server/models/shared/model-cache'
21import { forceNumber, getLowercaseExtension } from '@shared/core-utils'
22import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models'
23import { AttributesOnly } from '@shared/typescript-utils'
24import {
25 isActorFollowersCountValid,
26 isActorFollowingCountValid,
27 isActorPreferredUsernameValid,
28 isActorPrivateKeyValid,
29 isActorPublicKeyValid
30} from '../../helpers/custom-validators/activitypub/actor'
31import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
32import {
33 ACTIVITY_PUB,
34 ACTIVITY_PUB_ACTOR_TYPES,
35 CONSTRAINTS_FIELDS,
36 MIMETYPES,
37 SERVER_ACTOR_NAME,
38 WEBSERVER
39} from '../../initializers/constants'
40import {
41 MActor,
42 MActorAccountChannelId,
43 MActorAPAccount,
44 MActorAPChannel,
45 MActorFollowersUrl,
46 MActorFormattable,
47 MActorFull,
48 MActorHost,
49 MActorHostOnly,
50 MActorId,
51 MActorSummaryFormattable,
52 MActorUrl,
53 MActorWithInboxes
54} from '../../types/models'
55import { AccountModel } from '../account/account'
56import { getServerActor } from '../application/application'
57import { ServerModel } from '../server/server'
58import { buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared'
59import { VideoModel } from '../video/video'
60import { VideoChannelModel } from '../video/video-channel'
61import { ActorFollowModel } from './actor-follow'
62import { ActorImageModel } from './actor-image'
63
64enum ScopeNames {
65 FULL = 'FULL'
66}
67
68export const unusedActorAttributesForAPI: (keyof AttributesOnly<ActorModel>)[] = [
69 'publicKey',
70 'privateKey',
71 'inboxUrl',
72 'outboxUrl',
73 'sharedInboxUrl',
74 'followersUrl',
75 'followingUrl'
76]
77
78@DefaultScope(() => ({
79 include: [
80 {
81 model: ServerModel,
82 required: false
83 },
84 {
85 model: ActorImageModel,
86 as: 'Avatars',
87 required: false
88 }
89 ]
90}))
91@Scopes(() => ({
92 [ScopeNames.FULL]: {
93 include: [
94 {
95 model: AccountModel.unscoped(),
96 required: false
97 },
98 {
99 model: VideoChannelModel.unscoped(),
100 required: false,
101 include: [
102 {
103 model: AccountModel,
104 required: true
105 }
106 ]
107 },
108 {
109 model: ServerModel,
110 required: false
111 },
112 {
113 model: ActorImageModel,
114 as: 'Avatars',
115 required: false
116 },
117 {
118 model: ActorImageModel,
119 as: 'Banners',
120 required: false
121 }
122 ]
123 }
124}))
125@Table({
126 tableName: 'actor',
127 indexes: [
128 {
129 fields: [ 'url' ],
130 unique: true
131 },
132 {
133 fields: [ fn('lower', col('preferredUsername')), 'serverId' ],
134 name: 'actor_preferred_username_lower_server_id',
135 unique: true,
136 where: {
137 serverId: {
138 [Op.ne]: null
139 }
140 }
141 },
142 {
143 fields: [ fn('lower', col('preferredUsername')) ],
144 name: 'actor_preferred_username_lower',
145 unique: true,
146 where: {
147 serverId: null
148 }
149 },
150 {
151 fields: [ 'inboxUrl', 'sharedInboxUrl' ]
152 },
153 {
154 fields: [ 'sharedInboxUrl' ]
155 },
156 {
157 fields: [ 'serverId' ]
158 },
159 {
160 fields: [ 'followersUrl' ]
161 }
162 ]
163})
164export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
165
166 @AllowNull(false)
167 @Column(DataType.ENUM(...Object.values(ACTIVITY_PUB_ACTOR_TYPES)))
168 type: ActivityPubActorType
169
170 @AllowNull(false)
171 @Is('ActorPreferredUsername', value => throwIfNotValid(value, isActorPreferredUsernameValid, 'actor preferred username'))
172 @Column
173 preferredUsername: string
174
175 @AllowNull(false)
176 @Is('ActorUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
177 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
178 url: string
179
180 @AllowNull(true)
181 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPublicKeyValid, 'public key', true))
182 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY.max))
183 publicKey: string
184
185 @AllowNull(true)
186 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPrivateKeyValid, 'private key', true))
187 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PRIVATE_KEY.max))
188 privateKey: string
189
190 @AllowNull(false)
191 @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowersCountValid, 'followers count'))
192 @Column
193 followersCount: number
194
195 @AllowNull(false)
196 @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowingCountValid, 'following count'))
197 @Column
198 followingCount: number
199
200 @AllowNull(false)
201 @Is('ActorInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'inbox url'))
202 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
203 inboxUrl: string
204
205 @AllowNull(true)
206 @Is('ActorOutboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'outbox url', true))
207 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
208 outboxUrl: string
209
210 @AllowNull(true)
211 @Is('ActorSharedInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'shared inbox url', true))
212 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
213 sharedInboxUrl: string
214
215 @AllowNull(true)
216 @Is('ActorFollowersUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'followers url', true))
217 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
218 followersUrl: string
219
220 @AllowNull(true)
221 @Is('ActorFollowingUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'following url', true))
222 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
223 followingUrl: string
224
225 @AllowNull(true)
226 @Column
227 remoteCreatedAt: Date
228
229 @CreatedAt
230 createdAt: Date
231
232 @UpdatedAt
233 updatedAt: Date
234
235 @HasMany(() => ActorImageModel, {
236 as: 'Avatars',
237 onDelete: 'cascade',
238 hooks: true,
239 foreignKey: {
240 allowNull: false
241 },
242 scope: {
243 type: ActorImageType.AVATAR
244 }
245 })
246 Avatars: ActorImageModel[]
247
248 @HasMany(() => ActorImageModel, {
249 as: 'Banners',
250 onDelete: 'cascade',
251 hooks: true,
252 foreignKey: {
253 allowNull: false
254 },
255 scope: {
256 type: ActorImageType.BANNER
257 }
258 })
259 Banners: ActorImageModel[]
260
261 @HasMany(() => ActorFollowModel, {
262 foreignKey: {
263 name: 'actorId',
264 allowNull: false
265 },
266 as: 'ActorFollowings',
267 onDelete: 'cascade'
268 })
269 ActorFollowing: ActorFollowModel[]
270
271 @HasMany(() => ActorFollowModel, {
272 foreignKey: {
273 name: 'targetActorId',
274 allowNull: false
275 },
276 as: 'ActorFollowers',
277 onDelete: 'cascade'
278 })
279 ActorFollowers: ActorFollowModel[]
280
281 @ForeignKey(() => ServerModel)
282 @Column
283 serverId: number
284
285 @BelongsTo(() => ServerModel, {
286 foreignKey: {
287 allowNull: true
288 },
289 onDelete: 'cascade'
290 })
291 Server: ServerModel
292
293 @HasOne(() => AccountModel, {
294 foreignKey: {
295 allowNull: true
296 },
297 onDelete: 'cascade',
298 hooks: true
299 })
300 Account: AccountModel
301
302 @HasOne(() => VideoChannelModel, {
303 foreignKey: {
304 allowNull: true
305 },
306 onDelete: 'cascade',
307 hooks: true
308 })
309 VideoChannel: VideoChannelModel
310
311 // ---------------------------------------------------------------------------
312
313 static getSQLAttributes (tableName: string, aliasPrefix = '') {
314 return buildSQLAttributes({
315 model: this,
316 tableName,
317 aliasPrefix
318 })
319 }
320
321 static getSQLAPIAttributes (tableName: string, aliasPrefix = '') {
322 return buildSQLAttributes({
323 model: this,
324 tableName,
325 aliasPrefix,
326 excludeAttributes: unusedActorAttributesForAPI
327 })
328 }
329
330 // ---------------------------------------------------------------------------
331
332 static wherePreferredUsername (preferredUsername: string, colName = 'preferredUsername') {
333 return where(fn('lower', col(colName)), preferredUsername.toLowerCase())
334 }
335
336 // ---------------------------------------------------------------------------
337
338 static async load (id: number): Promise<MActor> {
339 const actorServer = await getServerActor()
340 if (id === actorServer.id) return actorServer
341
342 return ActorModel.unscoped().findByPk(id)
343 }
344
345 static loadFull (id: number): Promise<MActorFull> {
346 return ActorModel.scope(ScopeNames.FULL).findByPk(id)
347 }
348
349 static loadAccountActorFollowerUrlByVideoId (videoId: number, transaction: Transaction) {
350 const query = `SELECT "actor"."id" AS "id", "actor"."followersUrl" AS "followersUrl" ` +
351 `FROM "actor" ` +
352 `INNER JOIN "account" ON "actor"."id" = "account"."actorId" ` +
353 `INNER JOIN "videoChannel" ON "videoChannel"."accountId" = "account"."id" ` +
354 `INNER JOIN "video" ON "video"."channelId" = "videoChannel"."id" AND "video"."id" = :videoId`
355
356 const options = {
357 type: QueryTypes.SELECT as QueryTypes.SELECT,
358 replacements: { videoId },
359 plain: true as true,
360 transaction
361 }
362
363 return ActorModel.sequelize.query<MActorId & MActorFollowersUrl>(query, options)
364 }
365
366 static listByFollowersUrls (followersUrls: string[], transaction?: Transaction): Promise<MActorFull[]> {
367 const query = {
368 where: {
369 followersUrl: {
370 [Op.in]: followersUrls
371 }
372 },
373 transaction
374 }
375
376 return ActorModel.scope(ScopeNames.FULL).findAll(query)
377 }
378
379 static loadLocalByName (preferredUsername: string, transaction?: Transaction): Promise<MActorFull> {
380 const fun = () => {
381 const query = {
382 where: {
383 [Op.and]: [
384 this.wherePreferredUsername(preferredUsername, '"ActorModel"."preferredUsername"'),
385 {
386 serverId: null
387 }
388 ]
389 },
390 transaction
391 }
392
393 return ActorModel.scope(ScopeNames.FULL).findOne(query)
394 }
395
396 return ModelCache.Instance.doCache({
397 cacheType: 'local-actor-name',
398 key: preferredUsername,
399 // The server actor never change, so we can easily cache it
400 whitelist: () => preferredUsername === SERVER_ACTOR_NAME,
401 fun
402 })
403 }
404
405 static loadLocalUrlByName (preferredUsername: string, transaction?: Transaction): Promise<MActorUrl> {
406 const fun = () => {
407 const query = {
408 attributes: [ 'url' ],
409 where: {
410 [Op.and]: [
411 this.wherePreferredUsername(preferredUsername),
412 {
413 serverId: null
414 }
415 ]
416 },
417 transaction
418 }
419
420 return ActorModel.unscoped().findOne(query)
421 }
422
423 return ModelCache.Instance.doCache({
424 cacheType: 'local-actor-url',
425 key: preferredUsername,
426 // The server actor never change, so we can easily cache it
427 whitelist: () => preferredUsername === SERVER_ACTOR_NAME,
428 fun
429 })
430 }
431
432 static loadByNameAndHost (preferredUsername: string, host: string): Promise<MActorFull> {
433 const query = {
434 where: this.wherePreferredUsername(preferredUsername, '"ActorModel"."preferredUsername"'),
435 include: [
436 {
437 model: ServerModel,
438 required: true,
439 where: {
440 host
441 }
442 }
443 ]
444 }
445
446 return ActorModel.scope(ScopeNames.FULL).findOne(query)
447 }
448
449 static loadByUrl (url: string, transaction?: Transaction): Promise<MActorAccountChannelId> {
450 const query = {
451 where: {
452 url
453 },
454 transaction,
455 include: [
456 {
457 attributes: [ 'id' ],
458 model: AccountModel.unscoped(),
459 required: false
460 },
461 {
462 attributes: [ 'id' ],
463 model: VideoChannelModel.unscoped(),
464 required: false
465 }
466 ]
467 }
468
469 return ActorModel.unscoped().findOne(query)
470 }
471
472 static loadByUrlAndPopulateAccountAndChannel (url: string, transaction?: Transaction): Promise<MActorFull> {
473 const query = {
474 where: {
475 url
476 },
477 transaction
478 }
479
480 return ActorModel.scope(ScopeNames.FULL).findOne(query)
481 }
482
483 static rebuildFollowsCount (ofId: number, type: 'followers' | 'following', transaction?: Transaction) {
484 const sanitizedOfId = forceNumber(ofId)
485 const where = { id: sanitizedOfId }
486
487 let columnToUpdate: string
488 let columnOfCount: string
489
490 if (type === 'followers') {
491 columnToUpdate = 'followersCount'
492 columnOfCount = 'targetActorId'
493 } else {
494 columnToUpdate = 'followingCount'
495 columnOfCount = 'actorId'
496 }
497
498 return ActorModel.update({
499 [columnToUpdate]: literal(`(SELECT COUNT(*) FROM "actorFollow" WHERE "${columnOfCount}" = ${sanitizedOfId} AND "state" = 'accepted')`)
500 }, { where, transaction })
501 }
502
503 static loadAccountActorByVideoId (videoId: number, transaction: Transaction): Promise<MActor> {
504 const query = {
505 include: [
506 {
507 attributes: [ 'id' ],
508 model: AccountModel.unscoped(),
509 required: true,
510 include: [
511 {
512 attributes: [ 'id', 'accountId' ],
513 model: VideoChannelModel.unscoped(),
514 required: true,
515 include: [
516 {
517 attributes: [ 'id', 'channelId' ],
518 model: VideoModel.unscoped(),
519 where: {
520 id: videoId
521 }
522 }
523 ]
524 }
525 ]
526 }
527 ],
528 transaction
529 }
530
531 return ActorModel.unscoped().findOne(query)
532 }
533
534 getSharedInbox (this: MActorWithInboxes) {
535 return this.sharedInboxUrl || this.inboxUrl
536 }
537
538 toFormattedSummaryJSON (this: MActorSummaryFormattable) {
539 return {
540 url: this.url,
541 name: this.preferredUsername,
542 host: this.getHost(),
543 avatars: (this.Avatars || []).map(a => a.toFormattedJSON())
544 }
545 }
546
547 toFormattedJSON (this: MActorFormattable) {
548 return {
549 ...this.toFormattedSummaryJSON(),
550
551 id: this.id,
552 hostRedundancyAllowed: this.getRedundancyAllowed(),
553 followingCount: this.followingCount,
554 followersCount: this.followersCount,
555 createdAt: this.getCreatedAt(),
556
557 banners: (this.Banners || []).map(b => b.toFormattedJSON())
558 }
559 }
560
561 toActivityPubObject (this: MActorAPChannel | MActorAPAccount, name: string) {
562 let icon: ActivityIconObject[]
563 let image: ActivityIconObject
564
565 if (this.hasImage(ActorImageType.AVATAR)) {
566 icon = this.Avatars.map(a => a.toActivityPubObject())
567 }
568
569 if (this.hasImage(ActorImageType.BANNER)) {
570 const banner = getBiggestActorImage((this as MActorAPChannel).Banners)
571 const extension = getLowercaseExtension(banner.filename)
572
573 image = {
574 type: 'Image',
575 mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
576 height: banner.height,
577 width: banner.width,
578 url: ActorImageModel.getImageUrl(banner)
579 }
580 }
581
582 const json = {
583 type: this.type,
584 id: this.url,
585 following: this.getFollowingUrl(),
586 followers: this.getFollowersUrl(),
587 playlists: this.getPlaylistsUrl(),
588 inbox: this.inboxUrl,
589 outbox: this.outboxUrl,
590 preferredUsername: this.preferredUsername,
591 url: this.url,
592 name,
593 endpoints: {
594 sharedInbox: this.sharedInboxUrl
595 },
596 publicKey: {
597 id: this.getPublicKeyUrl(),
598 owner: this.url,
599 publicKeyPem: this.publicKey
600 },
601 published: this.getCreatedAt().toISOString(),
602
603 icon,
604
605 image
606 }
607
608 return activityPubContextify(json, 'Actor')
609 }
610
611 getFollowerSharedInboxUrls (t: Transaction) {
612 const query = {
613 attributes: [ 'sharedInboxUrl' ],
614 include: [
615 {
616 attribute: [],
617 model: ActorFollowModel.unscoped(),
618 required: true,
619 as: 'ActorFollowing',
620 where: {
621 state: 'accepted',
622 targetActorId: this.id
623 }
624 }
625 ],
626 transaction: t
627 }
628
629 return ActorModel.findAll(query)
630 .then(accounts => accounts.map(a => a.sharedInboxUrl))
631 }
632
633 getFollowingUrl () {
634 return this.url + '/following'
635 }
636
637 getFollowersUrl () {
638 return this.url + '/followers'
639 }
640
641 getPlaylistsUrl () {
642 return this.url + '/playlists'
643 }
644
645 getPublicKeyUrl () {
646 return this.url + '#main-key'
647 }
648
649 isOwned () {
650 return this.serverId === null
651 }
652
653 getWebfingerUrl (this: MActorHost) {
654 return 'acct:' + this.preferredUsername + '@' + this.getHost()
655 }
656
657 getIdentifier (this: MActorHost) {
658 return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
659 }
660
661 getHost (this: MActorHostOnly) {
662 return this.Server ? this.Server.host : WEBSERVER.HOST
663 }
664
665 getRedundancyAllowed () {
666 return this.Server ? this.Server.redundancyAllowed : false
667 }
668
669 hasImage (type: ActorImageType) {
670 const images = type === ActorImageType.AVATAR
671 ? this.Avatars
672 : this.Banners
673
674 return Array.isArray(images) && images.length !== 0
675 }
676
677 isOutdated () {
678 if (this.isOwned()) return false
679
680 return isOutdated(this, ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL)
681 }
682
683 getCreatedAt (this: MActorAPChannel | MActorAPAccount | MActorFormattable) {
684 return this.remoteCreatedAt || this.createdAt
685 }
686}
diff --git a/server/models/actor/sql/instance-list-followers-query-builder.ts b/server/models/actor/sql/instance-list-followers-query-builder.ts
deleted file mode 100644
index 34ce29b5d..000000000
--- a/server/models/actor/sql/instance-list-followers-query-builder.ts
+++ /dev/null
@@ -1,69 +0,0 @@
1import { Sequelize } from 'sequelize'
2import { ModelBuilder } from '@server/models/shared'
3import { MActorFollowActorsDefault } from '@server/types/models'
4import { ActivityPubActorType, FollowState } from '@shared/models'
5import { parseRowCountResult } from '../../shared'
6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
7
8export interface ListFollowersOptions {
9 actorIds: number[]
10 start: number
11 count: number
12 sort: string
13 state?: FollowState
14 actorType?: ActivityPubActorType
15 search?: string
16}
17
18export class InstanceListFollowersQueryBuilder extends InstanceListFollowsQueryBuilder <ListFollowersOptions> {
19
20 constructor (
21 protected readonly sequelize: Sequelize,
22 protected readonly options: ListFollowersOptions
23 ) {
24 super(sequelize, options)
25 }
26
27 async listFollowers () {
28 this.buildListQuery()
29
30 const results = await this.runQuery({ nest: true })
31 const modelBuilder = new ModelBuilder<MActorFollowActorsDefault>(this.sequelize)
32
33 return modelBuilder.createModels(results, 'ActorFollow')
34 }
35
36 async countFollowers () {
37 this.buildCountQuery()
38
39 const result = await this.runQuery()
40
41 return parseRowCountResult(result)
42 }
43
44 protected getWhere () {
45 let where = 'WHERE "ActorFollowing"."id" IN (:actorIds) '
46 this.replacements.actorIds = this.options.actorIds
47
48 if (this.options.state) {
49 where += 'AND "ActorFollowModel"."state" = :state '
50 this.replacements.state = this.options.state
51 }
52
53 if (this.options.search) {
54 const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
55
56 where += `AND (` +
57 `"ActorFollower->Server"."host" ILIKE ${escapedLikeSearch} ` +
58 `OR "ActorFollower"."preferredUsername" ILIKE ${escapedLikeSearch} ` +
59 `)`
60 }
61
62 if (this.options.actorType) {
63 where += `AND "ActorFollower"."type" = :actorType `
64 this.replacements.actorType = this.options.actorType
65 }
66
67 return where
68 }
69}
diff --git a/server/models/actor/sql/instance-list-following-query-builder.ts b/server/models/actor/sql/instance-list-following-query-builder.ts
deleted file mode 100644
index 77b4e3dce..000000000
--- a/server/models/actor/sql/instance-list-following-query-builder.ts
+++ /dev/null
@@ -1,69 +0,0 @@
1import { Sequelize } from 'sequelize'
2import { ModelBuilder } from '@server/models/shared'
3import { MActorFollowActorsDefault } from '@server/types/models'
4import { ActivityPubActorType, FollowState } from '@shared/models'
5import { parseRowCountResult } from '../../shared'
6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
7
8export interface ListFollowingOptions {
9 followerId: number
10 start: number
11 count: number
12 sort: string
13 state?: FollowState
14 actorType?: ActivityPubActorType
15 search?: string
16}
17
18export class InstanceListFollowingQueryBuilder extends InstanceListFollowsQueryBuilder <ListFollowingOptions> {
19
20 constructor (
21 protected readonly sequelize: Sequelize,
22 protected readonly options: ListFollowingOptions
23 ) {
24 super(sequelize, options)
25 }
26
27 async listFollowing () {
28 this.buildListQuery()
29
30 const results = await this.runQuery({ nest: true })
31 const modelBuilder = new ModelBuilder<MActorFollowActorsDefault>(this.sequelize)
32
33 return modelBuilder.createModels(results, 'ActorFollow')
34 }
35
36 async countFollowing () {
37 this.buildCountQuery()
38
39 const result = await this.runQuery()
40
41 return parseRowCountResult(result)
42 }
43
44 protected getWhere () {
45 let where = 'WHERE "ActorFollowModel"."actorId" = :followerId '
46 this.replacements.followerId = this.options.followerId
47
48 if (this.options.state) {
49 where += 'AND "ActorFollowModel"."state" = :state '
50 this.replacements.state = this.options.state
51 }
52
53 if (this.options.search) {
54 const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
55
56 where += `AND (` +
57 `"ActorFollowing->Server"."host" ILIKE ${escapedLikeSearch} ` +
58 `OR "ActorFollowing"."preferredUsername" ILIKE ${escapedLikeSearch} ` +
59 `)`
60 }
61
62 if (this.options.actorType) {
63 where += `AND "ActorFollowing"."type" = :actorType `
64 this.replacements.actorType = this.options.actorType
65 }
66
67 return where
68 }
69}
diff --git a/server/models/actor/sql/shared/actor-follow-table-attributes.ts b/server/models/actor/sql/shared/actor-follow-table-attributes.ts
deleted file mode 100644
index 4431aa6d1..000000000
--- a/server/models/actor/sql/shared/actor-follow-table-attributes.ts
+++ /dev/null
@@ -1,28 +0,0 @@
1import { Memoize } from '@server/helpers/memoize'
2import { ServerModel } from '@server/models/server/server'
3import { ActorModel } from '../../actor'
4import { ActorFollowModel } from '../../actor-follow'
5import { ActorImageModel } from '../../actor-image'
6
7export class ActorFollowTableAttributes {
8
9 @Memoize()
10 getFollowAttributes () {
11 return ActorFollowModel.getSQLAttributes('ActorFollowModel').join(', ')
12 }
13
14 @Memoize()
15 getActorAttributes (actorTableName: string) {
16 return ActorModel.getSQLAttributes(actorTableName, `${actorTableName}.`).join(', ')
17 }
18
19 @Memoize()
20 getServerAttributes (actorTableName: string) {
21 return ServerModel.getSQLAttributes(`${actorTableName}->Server`, `${actorTableName}.Server.`).join(', ')
22 }
23
24 @Memoize()
25 getAvatarAttributes (actorTableName: string) {
26 return ActorImageModel.getSQLAttributes(`${actorTableName}->Avatars`, `${actorTableName}.Avatars.`).join(', ')
27 }
28}
diff --git a/server/models/actor/sql/shared/instance-list-follows-query-builder.ts b/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
deleted file mode 100644
index d9593e48b..000000000
--- a/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
+++ /dev/null
@@ -1,97 +0,0 @@
1import { Sequelize } from 'sequelize'
2import { AbstractRunQuery } from '@server/models/shared'
3import { ActorImageType } from '@shared/models'
4import { getInstanceFollowsSort } from '../../../shared'
5import { ActorFollowTableAttributes } from './actor-follow-table-attributes'
6
7type BaseOptions = {
8 sort: string
9 count: number
10 start: number
11}
12
13export abstract class InstanceListFollowsQueryBuilder <T extends BaseOptions> extends AbstractRunQuery {
14 protected readonly tableAttributes = new ActorFollowTableAttributes()
15
16 protected innerQuery: string
17
18 constructor (
19 protected readonly sequelize: Sequelize,
20 protected readonly options: T
21 ) {
22 super(sequelize)
23 }
24
25 protected abstract getWhere (): string
26
27 protected getJoins () {
28 return 'INNER JOIN "actor" "ActorFollower" ON "ActorFollower"."id" = "ActorFollowModel"."actorId" ' +
29 'INNER JOIN "actor" "ActorFollowing" ON "ActorFollowing"."id" = "ActorFollowModel"."targetActorId" '
30 }
31
32 protected getServerJoin (actorName: string) {
33 return `LEFT JOIN "server" "${actorName}->Server" ON "${actorName}"."serverId" = "${actorName}->Server"."id" `
34 }
35
36 protected getAvatarsJoin (actorName: string) {
37 return `LEFT JOIN "actorImage" "${actorName}->Avatars" ON "${actorName}.id" = "${actorName}->Avatars"."actorId" ` +
38 `AND "${actorName}->Avatars"."type" = ${ActorImageType.AVATAR}`
39 }
40
41 private buildInnerQuery () {
42 this.innerQuery = `${this.getInnerSelect()} ` +
43 `FROM "actorFollow" AS "ActorFollowModel" ` +
44 `${this.getJoins()} ` +
45 `${this.getServerJoin('ActorFollowing')} ` +
46 `${this.getServerJoin('ActorFollower')} ` +
47 `${this.getWhere()} ` +
48 `${this.getOrder()} ` +
49 `LIMIT :limit OFFSET :offset `
50
51 this.replacements.limit = this.options.count
52 this.replacements.offset = this.options.start
53 }
54
55 protected buildListQuery () {
56 this.buildInnerQuery()
57
58 this.query = `${this.getSelect()} ` +
59 `FROM (${this.innerQuery}) AS "ActorFollowModel" ` +
60 `${this.getAvatarsJoin('ActorFollower')} ` +
61 `${this.getAvatarsJoin('ActorFollowing')} ` +
62 `${this.getOrder()}`
63 }
64
65 protected buildCountQuery () {
66 this.query = `SELECT COUNT(*) AS "total" ` +
67 `FROM "actorFollow" AS "ActorFollowModel" ` +
68 `${this.getJoins()} ` +
69 `${this.getServerJoin('ActorFollowing')} ` +
70 `${this.getServerJoin('ActorFollower')} ` +
71 `${this.getWhere()}`
72 }
73
74 private getInnerSelect () {
75 return this.buildSelect([
76 this.tableAttributes.getFollowAttributes(),
77 this.tableAttributes.getActorAttributes('ActorFollower'),
78 this.tableAttributes.getActorAttributes('ActorFollowing'),
79 this.tableAttributes.getServerAttributes('ActorFollower'),
80 this.tableAttributes.getServerAttributes('ActorFollowing')
81 ])
82 }
83
84 private getSelect () {
85 return this.buildSelect([
86 '"ActorFollowModel".*',
87 this.tableAttributes.getAvatarAttributes('ActorFollower'),
88 this.tableAttributes.getAvatarAttributes('ActorFollowing')
89 ])
90 }
91
92 private getOrder () {
93 const orders = getInstanceFollowsSort(this.options.sort)
94
95 return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
96 }
97}
diff --git a/server/models/application/application.ts b/server/models/application/application.ts
deleted file mode 100644
index c51ceb245..000000000
--- a/server/models/application/application.ts
+++ /dev/null
@@ -1,79 +0,0 @@
1import memoizee from 'memoizee'
2import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Model, Table } from 'sequelize-typescript'
3import { getNodeABIVersion } from '@server/helpers/version'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { AccountModel } from '../account/account'
6
7export const getServerActor = memoizee(async function () {
8 const application = await ApplicationModel.load()
9 if (!application) throw Error('Could not load Application from database.')
10
11 const actor = application.Account.Actor
12 actor.Account = application.Account
13
14 return actor
15}, { promise: true })
16
17@DefaultScope(() => ({
18 include: [
19 {
20 model: AccountModel,
21 required: true
22 }
23 ]
24}))
25@Table({
26 tableName: 'application',
27 timestamps: false
28})
29export class ApplicationModel extends Model<Partial<AttributesOnly<ApplicationModel>>> {
30
31 @AllowNull(false)
32 @Default(0)
33 @IsInt
34 @Column
35 migrationVersion: number
36
37 @AllowNull(true)
38 @Column
39 latestPeerTubeVersion: string
40
41 @AllowNull(false)
42 @Column
43 nodeVersion: string
44
45 @AllowNull(false)
46 @Column
47 nodeABIVersion: number
48
49 @HasOne(() => AccountModel, {
50 foreignKey: {
51 allowNull: true
52 },
53 onDelete: 'cascade'
54 })
55 Account: AccountModel
56
57 static countTotal () {
58 return ApplicationModel.count()
59 }
60
61 static load () {
62 return ApplicationModel.findOne()
63 }
64
65 static async nodeABIChanged () {
66 const application = await this.load()
67
68 return application.nodeABIVersion !== getNodeABIVersion()
69 }
70
71 static async updateNodeVersions () {
72 const application = await this.load()
73
74 application.nodeABIVersion = getNodeABIVersion()
75 application.nodeVersion = process.version
76
77 await application.save()
78 }
79}
diff --git a/server/models/migrations.ts b/server/models/migrations.ts
deleted file mode 100644
index 6c11332a1..000000000
--- a/server/models/migrations.ts
+++ /dev/null
@@ -1,27 +0,0 @@
1import { ModelAttributeColumnOptions } from 'sequelize'
2
3declare namespace Migration {
4 interface Boolean extends ModelAttributeColumnOptions {
5 defaultValue: boolean | null
6 }
7
8 interface String extends ModelAttributeColumnOptions {
9 defaultValue: string | null
10 }
11
12 interface Integer extends ModelAttributeColumnOptions {
13 defaultValue: number | null
14 }
15
16 interface BigInteger extends ModelAttributeColumnOptions {
17 defaultValue: number | null
18 }
19
20 interface UUID extends ModelAttributeColumnOptions {
21 defaultValue: null
22 }
23}
24
25export {
26 Migration
27}
diff --git a/server/models/oauth/oauth-client.ts b/server/models/oauth/oauth-client.ts
deleted file mode 100644
index 457e84613..000000000
--- a/server/models/oauth/oauth-client.ts
+++ /dev/null
@@ -1,63 +0,0 @@
1import { AllowNull, Column, CreatedAt, DataType, HasMany, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/typescript-utils'
3import { OAuthTokenModel } from './oauth-token'
4
5@Table({
6 tableName: 'oAuthClient',
7 indexes: [
8 {
9 fields: [ 'clientId' ],
10 unique: true
11 },
12 {
13 fields: [ 'clientId', 'clientSecret' ],
14 unique: true
15 }
16 ]
17})
18export class OAuthClientModel extends Model<Partial<AttributesOnly<OAuthClientModel>>> {
19
20 @AllowNull(false)
21 @Column
22 clientId: string
23
24 @AllowNull(false)
25 @Column
26 clientSecret: string
27
28 @Column(DataType.ARRAY(DataType.STRING))
29 grants: string[]
30
31 @Column(DataType.ARRAY(DataType.STRING))
32 redirectUris: string[]
33
34 @CreatedAt
35 createdAt: Date
36
37 @UpdatedAt
38 updatedAt: Date
39
40 @HasMany(() => OAuthTokenModel, {
41 onDelete: 'cascade'
42 })
43 OAuthTokens: OAuthTokenModel[]
44
45 static countTotal () {
46 return OAuthClientModel.count()
47 }
48
49 static loadFirstClient () {
50 return OAuthClientModel.findOne()
51 }
52
53 static getByIdAndSecret (clientId: string, clientSecret: string) {
54 const query = {
55 where: {
56 clientId,
57 clientSecret
58 }
59 }
60
61 return OAuthClientModel.findOne(query)
62 }
63}
diff --git a/server/models/oauth/oauth-token.ts b/server/models/oauth/oauth-token.ts
deleted file mode 100644
index f72423190..000000000
--- a/server/models/oauth/oauth-token.ts
+++ /dev/null
@@ -1,220 +0,0 @@
1import { Transaction } from 'sequelize'
2import {
3 AfterDestroy,
4 AfterUpdate,
5 AllowNull,
6 BelongsTo,
7 Column,
8 CreatedAt,
9 ForeignKey,
10 Model,
11 Scopes,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { TokensCache } from '@server/lib/auth/tokens-cache'
16import { MUserAccountId } from '@server/types/models'
17import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
18import { AttributesOnly } from '@shared/typescript-utils'
19import { logger } from '../../helpers/logger'
20import { AccountModel } from '../account/account'
21import { ActorModel } from '../actor/actor'
22import { UserModel } from '../user/user'
23import { OAuthClientModel } from './oauth-client'
24
25export type OAuthTokenInfo = {
26 refreshToken: string
27 refreshTokenExpiresAt: Date
28 client: {
29 id: number
30 }
31 user: MUserAccountId
32 token: MOAuthTokenUser
33}
34
35enum ScopeNames {
36 WITH_USER = 'WITH_USER'
37}
38
39@Scopes(() => ({
40 [ScopeNames.WITH_USER]: {
41 include: [
42 {
43 model: UserModel.unscoped(),
44 required: true,
45 include: [
46 {
47 attributes: [ 'id' ],
48 model: AccountModel.unscoped(),
49 required: true,
50 include: [
51 {
52 attributes: [ 'id', 'url' ],
53 model: ActorModel.unscoped(),
54 required: true
55 }
56 ]
57 }
58 ]
59 }
60 ]
61 }
62}))
63@Table({
64 tableName: 'oAuthToken',
65 indexes: [
66 {
67 fields: [ 'refreshToken' ],
68 unique: true
69 },
70 {
71 fields: [ 'accessToken' ],
72 unique: true
73 },
74 {
75 fields: [ 'userId' ]
76 },
77 {
78 fields: [ 'oAuthClientId' ]
79 }
80 ]
81})
82export class OAuthTokenModel extends Model<Partial<AttributesOnly<OAuthTokenModel>>> {
83
84 @AllowNull(false)
85 @Column
86 accessToken: string
87
88 @AllowNull(false)
89 @Column
90 accessTokenExpiresAt: Date
91
92 @AllowNull(false)
93 @Column
94 refreshToken: string
95
96 @AllowNull(false)
97 @Column
98 refreshTokenExpiresAt: Date
99
100 @Column
101 authName: string
102
103 @CreatedAt
104 createdAt: Date
105
106 @UpdatedAt
107 updatedAt: Date
108
109 @ForeignKey(() => UserModel)
110 @Column
111 userId: number
112
113 @BelongsTo(() => UserModel, {
114 foreignKey: {
115 allowNull: false
116 },
117 onDelete: 'cascade'
118 })
119 User: UserModel
120
121 @ForeignKey(() => OAuthClientModel)
122 @Column
123 oAuthClientId: number
124
125 @BelongsTo(() => OAuthClientModel, {
126 foreignKey: {
127 allowNull: false
128 },
129 onDelete: 'cascade'
130 })
131 OAuthClients: OAuthClientModel[]
132
133 @AfterUpdate
134 @AfterDestroy
135 static removeTokenCache (token: OAuthTokenModel) {
136 return TokensCache.Instance.clearCacheByToken(token.accessToken)
137 }
138
139 static loadByRefreshToken (refreshToken: string) {
140 const query = {
141 where: { refreshToken }
142 }
143
144 return OAuthTokenModel.findOne(query)
145 }
146
147 static getByRefreshTokenAndPopulateClient (refreshToken: string) {
148 const query = {
149 where: {
150 refreshToken
151 },
152 include: [ OAuthClientModel ]
153 }
154
155 return OAuthTokenModel.scope(ScopeNames.WITH_USER)
156 .findOne(query)
157 .then(token => {
158 if (!token) return null
159
160 return {
161 refreshToken: token.refreshToken,
162 refreshTokenExpiresAt: token.refreshTokenExpiresAt,
163 client: {
164 id: token.oAuthClientId
165 },
166 user: token.User,
167 token
168 } as OAuthTokenInfo
169 })
170 .catch(err => {
171 logger.error('getRefreshToken error.', { err })
172 throw err
173 })
174 }
175
176 static getByTokenAndPopulateUser (bearerToken: string): Promise<MOAuthTokenUser> {
177 const query = {
178 where: {
179 accessToken: bearerToken
180 }
181 }
182
183 return OAuthTokenModel.scope(ScopeNames.WITH_USER)
184 .findOne(query)
185 .then(token => {
186 if (!token) return null
187
188 return Object.assign(token, { user: token.User })
189 })
190 }
191
192 static getByRefreshTokenAndPopulateUser (refreshToken: string): Promise<MOAuthTokenUser> {
193 const query = {
194 where: {
195 refreshToken
196 }
197 }
198
199 return OAuthTokenModel.scope(ScopeNames.WITH_USER)
200 .findOne(query)
201 .then(token => {
202 if (!token) return undefined
203
204 return Object.assign(token, { user: token.User })
205 })
206 }
207
208 static deleteUserToken (userId: number, t?: Transaction) {
209 TokensCache.Instance.deleteUserToken(userId)
210
211 const query = {
212 where: {
213 userId
214 },
215 transaction: t
216 }
217
218 return OAuthTokenModel.destroy(query)
219 }
220}
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
deleted file mode 100644
index cebf47dfd..000000000
--- a/server/models/redundancy/video-redundancy.ts
+++ /dev/null
@@ -1,793 +0,0 @@
1import { sample } from 'lodash'
2import { literal, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize'
3import {
4 AllowNull,
5 BeforeDestroy,
6 BelongsTo,
7 Column,
8 CreatedAt,
9 DataType,
10 ForeignKey,
11 Is,
12 Model,
13 Scopes,
14 Table,
15 UpdatedAt
16} from 'sequelize-typescript'
17import { getServerActor } from '@server/models/application/application'
18import { MActor, MVideoForRedundancyAPI, MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/types/models'
19import {
20 CacheFileObject,
21 FileRedundancyInformation,
22 StreamingPlaylistRedundancyInformation,
23 VideoPrivacy,
24 VideoRedundanciesTarget,
25 VideoRedundancy,
26 VideoRedundancyStrategy,
27 VideoRedundancyStrategyWithManual
28} from '@shared/models'
29import { AttributesOnly } from '@shared/typescript-utils'
30import { isTestInstance } from '../../helpers/core-utils'
31import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc'
32import { logger } from '../../helpers/logger'
33import { CONFIG } from '../../initializers/config'
34import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
35import { ActorModel } from '../actor/actor'
36import { ServerModel } from '../server/server'
37import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../shared'
38import { ScheduleVideoUpdateModel } from '../video/schedule-video-update'
39import { VideoModel } from '../video/video'
40import { VideoChannelModel } from '../video/video-channel'
41import { VideoFileModel } from '../video/video-file'
42import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist'
43
44export enum ScopeNames {
45 WITH_VIDEO = 'WITH_VIDEO'
46}
47
48@Scopes(() => ({
49 [ScopeNames.WITH_VIDEO]: {
50 include: [
51 {
52 model: VideoFileModel,
53 required: false,
54 include: [
55 {
56 model: VideoModel,
57 required: true
58 }
59 ]
60 },
61 {
62 model: VideoStreamingPlaylistModel,
63 required: false,
64 include: [
65 {
66 model: VideoModel,
67 required: true
68 }
69 ]
70 }
71 ]
72 }
73}))
74
75@Table({
76 tableName: 'videoRedundancy',
77 indexes: [
78 {
79 fields: [ 'videoFileId' ]
80 },
81 {
82 fields: [ 'actorId' ]
83 },
84 {
85 fields: [ 'expiresOn' ]
86 },
87 {
88 fields: [ 'url' ],
89 unique: true
90 }
91 ]
92})
93export class VideoRedundancyModel extends Model<Partial<AttributesOnly<VideoRedundancyModel>>> {
94
95 @CreatedAt
96 createdAt: Date
97
98 @UpdatedAt
99 updatedAt: Date
100
101 @AllowNull(true)
102 @Column
103 expiresOn: Date
104
105 @AllowNull(false)
106 @Is('VideoRedundancyFileUrl', value => throwIfNotValid(value, isUrlValid, 'fileUrl'))
107 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS_REDUNDANCY.URL.max))
108 fileUrl: string
109
110 @AllowNull(false)
111 @Is('VideoRedundancyUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
112 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS_REDUNDANCY.URL.max))
113 url: string
114
115 @AllowNull(true)
116 @Column
117 strategy: string // Only used by us
118
119 @ForeignKey(() => VideoFileModel)
120 @Column
121 videoFileId: number
122
123 @BelongsTo(() => VideoFileModel, {
124 foreignKey: {
125 allowNull: true
126 },
127 onDelete: 'cascade'
128 })
129 VideoFile: VideoFileModel
130
131 @ForeignKey(() => VideoStreamingPlaylistModel)
132 @Column
133 videoStreamingPlaylistId: number
134
135 @BelongsTo(() => VideoStreamingPlaylistModel, {
136 foreignKey: {
137 allowNull: true
138 },
139 onDelete: 'cascade'
140 })
141 VideoStreamingPlaylist: VideoStreamingPlaylistModel
142
143 @ForeignKey(() => ActorModel)
144 @Column
145 actorId: number
146
147 @BelongsTo(() => ActorModel, {
148 foreignKey: {
149 allowNull: false
150 },
151 onDelete: 'cascade'
152 })
153 Actor: ActorModel
154
155 @BeforeDestroy
156 static async removeFile (instance: VideoRedundancyModel) {
157 if (!instance.isOwned()) return
158
159 if (instance.videoFileId) {
160 const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId)
161
162 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
163 logger.info('Removing duplicated video file %s.', logIdentifier)
164
165 videoFile.Video.removeWebVideoFile(videoFile, true)
166 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
167 }
168
169 if (instance.videoStreamingPlaylistId) {
170 const videoStreamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(instance.videoStreamingPlaylistId)
171
172 const videoUUID = videoStreamingPlaylist.Video.uuid
173 logger.info('Removing duplicated video streaming playlist %s.', videoUUID)
174
175 videoStreamingPlaylist.Video.removeStreamingPlaylistFiles(videoStreamingPlaylist, true)
176 .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err }))
177 }
178
179 return undefined
180 }
181
182 static async loadLocalByFileId (videoFileId: number): Promise<MVideoRedundancyVideo> {
183 const actor = await getServerActor()
184
185 const query = {
186 where: {
187 actorId: actor.id,
188 videoFileId
189 }
190 }
191
192 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
193 }
194
195 static async listLocalByVideoId (videoId: number): Promise<MVideoRedundancyVideo[]> {
196 const actor = await getServerActor()
197
198 const queryStreamingPlaylist = {
199 where: {
200 actorId: actor.id
201 },
202 include: [
203 {
204 model: VideoStreamingPlaylistModel.unscoped(),
205 required: true,
206 include: [
207 {
208 model: VideoModel.unscoped(),
209 required: true,
210 where: {
211 id: videoId
212 }
213 }
214 ]
215 }
216 ]
217 }
218
219 const queryFiles = {
220 where: {
221 actorId: actor.id
222 },
223 include: [
224 {
225 model: VideoFileModel,
226 required: true,
227 include: [
228 {
229 model: VideoModel,
230 required: true,
231 where: {
232 id: videoId
233 }
234 }
235 ]
236 }
237 ]
238 }
239
240 return Promise.all([
241 VideoRedundancyModel.findAll(queryStreamingPlaylist),
242 VideoRedundancyModel.findAll(queryFiles)
243 ]).then(([ r1, r2 ]) => r1.concat(r2))
244 }
245
246 static async loadLocalByStreamingPlaylistId (videoStreamingPlaylistId: number): Promise<MVideoRedundancyVideo> {
247 const actor = await getServerActor()
248
249 const query = {
250 where: {
251 actorId: actor.id,
252 videoStreamingPlaylistId
253 }
254 }
255
256 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
257 }
258
259 static loadByIdWithVideo (id: number, transaction?: Transaction): Promise<MVideoRedundancyVideo> {
260 const query = {
261 where: { id },
262 transaction
263 }
264
265 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
266 }
267
268 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoRedundancy> {
269 const query = {
270 where: {
271 url
272 },
273 transaction
274 }
275
276 return VideoRedundancyModel.findOne(query)
277 }
278
279 static async isLocalByVideoUUIDExists (uuid: string) {
280 const actor = await getServerActor()
281
282 const query = {
283 raw: true,
284 attributes: [ 'id' ],
285 where: {
286 actorId: actor.id
287 },
288 include: [
289 {
290 attributes: [],
291 model: VideoFileModel,
292 required: true,
293 include: [
294 {
295 attributes: [],
296 model: VideoModel,
297 required: true,
298 where: {
299 uuid
300 }
301 }
302 ]
303 }
304 ]
305 }
306
307 return VideoRedundancyModel.findOne(query)
308 .then(r => !!r)
309 }
310
311 static async getVideoSample (p: Promise<VideoModel[]>) {
312 const rows = await p
313 if (rows.length === 0) return undefined
314
315 const ids = rows.map(r => r.id)
316 const id = sample(ids)
317
318 return VideoModel.loadWithFiles(id, undefined, !isTestInstance())
319 }
320
321 static async findMostViewToDuplicate (randomizedFactor: number) {
322 const peertubeActor = await getServerActor()
323
324 // On VideoModel!
325 const query = {
326 attributes: [ 'id', 'views' ],
327 limit: randomizedFactor,
328 order: getVideoSort('-views'),
329 where: {
330 privacy: VideoPrivacy.PUBLIC,
331 isLive: false,
332 ...this.buildVideoIdsForDuplication(peertubeActor)
333 },
334 include: [
335 VideoRedundancyModel.buildServerRedundancyInclude()
336 ]
337 }
338
339 return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
340 }
341
342 static async findTrendingToDuplicate (randomizedFactor: number) {
343 const peertubeActor = await getServerActor()
344
345 // On VideoModel!
346 const query = {
347 attributes: [ 'id', 'views' ],
348 subQuery: false,
349 group: 'VideoModel.id',
350 limit: randomizedFactor,
351 order: getVideoSort('-trending'),
352 where: {
353 privacy: VideoPrivacy.PUBLIC,
354 isLive: false,
355 ...this.buildVideoIdsForDuplication(peertubeActor)
356 },
357 include: [
358 VideoRedundancyModel.buildServerRedundancyInclude(),
359
360 VideoModel.buildTrendingQuery(CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS)
361 ]
362 }
363
364 return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
365 }
366
367 static async findRecentlyAddedToDuplicate (randomizedFactor: number, minViews: number) {
368 const peertubeActor = await getServerActor()
369
370 // On VideoModel!
371 const query = {
372 attributes: [ 'id', 'publishedAt' ],
373 limit: randomizedFactor,
374 order: getVideoSort('-publishedAt'),
375 where: {
376 privacy: VideoPrivacy.PUBLIC,
377 isLive: false,
378 views: {
379 [Op.gte]: minViews
380 },
381 ...this.buildVideoIdsForDuplication(peertubeActor)
382 },
383 include: [
384 VideoRedundancyModel.buildServerRedundancyInclude(),
385
386 // Required by publishedAt sort
387 {
388 model: ScheduleVideoUpdateModel.unscoped(),
389 required: false
390 }
391 ]
392 }
393
394 return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
395 }
396
397 static async loadOldestLocalExpired (strategy: VideoRedundancyStrategy, expiresAfterMs: number): Promise<MVideoRedundancyVideo> {
398 const expiredDate = new Date()
399 expiredDate.setMilliseconds(expiredDate.getMilliseconds() - expiresAfterMs)
400
401 const actor = await getServerActor()
402
403 const query = {
404 where: {
405 actorId: actor.id,
406 strategy,
407 createdAt: {
408 [Op.lt]: expiredDate
409 }
410 }
411 }
412
413 return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findOne(query)
414 }
415
416 static async listLocalExpired (): Promise<MVideoRedundancyVideo[]> {
417 const actor = await getServerActor()
418
419 const query = {
420 where: {
421 actorId: actor.id,
422 expiresOn: {
423 [Op.lt]: new Date()
424 }
425 }
426 }
427
428 return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findAll(query)
429 }
430
431 static async listRemoteExpired () {
432 const actor = await getServerActor()
433
434 const query = {
435 where: {
436 actorId: {
437 [Op.ne]: actor.id
438 },
439 expiresOn: {
440 [Op.lt]: new Date(),
441 [Op.ne]: null
442 }
443 }
444 }
445
446 return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findAll(query)
447 }
448
449 static async listLocalOfServer (serverId: number) {
450 const actor = await getServerActor()
451 const buildVideoInclude = () => ({
452 model: VideoModel,
453 required: true,
454 include: [
455 {
456 attributes: [],
457 model: VideoChannelModel.unscoped(),
458 required: true,
459 include: [
460 {
461 attributes: [],
462 model: ActorModel.unscoped(),
463 required: true,
464 where: {
465 serverId
466 }
467 }
468 ]
469 }
470 ]
471 })
472
473 const query = {
474 where: {
475 [Op.and]: [
476 {
477 actorId: actor.id
478 },
479 {
480 [Op.or]: [
481 {
482 '$VideoStreamingPlaylist.id$': {
483 [Op.ne]: null
484 }
485 },
486 {
487 '$VideoFile.id$': {
488 [Op.ne]: null
489 }
490 }
491 ]
492 }
493 ]
494 },
495 include: [
496 {
497 model: VideoFileModel.unscoped(),
498 required: false,
499 include: [ buildVideoInclude() ]
500 },
501 {
502 model: VideoStreamingPlaylistModel.unscoped(),
503 required: false,
504 include: [ buildVideoInclude() ]
505 }
506 ]
507 }
508
509 return VideoRedundancyModel.findAll(query)
510 }
511
512 static listForApi (options: {
513 start: number
514 count: number
515 sort: string
516 target: VideoRedundanciesTarget
517 strategy?: string
518 }) {
519 const { start, count, sort, target, strategy } = options
520 const redundancyWhere: WhereOptions = {}
521 const videosWhere: WhereOptions = {}
522 let redundancySqlSuffix = ''
523
524 if (target === 'my-videos') {
525 Object.assign(videosWhere, { remote: false })
526 } else if (target === 'remote-videos') {
527 Object.assign(videosWhere, { remote: true })
528 Object.assign(redundancyWhere, { strategy: { [Op.ne]: null } })
529 redundancySqlSuffix = ' AND "videoRedundancy"."strategy" IS NOT NULL'
530 }
531
532 if (strategy) {
533 Object.assign(redundancyWhere, { strategy })
534 }
535
536 const videoFilterWhere = {
537 [Op.and]: [
538 {
539 [Op.or]: [
540 {
541 id: {
542 [Op.in]: literal(
543 '(' +
544 'SELECT "videoId" FROM "videoFile" ' +
545 'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoFileId" = "videoFile".id' +
546 redundancySqlSuffix +
547 ')'
548 )
549 }
550 },
551 {
552 id: {
553 [Op.in]: literal(
554 '(' +
555 'select "videoId" FROM "videoStreamingPlaylist" ' +
556 'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id' +
557 redundancySqlSuffix +
558 ')'
559 )
560 }
561 }
562 ]
563 },
564
565 videosWhere
566 ]
567 }
568
569 // /!\ On video model /!\
570 const findOptions = {
571 offset: start,
572 limit: count,
573 order: getSort(sort),
574 include: [
575 {
576 required: false,
577 model: VideoFileModel,
578 include: [
579 {
580 model: VideoRedundancyModel.unscoped(),
581 required: false,
582 where: redundancyWhere
583 }
584 ]
585 },
586 {
587 required: false,
588 model: VideoStreamingPlaylistModel.unscoped(),
589 include: [
590 {
591 model: VideoRedundancyModel.unscoped(),
592 required: false,
593 where: redundancyWhere
594 },
595 {
596 model: VideoFileModel,
597 required: false
598 }
599 ]
600 }
601 ],
602 where: videoFilterWhere
603 }
604
605 // /!\ On video model /!\
606 const countOptions = {
607 where: videoFilterWhere
608 }
609
610 return Promise.all([
611 VideoModel.findAll(findOptions),
612
613 VideoModel.count(countOptions)
614 ]).then(([ data, total ]) => ({ total, data }))
615 }
616
617 static async getStats (strategy: VideoRedundancyStrategyWithManual) {
618 const actor = await getServerActor()
619
620 const sql = `WITH "tmp" AS ` +
621 `(` +
622 `SELECT "videoFile"."size" AS "videoFileSize", "videoStreamingFile"."size" AS "videoStreamingFileSize", ` +
623 `"videoFile"."videoId" AS "videoFileVideoId", "videoStreamingPlaylist"."videoId" AS "videoStreamingVideoId"` +
624 `FROM "videoRedundancy" AS "videoRedundancy" ` +
625 `LEFT JOIN "videoFile" AS "videoFile" ON "videoRedundancy"."videoFileId" = "videoFile"."id" ` +
626 `LEFT JOIN "videoStreamingPlaylist" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ` +
627 `LEFT JOIN "videoFile" AS "videoStreamingFile" ` +
628 `ON "videoStreamingPlaylist"."id" = "videoStreamingFile"."videoStreamingPlaylistId" ` +
629 `WHERE "videoRedundancy"."strategy" = :strategy AND "videoRedundancy"."actorId" = :actorId` +
630 `), ` +
631 `"videoIds" AS (` +
632 `SELECT "videoFileVideoId" AS "videoId" FROM "tmp" ` +
633 `UNION SELECT "videoStreamingVideoId" AS "videoId" FROM "tmp" ` +
634 `) ` +
635 `SELECT ` +
636 `COALESCE(SUM("videoFileSize"), '0') + COALESCE(SUM("videoStreamingFileSize"), '0') AS "totalUsed", ` +
637 `(SELECT COUNT("videoIds"."videoId") FROM "videoIds") AS "totalVideos", ` +
638 `COUNT(*) AS "totalVideoFiles" ` +
639 `FROM "tmp"`
640
641 return VideoRedundancyModel.sequelize.query<any>(sql, {
642 replacements: { strategy, actorId: actor.id },
643 type: QueryTypes.SELECT
644 }).then(([ row ]) => ({
645 totalUsed: parseAggregateResult(row.totalUsed),
646 totalVideos: row.totalVideos,
647 totalVideoFiles: row.totalVideoFiles
648 }))
649 }
650
651 static toFormattedJSONStatic (video: MVideoForRedundancyAPI): VideoRedundancy {
652 const filesRedundancies: FileRedundancyInformation[] = []
653 const streamingPlaylistsRedundancies: StreamingPlaylistRedundancyInformation[] = []
654
655 for (const file of video.VideoFiles) {
656 for (const redundancy of file.RedundancyVideos) {
657 filesRedundancies.push({
658 id: redundancy.id,
659 fileUrl: redundancy.fileUrl,
660 strategy: redundancy.strategy,
661 createdAt: redundancy.createdAt,
662 updatedAt: redundancy.updatedAt,
663 expiresOn: redundancy.expiresOn,
664 size: file.size
665 })
666 }
667 }
668
669 for (const playlist of video.VideoStreamingPlaylists) {
670 const size = playlist.VideoFiles.reduce((a, b) => a + b.size, 0)
671
672 for (const redundancy of playlist.RedundancyVideos) {
673 streamingPlaylistsRedundancies.push({
674 id: redundancy.id,
675 fileUrl: redundancy.fileUrl,
676 strategy: redundancy.strategy,
677 createdAt: redundancy.createdAt,
678 updatedAt: redundancy.updatedAt,
679 expiresOn: redundancy.expiresOn,
680 size
681 })
682 }
683 }
684
685 return {
686 id: video.id,
687 name: video.name,
688 url: video.url,
689 uuid: video.uuid,
690
691 redundancies: {
692 files: filesRedundancies,
693 streamingPlaylists: streamingPlaylistsRedundancies
694 }
695 }
696 }
697
698 getVideo () {
699 if (this.VideoFile?.Video) return this.VideoFile.Video
700
701 if (this.VideoStreamingPlaylist?.Video) return this.VideoStreamingPlaylist.Video
702
703 return undefined
704 }
705
706 getVideoUUID () {
707 const video = this.getVideo()
708 if (!video) return undefined
709
710 return video.uuid
711 }
712
713 isOwned () {
714 return !!this.strategy
715 }
716
717 toActivityPubObject (this: MVideoRedundancyAP): CacheFileObject {
718 if (this.VideoStreamingPlaylist) {
719 return {
720 id: this.url,
721 type: 'CacheFile' as 'CacheFile',
722 object: this.VideoStreamingPlaylist.Video.url,
723 expires: this.expiresOn ? this.expiresOn.toISOString() : null,
724 url: {
725 type: 'Link',
726 mediaType: 'application/x-mpegURL',
727 href: this.fileUrl
728 }
729 }
730 }
731
732 return {
733 id: this.url,
734 type: 'CacheFile' as 'CacheFile',
735 object: this.VideoFile.Video.url,
736 expires: this.expiresOn ? this.expiresOn.toISOString() : null,
737 url: {
738 type: 'Link',
739 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[this.VideoFile.extname] as any,
740 href: this.fileUrl,
741 height: this.VideoFile.resolution,
742 size: this.VideoFile.size,
743 fps: this.VideoFile.fps
744 }
745 }
746 }
747
748 // Don't include video files we already duplicated
749 private static buildVideoIdsForDuplication (peertubeActor: MActor) {
750 const notIn = literal(
751 '(' +
752 `SELECT "videoFile"."videoId" AS "videoId" FROM "videoRedundancy" ` +
753 `INNER JOIN "videoFile" ON "videoFile"."id" = "videoRedundancy"."videoFileId" ` +
754 `WHERE "videoRedundancy"."actorId" = ${peertubeActor.id} ` +
755 `UNION ` +
756 `SELECT "videoStreamingPlaylist"."videoId" AS "videoId" FROM "videoRedundancy" ` +
757 `INNER JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoRedundancy"."videoStreamingPlaylistId" ` +
758 `WHERE "videoRedundancy"."actorId" = ${peertubeActor.id} ` +
759 ')'
760 )
761
762 return {
763 id: {
764 [Op.notIn]: notIn
765 }
766 }
767 }
768
769 private static buildServerRedundancyInclude () {
770 return {
771 attributes: [],
772 model: VideoChannelModel.unscoped(),
773 required: true,
774 include: [
775 {
776 attributes: [],
777 model: ActorModel.unscoped(),
778 required: true,
779 include: [
780 {
781 attributes: [],
782 model: ServerModel.unscoped(),
783 required: true,
784 where: {
785 redundancyAllowed: true
786 }
787 }
788 ]
789 }
790 ]
791 }
792 }
793}
diff --git a/server/models/runner/runner-job.ts b/server/models/runner/runner-job.ts
deleted file mode 100644
index f2ffd6a84..000000000
--- a/server/models/runner/runner-job.ts
+++ /dev/null
@@ -1,357 +0,0 @@
1import { Op, Transaction } from 'sequelize'
2import {
3 AllowNull,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 Default,
9 ForeignKey,
10 IsUUID,
11 Model,
12 Scopes,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import { isArray, isUUIDValid } from '@server/helpers/custom-validators/misc'
17import { CONSTRAINTS_FIELDS, RUNNER_JOB_STATES } from '@server/initializers/constants'
18import { MRunnerJob, MRunnerJobRunner, MRunnerJobRunnerParent } from '@server/types/models/runners'
19import { RunnerJob, RunnerJobAdmin, RunnerJobPayload, RunnerJobPrivatePayload, RunnerJobState, RunnerJobType } from '@shared/models'
20import { AttributesOnly } from '@shared/typescript-utils'
21import { getSort, searchAttribute } from '../shared'
22import { RunnerModel } from './runner'
23
24enum ScopeNames {
25 WITH_RUNNER = 'WITH_RUNNER',
26 WITH_PARENT = 'WITH_PARENT'
27}
28
29@Scopes(() => ({
30 [ScopeNames.WITH_RUNNER]: {
31 include: [
32 {
33 model: RunnerModel.unscoped(),
34 required: false
35 }
36 ]
37 },
38 [ScopeNames.WITH_PARENT]: {
39 include: [
40 {
41 model: RunnerJobModel.unscoped(),
42 required: false
43 }
44 ]
45 }
46}))
47@Table({
48 tableName: 'runnerJob',
49 indexes: [
50 {
51 fields: [ 'uuid' ],
52 unique: true
53 },
54 {
55 fields: [ 'processingJobToken' ],
56 unique: true
57 },
58 {
59 fields: [ 'runnerId' ]
60 }
61 ]
62})
63export class RunnerJobModel extends Model<Partial<AttributesOnly<RunnerJobModel>>> {
64
65 @AllowNull(false)
66 @IsUUID(4)
67 @Column(DataType.UUID)
68 uuid: string
69
70 @AllowNull(false)
71 @Column
72 type: RunnerJobType
73
74 @AllowNull(false)
75 @Column(DataType.JSONB)
76 payload: RunnerJobPayload
77
78 @AllowNull(false)
79 @Column(DataType.JSONB)
80 privatePayload: RunnerJobPrivatePayload
81
82 @AllowNull(false)
83 @Column
84 state: RunnerJobState
85
86 @AllowNull(false)
87 @Default(0)
88 @Column
89 failures: number
90
91 @AllowNull(true)
92 @Column(DataType.STRING(CONSTRAINTS_FIELDS.RUNNER_JOBS.ERROR_MESSAGE.max))
93 error: string
94
95 // Less has priority
96 @AllowNull(false)
97 @Column
98 priority: number
99
100 // Used to fetch the appropriate job when the runner wants to post the result
101 @AllowNull(true)
102 @Column
103 processingJobToken: string
104
105 @AllowNull(true)
106 @Column
107 progress: number
108
109 @AllowNull(true)
110 @Column
111 startedAt: Date
112
113 @AllowNull(true)
114 @Column
115 finishedAt: Date
116
117 @CreatedAt
118 createdAt: Date
119
120 @UpdatedAt
121 updatedAt: Date
122
123 @ForeignKey(() => RunnerJobModel)
124 @Column
125 dependsOnRunnerJobId: number
126
127 @BelongsTo(() => RunnerJobModel, {
128 foreignKey: {
129 name: 'dependsOnRunnerJobId',
130 allowNull: true
131 },
132 onDelete: 'cascade'
133 })
134 DependsOnRunnerJob: RunnerJobModel
135
136 @ForeignKey(() => RunnerModel)
137 @Column
138 runnerId: number
139
140 @BelongsTo(() => RunnerModel, {
141 foreignKey: {
142 name: 'runnerId',
143 allowNull: true
144 },
145 onDelete: 'SET NULL'
146 })
147 Runner: RunnerModel
148
149 // ---------------------------------------------------------------------------
150
151 static loadWithRunner (uuid: string) {
152 const query = {
153 where: { uuid }
154 }
155
156 return RunnerJobModel.scope(ScopeNames.WITH_RUNNER).findOne<MRunnerJobRunner>(query)
157 }
158
159 static loadByRunnerAndJobTokensWithRunner (options: {
160 uuid: string
161 runnerToken: string
162 jobToken: string
163 }) {
164 const { uuid, runnerToken, jobToken } = options
165
166 const query = {
167 where: {
168 uuid,
169 processingJobToken: jobToken
170 },
171 include: {
172 model: RunnerModel.unscoped(),
173 required: true,
174 where: {
175 runnerToken
176 }
177 }
178 }
179
180 return RunnerJobModel.findOne<MRunnerJobRunner>(query)
181 }
182
183 static listAvailableJobs () {
184 const query = {
185 limit: 10,
186 order: getSort('priority'),
187 where: {
188 state: RunnerJobState.PENDING
189 }
190 }
191
192 return RunnerJobModel.findAll<MRunnerJob>(query)
193 }
194
195 static listStalledJobs (options: {
196 staleTimeMS: number
197 types: RunnerJobType[]
198 }) {
199 const before = new Date(Date.now() - options.staleTimeMS)
200
201 return RunnerJobModel.findAll<MRunnerJob>({
202 where: {
203 type: {
204 [Op.in]: options.types
205 },
206 state: RunnerJobState.PROCESSING,
207 updatedAt: {
208 [Op.lt]: before
209 }
210 }
211 })
212 }
213
214 static listChildrenOf (job: MRunnerJob, transaction?: Transaction) {
215 const query = {
216 where: {
217 dependsOnRunnerJobId: job.id
218 },
219 transaction
220 }
221
222 return RunnerJobModel.findAll<MRunnerJob>(query)
223 }
224
225 static listForApi (options: {
226 start: number
227 count: number
228 sort: string
229 search?: string
230 stateOneOf?: RunnerJobState[]
231 }) {
232 const { start, count, sort, search, stateOneOf } = options
233
234 const query = {
235 offset: start,
236 limit: count,
237 order: getSort(sort),
238 where: []
239 }
240
241 if (search) {
242 if (isUUIDValid(search)) {
243 query.where.push({ uuid: search })
244 } else {
245 query.where.push({
246 [Op.or]: [
247 searchAttribute(search, 'type'),
248 searchAttribute(search, '$Runner.name$')
249 ]
250 })
251 }
252 }
253
254 if (isArray(stateOneOf) && stateOneOf.length !== 0) {
255 query.where.push({
256 state: {
257 [Op.in]: stateOneOf
258 }
259 })
260 }
261
262 return Promise.all([
263 RunnerJobModel.scope([ ScopeNames.WITH_RUNNER ]).count(query),
264 RunnerJobModel.scope([ ScopeNames.WITH_RUNNER, ScopeNames.WITH_PARENT ]).findAll<MRunnerJobRunnerParent>(query)
265 ]).then(([ total, data ]) => ({ total, data }))
266 }
267
268 static updateDependantJobsOf (runnerJob: MRunnerJob) {
269 const where = {
270 dependsOnRunnerJobId: runnerJob.id
271 }
272
273 return RunnerJobModel.update({ state: RunnerJobState.PENDING }, { where })
274 }
275
276 static cancelAllJobs (options: { type: RunnerJobType }) {
277 const where = {
278 type: options.type
279 }
280
281 return RunnerJobModel.update({ state: RunnerJobState.CANCELLED }, { where })
282 }
283
284 // ---------------------------------------------------------------------------
285
286 resetToPending () {
287 this.state = RunnerJobState.PENDING
288 this.processingJobToken = null
289 this.progress = null
290 this.startedAt = null
291 this.runnerId = null
292 }
293
294 setToErrorOrCancel (
295 state: RunnerJobState.PARENT_ERRORED | RunnerJobState.ERRORED | RunnerJobState.CANCELLED | RunnerJobState.PARENT_CANCELLED
296 ) {
297 this.state = state
298 this.processingJobToken = null
299 this.finishedAt = new Date()
300 }
301
302 toFormattedJSON (this: MRunnerJobRunnerParent): RunnerJob {
303 const runner = this.Runner
304 ? {
305 id: this.Runner.id,
306 name: this.Runner.name,
307 description: this.Runner.description
308 }
309 : null
310
311 const parent = this.DependsOnRunnerJob
312 ? {
313 id: this.DependsOnRunnerJob.id,
314 uuid: this.DependsOnRunnerJob.uuid,
315 type: this.DependsOnRunnerJob.type,
316 state: {
317 id: this.DependsOnRunnerJob.state,
318 label: RUNNER_JOB_STATES[this.DependsOnRunnerJob.state]
319 }
320 }
321 : undefined
322
323 return {
324 uuid: this.uuid,
325 type: this.type,
326
327 state: {
328 id: this.state,
329 label: RUNNER_JOB_STATES[this.state]
330 },
331
332 progress: this.progress,
333 priority: this.priority,
334 failures: this.failures,
335 error: this.error,
336
337 payload: this.payload,
338
339 startedAt: this.startedAt?.toISOString(),
340 finishedAt: this.finishedAt?.toISOString(),
341
342 createdAt: this.createdAt.toISOString(),
343 updatedAt: this.updatedAt.toISOString(),
344
345 parent,
346 runner
347 }
348 }
349
350 toFormattedAdminJSON (this: MRunnerJobRunnerParent): RunnerJobAdmin {
351 return {
352 ...this.toFormattedJSON(),
353
354 privatePayload: this.privatePayload
355 }
356 }
357}
diff --git a/server/models/runner/runner-registration-token.ts b/server/models/runner/runner-registration-token.ts
deleted file mode 100644
index b2ae6c9eb..000000000
--- a/server/models/runner/runner-registration-token.ts
+++ /dev/null
@@ -1,103 +0,0 @@
1import { FindOptions, literal } from 'sequelize'
2import { AllowNull, Column, CreatedAt, HasMany, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MRunnerRegistrationToken } from '@server/types/models/runners'
4import { RunnerRegistrationToken } from '@shared/models'
5import { AttributesOnly } from '@shared/typescript-utils'
6import { getSort } from '../shared'
7import { RunnerModel } from './runner'
8
9/**
10 *
11 * Tokens used by PeerTube runners to register themselves to the PeerTube instance
12 *
13 */
14
15@Table({
16 tableName: 'runnerRegistrationToken',
17 indexes: [
18 {
19 fields: [ 'registrationToken' ],
20 unique: true
21 }
22 ]
23})
24export class RunnerRegistrationTokenModel extends Model<Partial<AttributesOnly<RunnerRegistrationTokenModel>>> {
25
26 @AllowNull(false)
27 @Column
28 registrationToken: string
29
30 @CreatedAt
31 createdAt: Date
32
33 @UpdatedAt
34 updatedAt: Date
35
36 @HasMany(() => RunnerModel, {
37 foreignKey: {
38 allowNull: true
39 },
40 onDelete: 'cascade'
41 })
42 Runners: RunnerModel[]
43
44 static load (id: number) {
45 return RunnerRegistrationTokenModel.findByPk(id)
46 }
47
48 static loadByRegistrationToken (registrationToken: string) {
49 const query = {
50 where: { registrationToken }
51 }
52
53 return RunnerRegistrationTokenModel.findOne(query)
54 }
55
56 static countTotal () {
57 return RunnerRegistrationTokenModel.unscoped().count()
58 }
59
60 static listForApi (options: {
61 start: number
62 count: number
63 sort: string
64 }) {
65 const { start, count, sort } = options
66
67 const query: FindOptions = {
68 attributes: {
69 include: [
70 [
71 literal('(SELECT COUNT(*) FROM "runner" WHERE "runner"."runnerRegistrationTokenId" = "RunnerRegistrationTokenModel"."id")'),
72 'registeredRunnersCount'
73 ]
74 ]
75 },
76 offset: start,
77 limit: count,
78 order: getSort(sort)
79 }
80
81 return Promise.all([
82 RunnerRegistrationTokenModel.count(query),
83 RunnerRegistrationTokenModel.findAll<MRunnerRegistrationToken>(query)
84 ]).then(([ total, data ]) => ({ total, data }))
85 }
86
87 // ---------------------------------------------------------------------------
88
89 toFormattedJSON (this: MRunnerRegistrationToken): RunnerRegistrationToken {
90 const registeredRunnersCount = this.get('registeredRunnersCount') as number
91
92 return {
93 id: this.id,
94
95 registrationToken: this.registrationToken,
96
97 createdAt: this.createdAt,
98 updatedAt: this.updatedAt,
99
100 registeredRunnersCount
101 }
102 }
103}
diff --git a/server/models/runner/runner.ts b/server/models/runner/runner.ts
deleted file mode 100644
index 4d07707d8..000000000
--- a/server/models/runner/runner.ts
+++ /dev/null
@@ -1,124 +0,0 @@
1import { FindOptions } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MRunner } from '@server/types/models/runners'
4import { Runner } from '@shared/models'
5import { AttributesOnly } from '@shared/typescript-utils'
6import { getSort } from '../shared'
7import { RunnerRegistrationTokenModel } from './runner-registration-token'
8import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
9
10@Table({
11 tableName: 'runner',
12 indexes: [
13 {
14 fields: [ 'runnerToken' ],
15 unique: true
16 },
17 {
18 fields: [ 'runnerRegistrationTokenId' ]
19 },
20 {
21 fields: [ 'name' ],
22 unique: true
23 }
24 ]
25})
26export class RunnerModel extends Model<Partial<AttributesOnly<RunnerModel>>> {
27
28 // Used to identify the appropriate runner when it uses the runner REST API
29 @AllowNull(false)
30 @Column
31 runnerToken: string
32
33 @AllowNull(false)
34 @Column
35 name: string
36
37 @AllowNull(true)
38 @Column(DataType.STRING(CONSTRAINTS_FIELDS.RUNNERS.DESCRIPTION.max))
39 description: string
40
41 @AllowNull(false)
42 @Column
43 lastContact: Date
44
45 @AllowNull(false)
46 @Column
47 ip: string
48
49 @CreatedAt
50 createdAt: Date
51
52 @UpdatedAt
53 updatedAt: Date
54
55 @ForeignKey(() => RunnerRegistrationTokenModel)
56 @Column
57 runnerRegistrationTokenId: number
58
59 @BelongsTo(() => RunnerRegistrationTokenModel, {
60 foreignKey: {
61 allowNull: false
62 },
63 onDelete: 'cascade'
64 })
65 RunnerRegistrationToken: RunnerRegistrationTokenModel
66
67 // ---------------------------------------------------------------------------
68
69 static load (id: number) {
70 return RunnerModel.findByPk(id)
71 }
72
73 static loadByToken (runnerToken: string) {
74 const query = {
75 where: { runnerToken }
76 }
77
78 return RunnerModel.findOne(query)
79 }
80
81 static loadByName (name: string) {
82 const query = {
83 where: { name }
84 }
85
86 return RunnerModel.findOne(query)
87 }
88
89 static listForApi (options: {
90 start: number
91 count: number
92 sort: string
93 }) {
94 const { start, count, sort } = options
95
96 const query: FindOptions = {
97 offset: start,
98 limit: count,
99 order: getSort(sort)
100 }
101
102 return Promise.all([
103 RunnerModel.count(query),
104 RunnerModel.findAll<MRunner>(query)
105 ]).then(([ total, data ]) => ({ total, data }))
106 }
107
108 // ---------------------------------------------------------------------------
109
110 toFormattedJSON (this: MRunner): Runner {
111 return {
112 id: this.id,
113
114 name: this.name,
115 description: this.description,
116
117 ip: this.ip,
118 lastContact: this.lastContact,
119
120 createdAt: this.createdAt,
121 updatedAt: this.updatedAt
122 }
123 }
124}
diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts
deleted file mode 100644
index 9948c9f7a..000000000
--- a/server/models/server/plugin.ts
+++ /dev/null
@@ -1,305 +0,0 @@
1import { FindAndCountOptions, json, QueryTypes } from 'sequelize'
2import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MPlugin, MPluginFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { PeerTubePlugin, PluginType, RegisterServerSettingOptions, SettingEntries, SettingValue } from '../../../shared/models'
6import {
7 isPluginDescriptionValid,
8 isPluginHomepage,
9 isPluginNameValid,
10 isPluginStableOrUnstableVersionValid,
11 isPluginStableVersionValid,
12 isPluginTypeValid
13} from '../../helpers/custom-validators/plugins'
14import { getSort, throwIfNotValid } from '../shared'
15
16@DefaultScope(() => ({
17 attributes: {
18 exclude: [ 'storage' ]
19 }
20}))
21
22@Table({
23 tableName: 'plugin',
24 indexes: [
25 {
26 fields: [ 'name', 'type' ],
27 unique: true
28 }
29 ]
30})
31export class PluginModel extends Model<Partial<AttributesOnly<PluginModel>>> {
32
33 @AllowNull(false)
34 @Is('PluginName', value => throwIfNotValid(value, isPluginNameValid, 'name'))
35 @Column
36 name: string
37
38 @AllowNull(false)
39 @Is('PluginType', value => throwIfNotValid(value, isPluginTypeValid, 'type'))
40 @Column
41 type: number
42
43 @AllowNull(false)
44 @Is('PluginVersion', value => throwIfNotValid(value, isPluginStableOrUnstableVersionValid, 'version'))
45 @Column
46 version: string
47
48 @AllowNull(true)
49 @Is('PluginLatestVersion', value => throwIfNotValid(value, isPluginStableVersionValid, 'version'))
50 @Column
51 latestVersion: string
52
53 @AllowNull(false)
54 @Column
55 enabled: boolean
56
57 @AllowNull(false)
58 @Column
59 uninstalled: boolean
60
61 @AllowNull(false)
62 @Column
63 peertubeEngine: string
64
65 @AllowNull(true)
66 @Is('PluginDescription', value => throwIfNotValid(value, isPluginDescriptionValid, 'description'))
67 @Column
68 description: string
69
70 @AllowNull(false)
71 @Is('PluginHomepage', value => throwIfNotValid(value, isPluginHomepage, 'homepage'))
72 @Column
73 homepage: string
74
75 @AllowNull(true)
76 @Column(DataType.JSONB)
77 settings: any
78
79 @AllowNull(true)
80 @Column(DataType.JSONB)
81 storage: any
82
83 @CreatedAt
84 createdAt: Date
85
86 @UpdatedAt
87 updatedAt: Date
88
89 static listEnabledPluginsAndThemes (): Promise<MPlugin[]> {
90 const query = {
91 where: {
92 enabled: true,
93 uninstalled: false
94 }
95 }
96
97 return PluginModel.findAll(query)
98 }
99
100 static loadByNpmName (npmName: string): Promise<MPlugin> {
101 const name = this.normalizePluginName(npmName)
102 const type = this.getTypeFromNpmName(npmName)
103
104 const query = {
105 where: {
106 name,
107 type
108 }
109 }
110
111 return PluginModel.findOne(query)
112 }
113
114 static getSetting (pluginName: string, pluginType: PluginType, settingName: string, registeredSettings: RegisterServerSettingOptions[]) {
115 const query = {
116 attributes: [ 'settings' ],
117 where: {
118 name: pluginName,
119 type: pluginType
120 }
121 }
122
123 return PluginModel.findOne(query)
124 .then(p => {
125 if (!p?.settings || p.settings === undefined) {
126 const registered = registeredSettings.find(s => s.name === settingName)
127 if (!registered || registered.default === undefined) return undefined
128
129 return registered.default
130 }
131
132 return p.settings[settingName]
133 })
134 }
135
136 static getSettings (
137 pluginName: string,
138 pluginType: PluginType,
139 settingNames: string[],
140 registeredSettings: RegisterServerSettingOptions[]
141 ) {
142 const query = {
143 attributes: [ 'settings' ],
144 where: {
145 name: pluginName,
146 type: pluginType
147 }
148 }
149
150 return PluginModel.findOne(query)
151 .then(p => {
152 const result: SettingEntries = {}
153
154 for (const name of settingNames) {
155 if (!p?.settings || p.settings[name] === undefined) {
156 const registered = registeredSettings.find(s => s.name === name)
157
158 if (registered?.default !== undefined) {
159 result[name] = registered.default
160 }
161 } else {
162 result[name] = p.settings[name]
163 }
164 }
165
166 return result
167 })
168 }
169
170 static setSetting (pluginName: string, pluginType: PluginType, settingName: string, settingValue: SettingValue) {
171 const query = {
172 where: {
173 name: pluginName,
174 type: pluginType
175 }
176 }
177
178 const toSave = {
179 [`settings.${settingName}`]: settingValue
180 }
181
182 return PluginModel.update(toSave, query)
183 .then(() => undefined)
184 }
185
186 static getData (pluginName: string, pluginType: PluginType, key: string) {
187 const query = {
188 raw: true,
189 attributes: [ [ json('storage.' + key), 'value' ] as any ], // FIXME: typings
190 where: {
191 name: pluginName,
192 type: pluginType
193 }
194 }
195
196 return PluginModel.findOne(query)
197 .then((c: any) => {
198 if (!c) return undefined
199 const value = c.value
200
201 try {
202 return JSON.parse(value)
203 } catch {
204 return value
205 }
206 })
207 }
208
209 static storeData (pluginName: string, pluginType: PluginType, key: string, data: any) {
210 const query = 'UPDATE "plugin" SET "storage" = jsonb_set(coalesce("storage", \'{}\'), :key, :data::jsonb) ' +
211 'WHERE "name" = :pluginName AND "type" = :pluginType'
212
213 const jsonPath = '{' + key + '}'
214
215 const options = {
216 replacements: { pluginName, pluginType, key: jsonPath, data: JSON.stringify(data) },
217 type: QueryTypes.UPDATE
218 }
219
220 return PluginModel.sequelize.query(query, options)
221 .then(() => undefined)
222 }
223
224 static listForApi (options: {
225 pluginType?: PluginType
226 uninstalled?: boolean
227 start: number
228 count: number
229 sort: string
230 }) {
231 const { uninstalled = false } = options
232 const query: FindAndCountOptions = {
233 offset: options.start,
234 limit: options.count,
235 order: getSort(options.sort),
236 where: {
237 uninstalled
238 }
239 }
240
241 if (options.pluginType) query.where['type'] = options.pluginType
242
243 return Promise.all([
244 PluginModel.count(query),
245 PluginModel.findAll<MPlugin>(query)
246 ]).then(([ total, data ]) => ({ total, data }))
247 }
248
249 static listInstalled (): Promise<MPlugin[]> {
250 const query = {
251 where: {
252 uninstalled: false
253 }
254 }
255
256 return PluginModel.findAll(query)
257 }
258
259 static normalizePluginName (npmName: string) {
260 return npmName.replace(/^peertube-((theme)|(plugin))-/, '')
261 }
262
263 static getTypeFromNpmName (npmName: string) {
264 return npmName.startsWith('peertube-plugin-')
265 ? PluginType.PLUGIN
266 : PluginType.THEME
267 }
268
269 static buildNpmName (name: string, type: PluginType) {
270 if (type === PluginType.THEME) return 'peertube-theme-' + name
271
272 return 'peertube-plugin-' + name
273 }
274
275 getPublicSettings (registeredSettings: RegisterServerSettingOptions[]) {
276 const result: SettingEntries = {}
277 const settings = this.settings || {}
278
279 for (const r of registeredSettings) {
280 if (r.private !== false) continue
281
282 result[r.name] = settings[r.name] ?? r.default ?? null
283 }
284
285 return result
286 }
287
288 toFormattedJSON (this: MPluginFormattable): PeerTubePlugin {
289 return {
290 name: this.name,
291 type: this.type,
292 version: this.version,
293 latestVersion: this.latestVersion,
294 enabled: this.enabled,
295 uninstalled: this.uninstalled,
296 peertubeEngine: this.peertubeEngine,
297 description: this.description,
298 homepage: this.homepage,
299 settings: this.settings,
300 createdAt: this.createdAt,
301 updatedAt: this.updatedAt
302 }
303 }
304
305}
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
deleted file mode 100644
index 3d755fe4a..000000000
--- a/server/models/server/server-blocklist.ts
+++ /dev/null
@@ -1,190 +0,0 @@
1import { Op, QueryTypes } from 'sequelize'
2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models'
4import { ServerBlock } from '@shared/models'
5import { AttributesOnly } from '@shared/typescript-utils'
6import { AccountModel } from '../account/account'
7import { createSafeIn, getSort, searchAttribute } from '../shared'
8import { ServerModel } from './server'
9
10enum ScopeNames {
11 WITH_ACCOUNT = 'WITH_ACCOUNT',
12 WITH_SERVER = 'WITH_SERVER'
13}
14
15@Scopes(() => ({
16 [ScopeNames.WITH_ACCOUNT]: {
17 include: [
18 {
19 model: AccountModel,
20 required: true
21 }
22 ]
23 },
24 [ScopeNames.WITH_SERVER]: {
25 include: [
26 {
27 model: ServerModel,
28 required: true
29 }
30 ]
31 }
32}))
33
34@Table({
35 tableName: 'serverBlocklist',
36 indexes: [
37 {
38 fields: [ 'accountId', 'targetServerId' ],
39 unique: true
40 },
41 {
42 fields: [ 'targetServerId' ]
43 }
44 ]
45})
46export class ServerBlocklistModel extends Model<Partial<AttributesOnly<ServerBlocklistModel>>> {
47
48 @CreatedAt
49 createdAt: Date
50
51 @UpdatedAt
52 updatedAt: Date
53
54 @ForeignKey(() => AccountModel)
55 @Column
56 accountId: number
57
58 @BelongsTo(() => AccountModel, {
59 foreignKey: {
60 name: 'accountId',
61 allowNull: false
62 },
63 onDelete: 'CASCADE'
64 })
65 ByAccount: AccountModel
66
67 @ForeignKey(() => ServerModel)
68 @Column
69 targetServerId: number
70
71 @BelongsTo(() => ServerModel, {
72 foreignKey: {
73 allowNull: false
74 },
75 onDelete: 'CASCADE'
76 })
77 BlockedServer: ServerModel
78
79 static isServerMutedByAccounts (accountIds: number[], targetServerId: number) {
80 const query = {
81 attributes: [ 'accountId', 'id' ],
82 where: {
83 accountId: {
84 [Op.in]: accountIds
85 },
86 targetServerId
87 },
88 raw: true
89 }
90
91 return ServerBlocklistModel.unscoped()
92 .findAll(query)
93 .then(rows => {
94 const result: { [accountId: number]: boolean } = {}
95
96 for (const accountId of accountIds) {
97 result[accountId] = !!rows.find(r => r.accountId === accountId)
98 }
99
100 return result
101 })
102 }
103
104 static loadByAccountAndHost (accountId: number, host: string): Promise<MServerBlocklist> {
105 const query = {
106 where: {
107 accountId
108 },
109 include: [
110 {
111 model: ServerModel,
112 where: {
113 host
114 },
115 required: true
116 }
117 ]
118 }
119
120 return ServerBlocklistModel.findOne(query)
121 }
122
123 static listHostsBlockedBy (accountIds: number[]): Promise<string[]> {
124 const query = {
125 attributes: [ ],
126 where: {
127 accountId: {
128 [Op.in]: accountIds
129 }
130 },
131 include: [
132 {
133 attributes: [ 'host' ],
134 model: ServerModel.unscoped(),
135 required: true
136 }
137 ]
138 }
139
140 return ServerBlocklistModel.findAll(query)
141 .then(entries => entries.map(e => e.BlockedServer.host))
142 }
143
144 static getBlockStatus (byAccountIds: number[], hosts: string[]): Promise<{ host: string, accountId: number }[]> {
145 const rawQuery = `SELECT "server"."host", "serverBlocklist"."accountId" ` +
146 `FROM "serverBlocklist" ` +
147 `INNER JOIN "server" ON "server"."id" = "serverBlocklist"."targetServerId" ` +
148 `WHERE "server"."host" IN (:hosts) ` +
149 `AND "serverBlocklist"."accountId" IN (${createSafeIn(ServerBlocklistModel.sequelize, byAccountIds)})`
150
151 return ServerBlocklistModel.sequelize.query(rawQuery, {
152 type: QueryTypes.SELECT as QueryTypes.SELECT,
153 replacements: { hosts }
154 })
155 }
156
157 static listForApi (parameters: {
158 start: number
159 count: number
160 sort: string
161 search?: string
162 accountId: number
163 }) {
164 const { start, count, sort, search, accountId } = parameters
165
166 const query = {
167 offset: start,
168 limit: count,
169 order: getSort(sort),
170 where: {
171 accountId,
172
173 ...searchAttribute(search, '$BlockedServer.host$')
174 }
175 }
176
177 return Promise.all([
178 ServerBlocklistModel.scope(ScopeNames.WITH_SERVER).count(query),
179 ServerBlocklistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_SERVER ]).findAll<MServerBlocklistAccountServer>(query)
180 ]).then(([ total, data ]) => ({ total, data }))
181 }
182
183 toFormattedJSON (this: MServerBlocklistFormattable): ServerBlock {
184 return {
185 byAccount: this.ByAccount.toFormattedJSON(),
186 blockedServer: this.BlockedServer.toFormattedJSON(),
187 createdAt: this.createdAt
188 }
189 }
190}
diff --git a/server/models/server/server.ts b/server/models/server/server.ts
deleted file mode 100644
index a5e05f460..000000000
--- a/server/models/server/server.ts
+++ /dev/null
@@ -1,104 +0,0 @@
1import { Transaction } from 'sequelize'
2import { AllowNull, Column, CreatedAt, Default, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MServer, MServerFormattable } from '@server/types/models/server'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { isHostValid } from '../../helpers/custom-validators/servers'
6import { ActorModel } from '../actor/actor'
7import { buildSQLAttributes, throwIfNotValid } from '../shared'
8import { ServerBlocklistModel } from './server-blocklist'
9
10@Table({
11 tableName: 'server',
12 indexes: [
13 {
14 fields: [ 'host' ],
15 unique: true
16 }
17 ]
18})
19export class ServerModel extends Model<Partial<AttributesOnly<ServerModel>>> {
20
21 @AllowNull(false)
22 @Is('Host', value => throwIfNotValid(value, isHostValid, 'valid host'))
23 @Column
24 host: string
25
26 @AllowNull(false)
27 @Default(false)
28 @Column
29 redundancyAllowed: boolean
30
31 @CreatedAt
32 createdAt: Date
33
34 @UpdatedAt
35 updatedAt: Date
36
37 @HasMany(() => ActorModel, {
38 foreignKey: {
39 name: 'serverId',
40 allowNull: true
41 },
42 onDelete: 'CASCADE',
43 hooks: true
44 })
45 Actors: ActorModel[]
46
47 @HasMany(() => ServerBlocklistModel, {
48 foreignKey: {
49 allowNull: false
50 },
51 onDelete: 'CASCADE'
52 })
53 BlockedBy: ServerBlocklistModel[]
54
55 // ---------------------------------------------------------------------------
56
57 static getSQLAttributes (tableName: string, aliasPrefix = '') {
58 return buildSQLAttributes({
59 model: this,
60 tableName,
61 aliasPrefix
62 })
63 }
64
65 // ---------------------------------------------------------------------------
66
67 static load (id: number, transaction?: Transaction): Promise<MServer> {
68 const query = {
69 where: {
70 id
71 },
72 transaction
73 }
74
75 return ServerModel.findOne(query)
76 }
77
78 static loadByHost (host: string): Promise<MServer> {
79 const query = {
80 where: {
81 host
82 }
83 }
84
85 return ServerModel.findOne(query)
86 }
87
88 static async loadOrCreateByHost (host: string) {
89 let server = await ServerModel.loadByHost(host)
90 if (!server) server = await ServerModel.create({ host })
91
92 return server
93 }
94
95 isBlocked () {
96 return this.BlockedBy && this.BlockedBy.length !== 0
97 }
98
99 toFormattedJSON (this: MServerFormattable) {
100 return {
101 host: this.host
102 }
103 }
104}
diff --git a/server/models/server/tracker.ts b/server/models/server/tracker.ts
deleted file mode 100644
index ee087c4a3..000000000
--- a/server/models/server/tracker.ts
+++ /dev/null
@@ -1,74 +0,0 @@
1import { AllowNull, BelongsToMany, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { Transaction } from 'sequelize/types'
3import { MTracker } from '@server/types/models/server/tracker'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoModel } from '../video/video'
6import { VideoTrackerModel } from './video-tracker'
7
8@Table({
9 tableName: 'tracker',
10 indexes: [
11 {
12 fields: [ 'url' ],
13 unique: true
14 }
15 ]
16})
17export class TrackerModel extends Model<Partial<AttributesOnly<TrackerModel>>> {
18
19 @AllowNull(false)
20 @Column
21 url: string
22
23 @CreatedAt
24 createdAt: Date
25
26 @UpdatedAt
27 updatedAt: Date
28
29 @BelongsToMany(() => VideoModel, {
30 foreignKey: 'trackerId',
31 through: () => VideoTrackerModel,
32 onDelete: 'CASCADE'
33 })
34 Videos: VideoModel[]
35
36 static listUrlsByVideoId (videoId: number) {
37 const query = {
38 include: [
39 {
40 attributes: [ 'id' ],
41 model: VideoModel.unscoped(),
42 required: true,
43 where: { id: videoId }
44 }
45 ]
46 }
47
48 return TrackerModel.findAll(query)
49 .then(rows => rows.map(rows => rows.url))
50 }
51
52 static findOrCreateTrackers (trackers: string[], transaction: Transaction): Promise<MTracker[]> {
53 if (trackers === null) return Promise.resolve([])
54
55 const tasks: Promise<MTracker>[] = []
56 trackers.forEach(tracker => {
57 const query = {
58 where: {
59 url: tracker
60 },
61 defaults: {
62 url: tracker
63 },
64 transaction
65 }
66
67 const promise = TrackerModel.findOrCreate<MTracker>(query)
68 .then(([ trackerInstance ]) => trackerInstance)
69 tasks.push(promise)
70 })
71
72 return Promise.all(tasks)
73 }
74}
diff --git a/server/models/server/video-tracker.ts b/server/models/server/video-tracker.ts
deleted file mode 100644
index f14f3bd7d..000000000
--- a/server/models/server/video-tracker.ts
+++ /dev/null
@@ -1,31 +0,0 @@
1import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/typescript-utils'
3import { VideoModel } from '../video/video'
4import { TrackerModel } from './tracker'
5
6@Table({
7 tableName: 'videoTracker',
8 indexes: [
9 {
10 fields: [ 'videoId' ]
11 },
12 {
13 fields: [ 'trackerId' ]
14 }
15 ]
16})
17export class VideoTrackerModel extends Model<Partial<AttributesOnly<VideoTrackerModel>>> {
18 @CreatedAt
19 createdAt: Date
20
21 @UpdatedAt
22 updatedAt: Date
23
24 @ForeignKey(() => VideoModel)
25 @Column
26 videoId: number
27
28 @ForeignKey(() => TrackerModel)
29 @Column
30 trackerId: number
31}
diff --git a/server/models/shared/abstract-run-query.ts b/server/models/shared/abstract-run-query.ts
deleted file mode 100644
index 7f27a0c4b..000000000
--- a/server/models/shared/abstract-run-query.ts
+++ /dev/null
@@ -1,32 +0,0 @@
1import { QueryTypes, Sequelize, Transaction } from 'sequelize'
2
3/**
4 *
5 * Abstract builder to run video SQL queries
6 *
7 */
8
9export class AbstractRunQuery {
10 protected query: string
11 protected replacements: any = {}
12
13 constructor (protected readonly sequelize: Sequelize) {
14
15 }
16
17 protected runQuery (options: { nest?: boolean, transaction?: Transaction, logging?: boolean } = {}) {
18 const queryOptions = {
19 transaction: options.transaction,
20 logging: options.logging,
21 replacements: this.replacements,
22 type: QueryTypes.SELECT as QueryTypes.SELECT,
23 nest: options.nest ?? false
24 }
25
26 return this.sequelize.query<any>(this.query, queryOptions)
27 }
28
29 protected buildSelect (entities: string[]) {
30 return `SELECT ${entities.join(', ')} `
31 }
32}
diff --git a/server/models/shared/index.ts b/server/models/shared/index.ts
deleted file mode 100644
index 5a7621e4d..000000000
--- a/server/models/shared/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
1export * from './abstract-run-query'
2export * from './model-builder'
3export * from './model-cache'
4export * from './query'
5export * from './sequelize-helpers'
6export * from './sort'
7export * from './sql'
8export * from './update'
diff --git a/server/models/shared/model-builder.ts b/server/models/shared/model-builder.ts
deleted file mode 100644
index 07f7c4038..000000000
--- a/server/models/shared/model-builder.ts
+++ /dev/null
@@ -1,118 +0,0 @@
1import { isPlainObject } from 'lodash'
2import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize'
3import { logger } from '@server/helpers/logger'
4
5/**
6 *
7 * Build Sequelize models from sequelize raw query (that must use { nest: true } options)
8 *
9 * In order to sequelize to correctly build the JSON this class will ingest,
10 * the columns selected in the raw query should be in the following form:
11 * * All tables must be Pascal Cased (for example "VideoChannel")
12 * * Root table must end with `Model` (for example "VideoCommentModel")
13 * * Joined tables must contain the origin table name + '->JoinedTable'. For example:
14 * * "Actor" is joined to "Account": "Actor" table must be renamed "Account->Actor"
15 * * "Account->Actor" is joined to "Server": "Server" table must be renamed to "Account->Actor->Server"
16 * * Selected columns must be renamed to contain the JSON path:
17 * * "videoComment"."id": "VideoCommentModel"."id"
18 * * "Account"."Actor"."Server"."id": "Account.Actor.Server.id"
19 * * All tables must contain the row id
20 */
21
22export class ModelBuilder <T extends SequelizeModel> {
23 private readonly modelRegistry = new Map<string, T>()
24
25 constructor (private readonly sequelize: Sequelize) {
26
27 }
28
29 createModels (jsonArray: any[], baseModelName: string): T[] {
30 const result: T[] = []
31
32 for (const json of jsonArray) {
33 const { created, model } = this.createModel(json, baseModelName, json.id + '.' + baseModelName)
34
35 if (created) result.push(model)
36 }
37
38 return result
39 }
40
41 private createModel (json: any, modelName: string, keyPath: string) {
42 if (!json.id) return { created: false, model: null }
43
44 const { created, model } = this.createOrFindModel(json, modelName, keyPath)
45
46 for (const key of Object.keys(json)) {
47 const value = json[key]
48 if (!value) continue
49
50 // Child model
51 if (isPlainObject(value)) {
52 const { created, model: subModel } = this.createModel(value, key, keyPath + '.' + json.id + '.' + key)
53 if (!created || !subModel) continue
54
55 const Model = this.findModelBuilder(modelName)
56 const association = Model.associations[key]
57
58 if (!association) {
59 logger.error('Cannot find association %s of model %s', key, modelName, { associations: Object.keys(Model.associations) })
60 continue
61 }
62
63 if (association.isMultiAssociation) {
64 if (!Array.isArray(model[key])) model[key] = []
65
66 model[key].push(subModel)
67 } else {
68 model[key] = subModel
69 }
70 }
71 }
72
73 return { created, model }
74 }
75
76 private createOrFindModel (json: any, modelName: string, keyPath: string) {
77 const registryKey = this.getModelRegistryKey(json, keyPath)
78 if (this.modelRegistry.has(registryKey)) {
79 return {
80 created: false,
81 model: this.modelRegistry.get(registryKey)
82 }
83 }
84
85 const Model = this.findModelBuilder(modelName)
86
87 if (!Model) {
88 logger.error(
89 'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
90 { existing: this.sequelize.modelManager.all.map(m => m.name) }
91 )
92 return { created: false, model: null }
93 }
94
95 const model = Model.build(json, { raw: true, isNewRecord: false })
96
97 this.modelRegistry.set(registryKey, model)
98
99 return { created: true, model }
100 }
101
102 private findModelBuilder (modelName: string) {
103 return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) as ModelStatic<T>
104 }
105
106 private buildSequelizeModelName (modelName: string) {
107 if (modelName === 'Avatars') return 'ActorImageModel'
108 if (modelName === 'ActorFollowing') return 'ActorModel'
109 if (modelName === 'ActorFollower') return 'ActorModel'
110 if (modelName === 'FlaggedAccount') return 'AccountModel'
111
112 return modelName + 'Model'
113 }
114
115 private getModelRegistryKey (json: any, keyPath: string) {
116 return keyPath + json.id
117 }
118}
diff --git a/server/models/shared/model-cache.ts b/server/models/shared/model-cache.ts
deleted file mode 100644
index 3651267e7..000000000
--- a/server/models/shared/model-cache.ts
+++ /dev/null
@@ -1,90 +0,0 @@
1import { Model } from 'sequelize-typescript'
2import { logger } from '@server/helpers/logger'
3
4type ModelCacheType =
5 'local-account-name'
6 | 'local-actor-name'
7 | 'local-actor-url'
8 | 'load-video-immutable-id'
9 | 'load-video-immutable-url'
10
11type DeleteKey =
12 'video'
13
14class ModelCache {
15
16 private static instance: ModelCache
17
18 private readonly localCache: { [id in ModelCacheType]: Map<string, any> } = {
19 'local-account-name': new Map(),
20 'local-actor-name': new Map(),
21 'local-actor-url': new Map(),
22 'load-video-immutable-id': new Map(),
23 'load-video-immutable-url': new Map()
24 }
25
26 private readonly deleteIds: {
27 [deleteKey in DeleteKey]: Map<number, { cacheType: ModelCacheType, key: string }[]>
28 } = {
29 video: new Map()
30 }
31
32 private constructor () {
33 }
34
35 static get Instance () {
36 return this.instance || (this.instance = new this())
37 }
38
39 doCache<T extends Model> (options: {
40 cacheType: ModelCacheType
41 key: string
42 fun: () => Promise<T>
43 whitelist?: () => boolean
44 deleteKey?: DeleteKey
45 }) {
46 const { cacheType, key, fun, whitelist, deleteKey } = options
47
48 if (whitelist && whitelist() !== true) return fun()
49
50 const cache = this.localCache[cacheType]
51
52 if (cache.has(key)) {
53 logger.debug('Model cache hit for %s -> %s.', cacheType, key)
54 return Promise.resolve<T>(cache.get(key))
55 }
56
57 return fun().then(m => {
58 if (!m) return m
59
60 if (!whitelist || whitelist()) cache.set(key, m)
61
62 if (deleteKey) {
63 const map = this.deleteIds[deleteKey]
64 if (!map.has(m.id)) map.set(m.id, [])
65
66 const a = map.get(m.id)
67 a.push({ cacheType, key })
68 }
69
70 return m
71 })
72 }
73
74 invalidateCache (deleteKey: DeleteKey, modelId: number) {
75 const map = this.deleteIds[deleteKey]
76
77 if (!map.has(modelId)) return
78
79 for (const toDelete of map.get(modelId)) {
80 logger.debug('Removing %s -> %d of model cache %s -> %s.', deleteKey, modelId, toDelete.cacheType, toDelete.key)
81 this.localCache[toDelete.cacheType].delete(toDelete.key)
82 }
83
84 map.delete(modelId)
85 }
86}
87
88export {
89 ModelCache
90}
diff --git a/server/models/shared/query.ts b/server/models/shared/query.ts
deleted file mode 100644
index 934acc21f..000000000
--- a/server/models/shared/query.ts
+++ /dev/null
@@ -1,82 +0,0 @@
1import { BindOrReplacements, Op, QueryTypes, Sequelize } from 'sequelize'
2import validator from 'validator'
3import { forceNumber } from '@shared/core-utils'
4
5function doesExist (sequelize: Sequelize, query: string, bind?: BindOrReplacements) {
6 const options = {
7 type: QueryTypes.SELECT as QueryTypes.SELECT,
8 bind,
9 raw: true
10 }
11
12 return sequelize.query(query, options)
13 .then(results => results.length === 1)
14}
15
16function createSimilarityAttribute (col: string, value: string) {
17 return Sequelize.fn(
18 'similarity',
19
20 searchTrigramNormalizeCol(col),
21
22 searchTrigramNormalizeValue(value)
23 )
24}
25
26function buildWhereIdOrUUID (id: number | string) {
27 return validator.isInt('' + id) ? { id } : { uuid: id }
28}
29
30function parseAggregateResult (result: any) {
31 if (!result) return 0
32
33 const total = forceNumber(result)
34 if (isNaN(total)) return 0
35
36 return total
37}
38
39function parseRowCountResult (result: any) {
40 if (result.length !== 0) return result[0].total
41
42 return 0
43}
44
45function createSafeIn (sequelize: Sequelize, toEscape: (string | number)[], additionalUnescaped: string[] = []) {
46 return toEscape.map(t => {
47 return t === null
48 ? null
49 : sequelize.escape('' + t)
50 }).concat(additionalUnescaped).join(', ')
51}
52
53function searchAttribute (sourceField?: string, targetField?: string) {
54 if (!sourceField) return {}
55
56 return {
57 [targetField]: {
58 // FIXME: ts error
59 [Op.iLike as any]: `%${sourceField}%`
60 }
61 }
62}
63
64export {
65 doesExist,
66 createSimilarityAttribute,
67 buildWhereIdOrUUID,
68 parseAggregateResult,
69 parseRowCountResult,
70 createSafeIn,
71 searchAttribute
72}
73
74// ---------------------------------------------------------------------------
75
76function searchTrigramNormalizeValue (value: string) {
77 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
78}
79
80function searchTrigramNormalizeCol (col: string) {
81 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
82}
diff --git a/server/models/shared/sequelize-helpers.ts b/server/models/shared/sequelize-helpers.ts
deleted file mode 100644
index 7af8471dc..000000000
--- a/server/models/shared/sequelize-helpers.ts
+++ /dev/null
@@ -1,39 +0,0 @@
1import { Sequelize } from 'sequelize'
2
3function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
4 if (!model.createdAt || !model.updatedAt) {
5 throw new Error('Miss createdAt & updatedAt attributes to model')
6 }
7
8 const now = Date.now()
9 const createdAtTime = model.createdAt.getTime()
10 const updatedAtTime = model.updatedAt.getTime()
11
12 return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
13}
14
15function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
16 if (nullable && (value === null || value === undefined)) return
17
18 if (validator(value) === false) {
19 throw new Error(`"${value}" is not a valid ${fieldName}.`)
20 }
21}
22
23function buildTrigramSearchIndex (indexName: string, attribute: string) {
24 return {
25 name: indexName,
26 // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
27 fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
28 using: 'gin',
29 operator: 'gin_trgm_ops'
30 }
31}
32
33// ---------------------------------------------------------------------------
34
35export {
36 throwIfNotValid,
37 buildTrigramSearchIndex,
38 isOutdated
39}
diff --git a/server/models/shared/sort.ts b/server/models/shared/sort.ts
deleted file mode 100644
index d923072f2..000000000
--- a/server/models/shared/sort.ts
+++ /dev/null
@@ -1,146 +0,0 @@
1import { literal, OrderItem, Sequelize } from 'sequelize'
2
3// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
4function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
5 const { direction, field } = buildSortDirectionAndField(value)
6
7 let finalField: string | ReturnType<typeof Sequelize.col>
8
9 if (field.toLowerCase() === 'match') { // Search
10 finalField = Sequelize.col('similarity')
11 } else {
12 finalField = field
13 }
14
15 return [ [ finalField, direction ], lastSort ]
16}
17
18function getAdminUsersSort (value: string): OrderItem[] {
19 const { direction, field } = buildSortDirectionAndField(value)
20
21 let finalField: string | ReturnType<typeof Sequelize.col>
22
23 if (field === 'videoQuotaUsed') { // Users list
24 finalField = Sequelize.col('videoQuotaUsed')
25 } else {
26 finalField = field
27 }
28
29 const nullPolicy = direction === 'ASC'
30 ? 'NULLS FIRST'
31 : 'NULLS LAST'
32
33 // FIXME: typings
34 return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
35}
36
37function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
38 const { direction, field } = buildSortDirectionAndField(value)
39
40 if (field.toLowerCase() === 'name') {
41 return [ [ 'displayName', direction ], lastSort ]
42 }
43
44 return getSort(value, lastSort)
45}
46
47function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
48 const { direction, field } = buildSortDirectionAndField(value)
49
50 if (field.toLowerCase() === 'trending') { // Sort by aggregation
51 return [
52 [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
53
54 [ Sequelize.col('VideoModel.views'), direction ],
55
56 lastSort
57 ]
58 } else if (field === 'publishedAt') {
59 return [
60 [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
61
62 [ Sequelize.col('VideoModel.publishedAt'), direction ],
63
64 lastSort
65 ]
66 }
67
68 let finalField: string | ReturnType<typeof Sequelize.col>
69
70 // Alias
71 if (field.toLowerCase() === 'match') { // Search
72 finalField = Sequelize.col('similarity')
73 } else {
74 finalField = field
75 }
76
77 const firstSort: OrderItem = typeof finalField === 'string'
78 ? finalField.split('.').concat([ direction ]) as OrderItem
79 : [ finalField, direction ]
80
81 return [ firstSort, lastSort ]
82}
83
84function getBlacklistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
85 const { direction, field } = buildSortDirectionAndField(value)
86
87 const videoFields = new Set([ 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid' ])
88
89 if (videoFields.has(field)) {
90 return [
91 [ literal(`"Video.${field}" ${direction}`) ],
92 lastSort
93 ] as OrderItem[]
94 }
95
96 return getSort(value, lastSort)
97}
98
99function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
100 const { direction, field } = buildSortDirectionAndField(value)
101
102 if (field === 'redundancyAllowed') {
103 return [
104 [ 'ActorFollowing.Server.redundancyAllowed', direction ],
105 lastSort
106 ]
107 }
108
109 return getSort(value, lastSort)
110}
111
112function getChannelSyncSort (value: string): OrderItem[] {
113 const { direction, field } = buildSortDirectionAndField(value)
114 if (field.toLowerCase() === 'videochannel') {
115 return [
116 [ literal('"VideoChannel.name"'), direction ]
117 ]
118 }
119 return [ [ field, direction ] ]
120}
121
122function buildSortDirectionAndField (value: string) {
123 let field: string
124 let direction: 'ASC' | 'DESC'
125
126 if (value.substring(0, 1) === '-') {
127 direction = 'DESC'
128 field = value.substring(1)
129 } else {
130 direction = 'ASC'
131 field = value
132 }
133
134 return { direction, field }
135}
136
137export {
138 buildSortDirectionAndField,
139 getPlaylistSort,
140 getSort,
141 getAdminUsersSort,
142 getVideoSort,
143 getBlacklistSort,
144 getChannelSyncSort,
145 getInstanceFollowsSort
146}
diff --git a/server/models/shared/sql.ts b/server/models/shared/sql.ts
deleted file mode 100644
index 5aaeb49f0..000000000
--- a/server/models/shared/sql.ts
+++ /dev/null
@@ -1,68 +0,0 @@
1import { literal, Model, ModelStatic } from 'sequelize'
2import { forceNumber } from '@shared/core-utils'
3import { AttributesOnly } from '@shared/typescript-utils'
4
5function buildLocalAccountIdsIn () {
6 return literal(
7 '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
8 )
9}
10
11function buildLocalActorIdsIn () {
12 return literal(
13 '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
14 )
15}
16
17function buildBlockedAccountSQL (blockerIds: number[]) {
18 const blockerIdsString = blockerIds.join(', ')
19
20 return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
21 ' UNION ' +
22 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
23 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
24 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
25}
26
27function buildServerIdsFollowedBy (actorId: any) {
28 const actorIdNumber = forceNumber(actorId)
29
30 return '(' +
31 'SELECT "actor"."serverId" FROM "actorFollow" ' +
32 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
33 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
34 ')'
35}
36
37function buildSQLAttributes<M extends Model> (options: {
38 model: ModelStatic<M>
39 tableName: string
40
41 excludeAttributes?: Exclude<keyof AttributesOnly<M>, symbol>[]
42 aliasPrefix?: string
43}) {
44 const { model, tableName, aliasPrefix, excludeAttributes } = options
45
46 const attributes = Object.keys(model.getAttributes()) as Exclude<keyof AttributesOnly<M>, symbol>[]
47
48 return attributes
49 .filter(a => {
50 if (!excludeAttributes) return true
51 if (excludeAttributes.includes(a)) return false
52
53 return true
54 })
55 .map(a => {
56 return `"${tableName}"."${a}" AS "${aliasPrefix || ''}${a}"`
57 })
58}
59
60// ---------------------------------------------------------------------------
61
62export {
63 buildSQLAttributes,
64 buildBlockedAccountSQL,
65 buildServerIdsFollowedBy,
66 buildLocalAccountIdsIn,
67 buildLocalActorIdsIn
68}
diff --git a/server/models/shared/update.ts b/server/models/shared/update.ts
deleted file mode 100644
index 96db43730..000000000
--- a/server/models/shared/update.ts
+++ /dev/null
@@ -1,34 +0,0 @@
1import { QueryTypes, Sequelize, Transaction } from 'sequelize'
2
3const updating = new Set<string>()
4
5// Sequelize always skip the update if we only update updatedAt field
6async function setAsUpdated (options: {
7 sequelize: Sequelize
8 table: string
9 id: number
10 transaction?: Transaction
11}) {
12 const { sequelize, table, id, transaction } = options
13 const key = table + '-' + id
14
15 if (updating.has(key)) return
16 updating.add(key)
17
18 try {
19 await sequelize.query(
20 `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`,
21 {
22 replacements: { table, id, updatedAt: new Date() },
23 type: QueryTypes.UPDATE,
24 transaction
25 }
26 )
27 } finally {
28 updating.delete(key)
29 }
30}
31
32export {
33 setAsUpdated
34}
diff --git a/server/models/user/sql/user-notitication-list-query-builder.ts b/server/models/user/sql/user-notitication-list-query-builder.ts
deleted file mode 100644
index 7b29807a3..000000000
--- a/server/models/user/sql/user-notitication-list-query-builder.ts
+++ /dev/null
@@ -1,273 +0,0 @@
1import { Sequelize } from 'sequelize'
2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
3import { UserNotificationModelForApi } from '@server/types/models'
4import { ActorImageType } from '@shared/models'
5import { getSort } from '../../shared'
6
7export interface ListNotificationsOptions {
8 userId: number
9 unread?: boolean
10 sort: string
11 offset: number
12 limit: number
13}
14
15export class UserNotificationListQueryBuilder extends AbstractRunQuery {
16 private innerQuery: string
17
18 constructor (
19 protected readonly sequelize: Sequelize,
20 private readonly options: ListNotificationsOptions
21 ) {
22 super(sequelize)
23 }
24
25 async listNotifications () {
26 this.buildQuery()
27
28 const results = await this.runQuery({ nest: true })
29 const modelBuilder = new ModelBuilder<UserNotificationModelForApi>(this.sequelize)
30
31 return modelBuilder.createModels(results, 'UserNotification')
32 }
33
34 private buildInnerQuery () {
35 this.innerQuery = `SELECT * FROM "userNotification" AS "UserNotificationModel" ` +
36 `${this.getWhere()} ` +
37 `${this.getOrder()} ` +
38 `LIMIT :limit OFFSET :offset `
39
40 this.replacements.limit = this.options.limit
41 this.replacements.offset = this.options.offset
42 }
43
44 private buildQuery () {
45 this.buildInnerQuery()
46
47 this.query = `
48 ${this.getSelect()}
49 FROM (${this.innerQuery}) "UserNotificationModel"
50 ${this.getJoins()}
51 ${this.getOrder()}`
52 }
53
54 private getWhere () {
55 let base = '"UserNotificationModel"."userId" = :userId '
56 this.replacements.userId = this.options.userId
57
58 if (this.options.unread === true) {
59 base += 'AND "UserNotificationModel"."read" IS FALSE '
60 } else if (this.options.unread === false) {
61 base += 'AND "UserNotificationModel"."read" IS TRUE '
62 }
63
64 return `WHERE ${base}`
65 }
66
67 private getOrder () {
68 const orders = getSort(this.options.sort)
69
70 return 'ORDER BY ' + orders.map(o => `"UserNotificationModel"."${o[0]}" ${o[1]}`).join(', ')
71 }
72
73 private getSelect () {
74 return `SELECT
75 "UserNotificationModel"."id",
76 "UserNotificationModel"."type",
77 "UserNotificationModel"."read",
78 "UserNotificationModel"."createdAt",
79 "UserNotificationModel"."updatedAt",
80 "Video"."id" AS "Video.id",
81 "Video"."uuid" AS "Video.uuid",
82 "Video"."name" AS "Video.name",
83 "Video->VideoChannel"."id" AS "Video.VideoChannel.id",
84 "Video->VideoChannel"."name" AS "Video.VideoChannel.name",
85 "Video->VideoChannel->Actor"."id" AS "Video.VideoChannel.Actor.id",
86 "Video->VideoChannel->Actor"."preferredUsername" AS "Video.VideoChannel.Actor.preferredUsername",
87 "Video->VideoChannel->Actor->Avatars"."id" AS "Video.VideoChannel.Actor.Avatars.id",
88 "Video->VideoChannel->Actor->Avatars"."width" AS "Video.VideoChannel.Actor.Avatars.width",
89 "Video->VideoChannel->Actor->Avatars"."type" AS "Video.VideoChannel.Actor.Avatars.type",
90 "Video->VideoChannel->Actor->Avatars"."filename" AS "Video.VideoChannel.Actor.Avatars.filename",
91 "Video->VideoChannel->Actor->Server"."id" AS "Video.VideoChannel.Actor.Server.id",
92 "Video->VideoChannel->Actor->Server"."host" AS "Video.VideoChannel.Actor.Server.host",
93 "VideoComment"."id" AS "VideoComment.id",
94 "VideoComment"."originCommentId" AS "VideoComment.originCommentId",
95 "VideoComment->Account"."id" AS "VideoComment.Account.id",
96 "VideoComment->Account"."name" AS "VideoComment.Account.name",
97 "VideoComment->Account->Actor"."id" AS "VideoComment.Account.Actor.id",
98 "VideoComment->Account->Actor"."preferredUsername" AS "VideoComment.Account.Actor.preferredUsername",
99 "VideoComment->Account->Actor->Avatars"."id" AS "VideoComment.Account.Actor.Avatars.id",
100 "VideoComment->Account->Actor->Avatars"."width" AS "VideoComment.Account.Actor.Avatars.width",
101 "VideoComment->Account->Actor->Avatars"."type" AS "VideoComment.Account.Actor.Avatars.type",
102 "VideoComment->Account->Actor->Avatars"."filename" AS "VideoComment.Account.Actor.Avatars.filename",
103 "VideoComment->Account->Actor->Server"."id" AS "VideoComment.Account.Actor.Server.id",
104 "VideoComment->Account->Actor->Server"."host" AS "VideoComment.Account.Actor.Server.host",
105 "VideoComment->Video"."id" AS "VideoComment.Video.id",
106 "VideoComment->Video"."uuid" AS "VideoComment.Video.uuid",
107 "VideoComment->Video"."name" AS "VideoComment.Video.name",
108 "Abuse"."id" AS "Abuse.id",
109 "Abuse"."state" AS "Abuse.state",
110 "Abuse->VideoAbuse"."id" AS "Abuse.VideoAbuse.id",
111 "Abuse->VideoAbuse->Video"."id" AS "Abuse.VideoAbuse.Video.id",
112 "Abuse->VideoAbuse->Video"."uuid" AS "Abuse.VideoAbuse.Video.uuid",
113 "Abuse->VideoAbuse->Video"."name" AS "Abuse.VideoAbuse.Video.name",
114 "Abuse->VideoCommentAbuse"."id" AS "Abuse.VideoCommentAbuse.id",
115 "Abuse->VideoCommentAbuse->VideoComment"."id" AS "Abuse.VideoCommentAbuse.VideoComment.id",
116 "Abuse->VideoCommentAbuse->VideoComment"."originCommentId" AS "Abuse.VideoCommentAbuse.VideoComment.originCommentId",
117 "Abuse->VideoCommentAbuse->VideoComment->Video"."id" AS "Abuse.VideoCommentAbuse.VideoComment.Video.id",
118 "Abuse->VideoCommentAbuse->VideoComment->Video"."name" AS "Abuse.VideoCommentAbuse.VideoComment.Video.name",
119 "Abuse->VideoCommentAbuse->VideoComment->Video"."uuid" AS "Abuse.VideoCommentAbuse.VideoComment.Video.uuid",
120 "Abuse->FlaggedAccount"."id" AS "Abuse.FlaggedAccount.id",
121 "Abuse->FlaggedAccount"."name" AS "Abuse.FlaggedAccount.name",
122 "Abuse->FlaggedAccount"."description" AS "Abuse.FlaggedAccount.description",
123 "Abuse->FlaggedAccount"."actorId" AS "Abuse.FlaggedAccount.actorId",
124 "Abuse->FlaggedAccount"."userId" AS "Abuse.FlaggedAccount.userId",
125 "Abuse->FlaggedAccount"."applicationId" AS "Abuse.FlaggedAccount.applicationId",
126 "Abuse->FlaggedAccount"."createdAt" AS "Abuse.FlaggedAccount.createdAt",
127 "Abuse->FlaggedAccount"."updatedAt" AS "Abuse.FlaggedAccount.updatedAt",
128 "Abuse->FlaggedAccount->Actor"."id" AS "Abuse.FlaggedAccount.Actor.id",
129 "Abuse->FlaggedAccount->Actor"."preferredUsername" AS "Abuse.FlaggedAccount.Actor.preferredUsername",
130 "Abuse->FlaggedAccount->Actor->Avatars"."id" AS "Abuse.FlaggedAccount.Actor.Avatars.id",
131 "Abuse->FlaggedAccount->Actor->Avatars"."width" AS "Abuse.FlaggedAccount.Actor.Avatars.width",
132 "Abuse->FlaggedAccount->Actor->Avatars"."type" AS "Abuse.FlaggedAccount.Actor.Avatars.type",
133 "Abuse->FlaggedAccount->Actor->Avatars"."filename" AS "Abuse.FlaggedAccount.Actor.Avatars.filename",
134 "Abuse->FlaggedAccount->Actor->Server"."id" AS "Abuse.FlaggedAccount.Actor.Server.id",
135 "Abuse->FlaggedAccount->Actor->Server"."host" AS "Abuse.FlaggedAccount.Actor.Server.host",
136 "VideoBlacklist"."id" AS "VideoBlacklist.id",
137 "VideoBlacklist->Video"."id" AS "VideoBlacklist.Video.id",
138 "VideoBlacklist->Video"."uuid" AS "VideoBlacklist.Video.uuid",
139 "VideoBlacklist->Video"."name" AS "VideoBlacklist.Video.name",
140 "VideoImport"."id" AS "VideoImport.id",
141 "VideoImport"."magnetUri" AS "VideoImport.magnetUri",
142 "VideoImport"."targetUrl" AS "VideoImport.targetUrl",
143 "VideoImport"."torrentName" AS "VideoImport.torrentName",
144 "VideoImport->Video"."id" AS "VideoImport.Video.id",
145 "VideoImport->Video"."uuid" AS "VideoImport.Video.uuid",
146 "VideoImport->Video"."name" AS "VideoImport.Video.name",
147 "Plugin"."id" AS "Plugin.id",
148 "Plugin"."name" AS "Plugin.name",
149 "Plugin"."type" AS "Plugin.type",
150 "Plugin"."latestVersion" AS "Plugin.latestVersion",
151 "Application"."id" AS "Application.id",
152 "Application"."latestPeerTubeVersion" AS "Application.latestPeerTubeVersion",
153 "ActorFollow"."id" AS "ActorFollow.id",
154 "ActorFollow"."state" AS "ActorFollow.state",
155 "ActorFollow->ActorFollower"."id" AS "ActorFollow.ActorFollower.id",
156 "ActorFollow->ActorFollower"."preferredUsername" AS "ActorFollow.ActorFollower.preferredUsername",
157 "ActorFollow->ActorFollower->Account"."id" AS "ActorFollow.ActorFollower.Account.id",
158 "ActorFollow->ActorFollower->Account"."name" AS "ActorFollow.ActorFollower.Account.name",
159 "ActorFollow->ActorFollower->Avatars"."id" AS "ActorFollow.ActorFollower.Avatars.id",
160 "ActorFollow->ActorFollower->Avatars"."width" AS "ActorFollow.ActorFollower.Avatars.width",
161 "ActorFollow->ActorFollower->Avatars"."type" AS "ActorFollow.ActorFollower.Avatars.type",
162 "ActorFollow->ActorFollower->Avatars"."filename" AS "ActorFollow.ActorFollower.Avatars.filename",
163 "ActorFollow->ActorFollower->Server"."id" AS "ActorFollow.ActorFollower.Server.id",
164 "ActorFollow->ActorFollower->Server"."host" AS "ActorFollow.ActorFollower.Server.host",
165 "ActorFollow->ActorFollowing"."id" AS "ActorFollow.ActorFollowing.id",
166 "ActorFollow->ActorFollowing"."preferredUsername" AS "ActorFollow.ActorFollowing.preferredUsername",
167 "ActorFollow->ActorFollowing"."type" AS "ActorFollow.ActorFollowing.type",
168 "ActorFollow->ActorFollowing->VideoChannel"."id" AS "ActorFollow.ActorFollowing.VideoChannel.id",
169 "ActorFollow->ActorFollowing->VideoChannel"."name" AS "ActorFollow.ActorFollowing.VideoChannel.name",
170 "ActorFollow->ActorFollowing->Account"."id" AS "ActorFollow.ActorFollowing.Account.id",
171 "ActorFollow->ActorFollowing->Account"."name" AS "ActorFollow.ActorFollowing.Account.name",
172 "ActorFollow->ActorFollowing->Server"."id" AS "ActorFollow.ActorFollowing.Server.id",
173 "ActorFollow->ActorFollowing->Server"."host" AS "ActorFollow.ActorFollowing.Server.host",
174 "Account"."id" AS "Account.id",
175 "Account"."name" AS "Account.name",
176 "Account->Actor"."id" AS "Account.Actor.id",
177 "Account->Actor"."preferredUsername" AS "Account.Actor.preferredUsername",
178 "Account->Actor->Avatars"."id" AS "Account.Actor.Avatars.id",
179 "Account->Actor->Avatars"."width" AS "Account.Actor.Avatars.width",
180 "Account->Actor->Avatars"."type" AS "Account.Actor.Avatars.type",
181 "Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename",
182 "Account->Actor->Server"."id" AS "Account.Actor.Server.id",
183 "Account->Actor->Server"."host" AS "Account.Actor.Server.host",
184 "UserRegistration"."id" AS "UserRegistration.id",
185 "UserRegistration"."username" AS "UserRegistration.username"`
186 }
187
188 private getJoins () {
189 return `
190 LEFT JOIN (
191 "video" AS "Video"
192 INNER JOIN "videoChannel" AS "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id"
193 INNER JOIN "actor" AS "Video->VideoChannel->Actor" ON "Video->VideoChannel"."actorId" = "Video->VideoChannel->Actor"."id"
194 LEFT JOIN "actorImage" AS "Video->VideoChannel->Actor->Avatars"
195 ON "Video->VideoChannel->Actor"."id" = "Video->VideoChannel->Actor->Avatars"."actorId"
196 AND "Video->VideoChannel->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
197 LEFT JOIN "server" AS "Video->VideoChannel->Actor->Server"
198 ON "Video->VideoChannel->Actor"."serverId" = "Video->VideoChannel->Actor->Server"."id"
199 ) ON "UserNotificationModel"."videoId" = "Video"."id"
200
201 LEFT JOIN (
202 "videoComment" AS "VideoComment"
203 INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id"
204 INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id"
205 LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars"
206 ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId"
207 AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
208 LEFT JOIN "server" AS "VideoComment->Account->Actor->Server"
209 ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id"
210 INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id"
211 ) ON "UserNotificationModel"."commentId" = "VideoComment"."id"
212
213 LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id"
214 LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId"
215 LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id"
216 LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId"
217 LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
218 ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
219 LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
220 ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
221 LEFT JOIN (
222 "account" AS "Abuse->FlaggedAccount"
223 INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id"
224 LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars"
225 ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId"
226 AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
227 LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server"
228 ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id"
229 ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
230
231 LEFT JOIN (
232 "videoBlacklist" AS "VideoBlacklist"
233 INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
234 ) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
235
236 LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id"
237 LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
238
239 LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id"
240
241 LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id"
242
243 LEFT JOIN (
244 "actorFollow" AS "ActorFollow"
245 INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id"
246 INNER JOIN "account" AS "ActorFollow->ActorFollower->Account"
247 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId"
248 LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars"
249 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId"
250 AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR}
251 LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server"
252 ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id"
253 INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
254 LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
255 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId"
256 LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
257 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId"
258 LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server"
259 ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id"
260 ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
261
262 LEFT JOIN (
263 "account" AS "Account"
264 INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
265 LEFT JOIN "actorImage" AS "Account->Actor->Avatars"
266 ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId"
267 AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
268 LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"
269 ) ON "UserNotificationModel"."accountId" = "Account"."id"
270
271 LEFT JOIN "userRegistration" as "UserRegistration" ON "UserNotificationModel"."userRegistrationId" = "UserRegistration"."id"`
272 }
273}
diff --git a/server/models/user/user-notification-setting.ts b/server/models/user/user-notification-setting.ts
deleted file mode 100644
index 394494c0c..000000000
--- a/server/models/user/user-notification-setting.ts
+++ /dev/null
@@ -1,232 +0,0 @@
1import {
2 AfterDestroy,
3 AfterUpdate,
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 Default,
9 ForeignKey,
10 Is,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { TokensCache } from '@server/lib/auth/tokens-cache'
16import { MNotificationSettingFormattable } from '@server/types/models'
17import { AttributesOnly } from '@shared/typescript-utils'
18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
19import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
20import { throwIfNotValid } from '../shared'
21import { UserModel } from './user'
22
23@Table({
24 tableName: 'userNotificationSetting',
25 indexes: [
26 {
27 fields: [ 'userId' ],
28 unique: true
29 }
30 ]
31})
32export class UserNotificationSettingModel extends Model<Partial<AttributesOnly<UserNotificationSettingModel>>> {
33
34 @AllowNull(false)
35 @Default(null)
36 @Is(
37 'UserNotificationSettingNewVideoFromSubscription',
38 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newVideoFromSubscription')
39 )
40 @Column
41 newVideoFromSubscription: UserNotificationSettingValue
42
43 @AllowNull(false)
44 @Default(null)
45 @Is(
46 'UserNotificationSettingNewCommentOnMyVideo',
47 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newCommentOnMyVideo')
48 )
49 @Column
50 newCommentOnMyVideo: UserNotificationSettingValue
51
52 @AllowNull(false)
53 @Default(null)
54 @Is(
55 'UserNotificationSettingAbuseAsModerator',
56 value => throwIfNotValid(value, isUserNotificationSettingValid, 'abuseAsModerator')
57 )
58 @Column
59 abuseAsModerator: UserNotificationSettingValue
60
61 @AllowNull(false)
62 @Default(null)
63 @Is(
64 'UserNotificationSettingVideoAutoBlacklistAsModerator',
65 value => throwIfNotValid(value, isUserNotificationSettingValid, 'videoAutoBlacklistAsModerator')
66 )
67 @Column
68 videoAutoBlacklistAsModerator: UserNotificationSettingValue
69
70 @AllowNull(false)
71 @Default(null)
72 @Is(
73 'UserNotificationSettingBlacklistOnMyVideo',
74 value => throwIfNotValid(value, isUserNotificationSettingValid, 'blacklistOnMyVideo')
75 )
76 @Column
77 blacklistOnMyVideo: UserNotificationSettingValue
78
79 @AllowNull(false)
80 @Default(null)
81 @Is(
82 'UserNotificationSettingMyVideoPublished',
83 value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoPublished')
84 )
85 @Column
86 myVideoPublished: UserNotificationSettingValue
87
88 @AllowNull(false)
89 @Default(null)
90 @Is(
91 'UserNotificationSettingMyVideoImportFinished',
92 value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoImportFinished')
93 )
94 @Column
95 myVideoImportFinished: UserNotificationSettingValue
96
97 @AllowNull(false)
98 @Default(null)
99 @Is(
100 'UserNotificationSettingNewUserRegistration',
101 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newUserRegistration')
102 )
103 @Column
104 newUserRegistration: UserNotificationSettingValue
105
106 @AllowNull(false)
107 @Default(null)
108 @Is(
109 'UserNotificationSettingNewInstanceFollower',
110 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newInstanceFollower')
111 )
112 @Column
113 newInstanceFollower: UserNotificationSettingValue
114
115 @AllowNull(false)
116 @Default(null)
117 @Is(
118 'UserNotificationSettingNewInstanceFollower',
119 value => throwIfNotValid(value, isUserNotificationSettingValid, 'autoInstanceFollowing')
120 )
121 @Column
122 autoInstanceFollowing: UserNotificationSettingValue
123
124 @AllowNull(false)
125 @Default(null)
126 @Is(
127 'UserNotificationSettingNewFollow',
128 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newFollow')
129 )
130 @Column
131 newFollow: UserNotificationSettingValue
132
133 @AllowNull(false)
134 @Default(null)
135 @Is(
136 'UserNotificationSettingCommentMention',
137 value => throwIfNotValid(value, isUserNotificationSettingValid, 'commentMention')
138 )
139 @Column
140 commentMention: UserNotificationSettingValue
141
142 @AllowNull(false)
143 @Default(null)
144 @Is(
145 'UserNotificationSettingAbuseStateChange',
146 value => throwIfNotValid(value, isUserNotificationSettingValid, 'abuseStateChange')
147 )
148 @Column
149 abuseStateChange: UserNotificationSettingValue
150
151 @AllowNull(false)
152 @Default(null)
153 @Is(
154 'UserNotificationSettingAbuseNewMessage',
155 value => throwIfNotValid(value, isUserNotificationSettingValid, 'abuseNewMessage')
156 )
157 @Column
158 abuseNewMessage: UserNotificationSettingValue
159
160 @AllowNull(false)
161 @Default(null)
162 @Is(
163 'UserNotificationSettingNewPeerTubeVersion',
164 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newPeerTubeVersion')
165 )
166 @Column
167 newPeerTubeVersion: UserNotificationSettingValue
168
169 @AllowNull(false)
170 @Default(null)
171 @Is(
172 'UserNotificationSettingNewPeerPluginVersion',
173 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newPluginVersion')
174 )
175 @Column
176 newPluginVersion: UserNotificationSettingValue
177
178 @AllowNull(false)
179 @Default(null)
180 @Is(
181 'UserNotificationSettingMyVideoStudioEditionFinished',
182 value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoStudioEditionFinished')
183 )
184 @Column
185 myVideoStudioEditionFinished: UserNotificationSettingValue
186
187 @ForeignKey(() => UserModel)
188 @Column
189 userId: number
190
191 @BelongsTo(() => UserModel, {
192 foreignKey: {
193 allowNull: false
194 },
195 onDelete: 'cascade'
196 })
197 User: UserModel
198
199 @CreatedAt
200 createdAt: Date
201
202 @UpdatedAt
203 updatedAt: Date
204
205 @AfterUpdate
206 @AfterDestroy
207 static removeTokenCache (instance: UserNotificationSettingModel) {
208 return TokensCache.Instance.clearCacheByUserId(instance.userId)
209 }
210
211 toFormattedJSON (this: MNotificationSettingFormattable): UserNotificationSetting {
212 return {
213 newCommentOnMyVideo: this.newCommentOnMyVideo,
214 newVideoFromSubscription: this.newVideoFromSubscription,
215 abuseAsModerator: this.abuseAsModerator,
216 videoAutoBlacklistAsModerator: this.videoAutoBlacklistAsModerator,
217 blacklistOnMyVideo: this.blacklistOnMyVideo,
218 myVideoPublished: this.myVideoPublished,
219 myVideoImportFinished: this.myVideoImportFinished,
220 newUserRegistration: this.newUserRegistration,
221 commentMention: this.commentMention,
222 newFollow: this.newFollow,
223 newInstanceFollower: this.newInstanceFollower,
224 autoInstanceFollowing: this.autoInstanceFollowing,
225 abuseNewMessage: this.abuseNewMessage,
226 abuseStateChange: this.abuseStateChange,
227 newPeerTubeVersion: this.newPeerTubeVersion,
228 myVideoStudioEditionFinished: this.myVideoStudioEditionFinished,
229 newPluginVersion: this.newPluginVersion
230 }
231 }
232}
diff --git a/server/models/user/user-notification.ts b/server/models/user/user-notification.ts
deleted file mode 100644
index 667ee7f5f..000000000
--- a/server/models/user/user-notification.ts
+++ /dev/null
@@ -1,534 +0,0 @@
1import { ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { getBiggestActorImage } from '@server/lib/actor-image'
4import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user'
5import { forceNumber } from '@shared/core-utils'
6import { uuidToShort } from '@shared/extra-utils'
7import { UserNotification, UserNotificationType } from '@shared/models'
8import { AttributesOnly } from '@shared/typescript-utils'
9import { isBooleanValid } from '../../helpers/custom-validators/misc'
10import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
11import { AbuseModel } from '../abuse/abuse'
12import { AccountModel } from '../account/account'
13import { ActorFollowModel } from '../actor/actor-follow'
14import { ApplicationModel } from '../application/application'
15import { PluginModel } from '../server/plugin'
16import { throwIfNotValid } from '../shared'
17import { VideoModel } from '../video/video'
18import { VideoBlacklistModel } from '../video/video-blacklist'
19import { VideoCommentModel } from '../video/video-comment'
20import { VideoImportModel } from '../video/video-import'
21import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder'
22import { UserModel } from './user'
23import { UserRegistrationModel } from './user-registration'
24
25@Table({
26 tableName: 'userNotification',
27 indexes: [
28 {
29 fields: [ 'userId' ]
30 },
31 {
32 fields: [ 'videoId' ],
33 where: {
34 videoId: {
35 [Op.ne]: null
36 }
37 }
38 },
39 {
40 fields: [ 'commentId' ],
41 where: {
42 commentId: {
43 [Op.ne]: null
44 }
45 }
46 },
47 {
48 fields: [ 'abuseId' ],
49 where: {
50 abuseId: {
51 [Op.ne]: null
52 }
53 }
54 },
55 {
56 fields: [ 'videoBlacklistId' ],
57 where: {
58 videoBlacklistId: {
59 [Op.ne]: null
60 }
61 }
62 },
63 {
64 fields: [ 'videoImportId' ],
65 where: {
66 videoImportId: {
67 [Op.ne]: null
68 }
69 }
70 },
71 {
72 fields: [ 'accountId' ],
73 where: {
74 accountId: {
75 [Op.ne]: null
76 }
77 }
78 },
79 {
80 fields: [ 'actorFollowId' ],
81 where: {
82 actorFollowId: {
83 [Op.ne]: null
84 }
85 }
86 },
87 {
88 fields: [ 'pluginId' ],
89 where: {
90 pluginId: {
91 [Op.ne]: null
92 }
93 }
94 },
95 {
96 fields: [ 'applicationId' ],
97 where: {
98 applicationId: {
99 [Op.ne]: null
100 }
101 }
102 },
103 {
104 fields: [ 'userRegistrationId' ],
105 where: {
106 userRegistrationId: {
107 [Op.ne]: null
108 }
109 }
110 }
111 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
112})
113export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNotificationModel>>> {
114
115 @AllowNull(false)
116 @Default(null)
117 @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
118 @Column
119 type: UserNotificationType
120
121 @AllowNull(false)
122 @Default(false)
123 @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
124 @Column
125 read: boolean
126
127 @CreatedAt
128 createdAt: Date
129
130 @UpdatedAt
131 updatedAt: Date
132
133 @ForeignKey(() => UserModel)
134 @Column
135 userId: number
136
137 @BelongsTo(() => UserModel, {
138 foreignKey: {
139 allowNull: false
140 },
141 onDelete: 'cascade'
142 })
143 User: UserModel
144
145 @ForeignKey(() => VideoModel)
146 @Column
147 videoId: number
148
149 @BelongsTo(() => VideoModel, {
150 foreignKey: {
151 allowNull: true
152 },
153 onDelete: 'cascade'
154 })
155 Video: VideoModel
156
157 @ForeignKey(() => VideoCommentModel)
158 @Column
159 commentId: number
160
161 @BelongsTo(() => VideoCommentModel, {
162 foreignKey: {
163 allowNull: true
164 },
165 onDelete: 'cascade'
166 })
167 VideoComment: VideoCommentModel
168
169 @ForeignKey(() => AbuseModel)
170 @Column
171 abuseId: number
172
173 @BelongsTo(() => AbuseModel, {
174 foreignKey: {
175 allowNull: true
176 },
177 onDelete: 'cascade'
178 })
179 Abuse: AbuseModel
180
181 @ForeignKey(() => VideoBlacklistModel)
182 @Column
183 videoBlacklistId: number
184
185 @BelongsTo(() => VideoBlacklistModel, {
186 foreignKey: {
187 allowNull: true
188 },
189 onDelete: 'cascade'
190 })
191 VideoBlacklist: VideoBlacklistModel
192
193 @ForeignKey(() => VideoImportModel)
194 @Column
195 videoImportId: number
196
197 @BelongsTo(() => VideoImportModel, {
198 foreignKey: {
199 allowNull: true
200 },
201 onDelete: 'cascade'
202 })
203 VideoImport: VideoImportModel
204
205 @ForeignKey(() => AccountModel)
206 @Column
207 accountId: number
208
209 @BelongsTo(() => AccountModel, {
210 foreignKey: {
211 allowNull: true
212 },
213 onDelete: 'cascade'
214 })
215 Account: AccountModel
216
217 @ForeignKey(() => ActorFollowModel)
218 @Column
219 actorFollowId: number
220
221 @BelongsTo(() => ActorFollowModel, {
222 foreignKey: {
223 allowNull: true
224 },
225 onDelete: 'cascade'
226 })
227 ActorFollow: ActorFollowModel
228
229 @ForeignKey(() => PluginModel)
230 @Column
231 pluginId: number
232
233 @BelongsTo(() => PluginModel, {
234 foreignKey: {
235 allowNull: true
236 },
237 onDelete: 'cascade'
238 })
239 Plugin: PluginModel
240
241 @ForeignKey(() => ApplicationModel)
242 @Column
243 applicationId: number
244
245 @BelongsTo(() => ApplicationModel, {
246 foreignKey: {
247 allowNull: true
248 },
249 onDelete: 'cascade'
250 })
251 Application: ApplicationModel
252
253 @ForeignKey(() => UserRegistrationModel)
254 @Column
255 userRegistrationId: number
256
257 @BelongsTo(() => UserRegistrationModel, {
258 foreignKey: {
259 allowNull: true
260 },
261 onDelete: 'cascade'
262 })
263 UserRegistration: UserRegistrationModel
264
265 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
266 const where = { userId }
267
268 const query = {
269 userId,
270 unread,
271 offset: start,
272 limit: count,
273 sort,
274 where
275 }
276
277 if (unread !== undefined) query.where['read'] = !unread
278
279 return Promise.all([
280 UserNotificationModel.count({ where })
281 .then(count => count || 0),
282
283 count === 0
284 ? [] as UserNotificationModelForApi[]
285 : new UserNotificationListQueryBuilder(this.sequelize, query).listNotifications()
286 ]).then(([ total, data ]) => ({ total, data }))
287 }
288
289 static markAsRead (userId: number, notificationIds: number[]) {
290 const query = {
291 where: {
292 userId,
293 id: {
294 [Op.in]: notificationIds
295 }
296 }
297 }
298
299 return UserNotificationModel.update({ read: true }, query)
300 }
301
302 static markAllAsRead (userId: number) {
303 const query = { where: { userId } }
304
305 return UserNotificationModel.update({ read: true }, query)
306 }
307
308 static removeNotificationsOf (options: { id: number, type: 'account' | 'server', forUserId?: number }) {
309 const id = forceNumber(options.id)
310
311 function buildAccountWhereQuery (base: string) {
312 const whereSuffix = options.forUserId
313 ? ` AND "userNotification"."userId" = ${options.forUserId}`
314 : ''
315
316 if (options.type === 'account') {
317 return base +
318 ` WHERE "account"."id" = ${id} ${whereSuffix}`
319 }
320
321 return base +
322 ` WHERE "actor"."serverId" = ${id} ${whereSuffix}`
323 }
324
325 const queries = [
326 buildAccountWhereQuery(
327 `SELECT "userNotification"."id" FROM "userNotification" ` +
328 `INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` +
329 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
330 ),
331
332 // Remove notifications from muted accounts that followed ours
333 buildAccountWhereQuery(
334 `SELECT "userNotification"."id" FROM "userNotification" ` +
335 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
336 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
337 `INNER JOIN account ON account."actorId" = actor.id `
338 ),
339
340 // Remove notifications from muted accounts that commented something
341 buildAccountWhereQuery(
342 `SELECT "userNotification"."id" FROM "userNotification" ` +
343 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
344 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
345 `INNER JOIN account ON account."actorId" = actor.id `
346 ),
347
348 buildAccountWhereQuery(
349 `SELECT "userNotification"."id" FROM "userNotification" ` +
350 `INNER JOIN "videoComment" ON "videoComment".id = "userNotification"."commentId" ` +
351 `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
352 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
353 )
354 ]
355
356 const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})`
357
358 return UserNotificationModel.sequelize.query(query)
359 }
360
361 toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
362 const video = this.Video
363 ? {
364 ...this.formatVideo(this.Video),
365
366 channel: this.formatActor(this.Video.VideoChannel)
367 }
368 : undefined
369
370 const videoImport = this.VideoImport
371 ? {
372 id: this.VideoImport.id,
373 video: this.VideoImport.Video
374 ? this.formatVideo(this.VideoImport.Video)
375 : undefined,
376 torrentName: this.VideoImport.torrentName,
377 magnetUri: this.VideoImport.magnetUri,
378 targetUrl: this.VideoImport.targetUrl
379 }
380 : undefined
381
382 const comment = this.VideoComment
383 ? {
384 id: this.VideoComment.id,
385 threadId: this.VideoComment.getThreadId(),
386 account: this.formatActor(this.VideoComment.Account),
387 video: this.formatVideo(this.VideoComment.Video)
388 }
389 : undefined
390
391 const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
392
393 const videoBlacklist = this.VideoBlacklist
394 ? {
395 id: this.VideoBlacklist.id,
396 video: this.formatVideo(this.VideoBlacklist.Video)
397 }
398 : undefined
399
400 const account = this.Account ? this.formatActor(this.Account) : undefined
401
402 const actorFollowingType = {
403 Application: 'instance' as 'instance',
404 Group: 'channel' as 'channel',
405 Person: 'account' as 'account'
406 }
407 const actorFollow = this.ActorFollow
408 ? {
409 id: this.ActorFollow.id,
410 state: this.ActorFollow.state,
411 follower: {
412 id: this.ActorFollow.ActorFollower.Account.id,
413 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
414 name: this.ActorFollow.ActorFollower.preferredUsername,
415 host: this.ActorFollow.ActorFollower.getHost(),
416
417 ...this.formatAvatars(this.ActorFollow.ActorFollower.Avatars)
418 },
419 following: {
420 type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
421 displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
422 name: this.ActorFollow.ActorFollowing.preferredUsername,
423 host: this.ActorFollow.ActorFollowing.getHost()
424 }
425 }
426 : undefined
427
428 const plugin = this.Plugin
429 ? {
430 name: this.Plugin.name,
431 type: this.Plugin.type,
432 latestVersion: this.Plugin.latestVersion
433 }
434 : undefined
435
436 const peertube = this.Application
437 ? { latestVersion: this.Application.latestPeerTubeVersion }
438 : undefined
439
440 const registration = this.UserRegistration
441 ? { id: this.UserRegistration.id, username: this.UserRegistration.username }
442 : undefined
443
444 return {
445 id: this.id,
446 type: this.type,
447 read: this.read,
448 video,
449 videoImport,
450 comment,
451 abuse,
452 videoBlacklist,
453 account,
454 actorFollow,
455 plugin,
456 peertube,
457 registration,
458 createdAt: this.createdAt.toISOString(),
459 updatedAt: this.updatedAt.toISOString()
460 }
461 }
462
463 formatVideo (video: UserNotificationIncludes.VideoInclude) {
464 return {
465 id: video.id,
466 uuid: video.uuid,
467 shortUUID: uuidToShort(video.uuid),
468 name: video.name
469 }
470 }
471
472 formatAbuse (abuse: UserNotificationIncludes.AbuseInclude) {
473 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
474 ? {
475 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
476
477 video: abuse.VideoCommentAbuse.VideoComment.Video
478 ? {
479 id: abuse.VideoCommentAbuse.VideoComment.Video.id,
480 name: abuse.VideoCommentAbuse.VideoComment.Video.name,
481 shortUUID: uuidToShort(abuse.VideoCommentAbuse.VideoComment.Video.uuid),
482 uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
483 }
484 : undefined
485 }
486 : undefined
487
488 const videoAbuse = abuse.VideoAbuse?.Video
489 ? this.formatVideo(abuse.VideoAbuse.Video)
490 : undefined
491
492 const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount)
493 ? this.formatActor(abuse.FlaggedAccount)
494 : undefined
495
496 return {
497 id: abuse.id,
498 state: abuse.state,
499 video: videoAbuse,
500 comment: commentAbuse,
501 account: accountAbuse
502 }
503 }
504
505 formatActor (
506 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
507 ) {
508 return {
509 id: accountOrChannel.id,
510 displayName: accountOrChannel.getDisplayName(),
511 name: accountOrChannel.Actor.preferredUsername,
512 host: accountOrChannel.Actor.getHost(),
513
514 ...this.formatAvatars(accountOrChannel.Actor.Avatars)
515 }
516 }
517
518 formatAvatars (avatars: UserNotificationIncludes.ActorImageInclude[]) {
519 if (!avatars || avatars.length === 0) return { avatar: undefined, avatars: [] }
520
521 return {
522 avatar: this.formatAvatar(getBiggestActorImage(avatars)),
523
524 avatars: avatars.map(a => this.formatAvatar(a))
525 }
526 }
527
528 formatAvatar (a: UserNotificationIncludes.ActorImageInclude) {
529 return {
530 path: a.getStaticPath(),
531 width: a.width
532 }
533 }
534}
diff --git a/server/models/user/user-registration.ts b/server/models/user/user-registration.ts
deleted file mode 100644
index adda3cc7e..000000000
--- a/server/models/user/user-registration.ts
+++ /dev/null
@@ -1,259 +0,0 @@
1import { FindOptions, Op, WhereOptions } from 'sequelize'
2import {
3 AllowNull,
4 BeforeCreate,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 Is,
11 IsEmail,
12 Model,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import {
17 isRegistrationModerationResponseValid,
18 isRegistrationReasonValid,
19 isRegistrationStateValid
20} from '@server/helpers/custom-validators/user-registration'
21import { isVideoChannelDisplayNameValid } from '@server/helpers/custom-validators/video-channels'
22import { cryptPassword } from '@server/helpers/peertube-crypto'
23import { USER_REGISTRATION_STATES } from '@server/initializers/constants'
24import { MRegistration, MRegistrationFormattable } from '@server/types/models'
25import { UserRegistration, UserRegistrationState } from '@shared/models'
26import { AttributesOnly } from '@shared/typescript-utils'
27import { isUserDisplayNameValid, isUserEmailVerifiedValid, isUserPasswordValid } from '../../helpers/custom-validators/users'
28import { getSort, throwIfNotValid } from '../shared'
29import { UserModel } from './user'
30
31@Table({
32 tableName: 'userRegistration',
33 indexes: [
34 {
35 fields: [ 'username' ],
36 unique: true
37 },
38 {
39 fields: [ 'email' ],
40 unique: true
41 },
42 {
43 fields: [ 'channelHandle' ],
44 unique: true
45 },
46 {
47 fields: [ 'userId' ],
48 unique: true
49 }
50 ]
51})
52export class UserRegistrationModel extends Model<Partial<AttributesOnly<UserRegistrationModel>>> {
53
54 @AllowNull(false)
55 @Is('RegistrationState', value => throwIfNotValid(value, isRegistrationStateValid, 'state'))
56 @Column
57 state: UserRegistrationState
58
59 @AllowNull(false)
60 @Is('RegistrationReason', value => throwIfNotValid(value, isRegistrationReasonValid, 'registration reason'))
61 @Column(DataType.TEXT)
62 registrationReason: string
63
64 @AllowNull(true)
65 @Is('RegistrationModerationResponse', value => throwIfNotValid(value, isRegistrationModerationResponseValid, 'moderation response', true))
66 @Column(DataType.TEXT)
67 moderationResponse: string
68
69 @AllowNull(true)
70 @Is('RegistrationPassword', value => throwIfNotValid(value, isUserPasswordValid, 'registration password', true))
71 @Column
72 password: string
73
74 @AllowNull(false)
75 @Column
76 username: string
77
78 @AllowNull(false)
79 @IsEmail
80 @Column(DataType.STRING(400))
81 email: string
82
83 @AllowNull(true)
84 @Is('RegistrationEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
85 @Column
86 emailVerified: boolean
87
88 @AllowNull(true)
89 @Is('RegistrationAccountDisplayName', value => throwIfNotValid(value, isUserDisplayNameValid, 'account display name', true))
90 @Column
91 accountDisplayName: string
92
93 @AllowNull(true)
94 @Is('ChannelHandle', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel handle', true))
95 @Column
96 channelHandle: string
97
98 @AllowNull(true)
99 @Is('ChannelDisplayName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel display name', true))
100 @Column
101 channelDisplayName: string
102
103 @CreatedAt
104 createdAt: Date
105
106 @UpdatedAt
107 updatedAt: Date
108
109 @ForeignKey(() => UserModel)
110 @Column
111 userId: number
112
113 @BelongsTo(() => UserModel, {
114 foreignKey: {
115 allowNull: true
116 },
117 onDelete: 'SET NULL'
118 })
119 User: UserModel
120
121 @BeforeCreate
122 static async cryptPasswordIfNeeded (instance: UserRegistrationModel) {
123 instance.password = await cryptPassword(instance.password)
124 }
125
126 static load (id: number): Promise<MRegistration> {
127 return UserRegistrationModel.findByPk(id)
128 }
129
130 static loadByEmail (email: string): Promise<MRegistration> {
131 const query = {
132 where: { email }
133 }
134
135 return UserRegistrationModel.findOne(query)
136 }
137
138 static loadByEmailOrUsername (emailOrUsername: string): Promise<MRegistration> {
139 const query = {
140 where: {
141 [Op.or]: [
142 { email: emailOrUsername },
143 { username: emailOrUsername }
144 ]
145 }
146 }
147
148 return UserRegistrationModel.findOne(query)
149 }
150
151 static loadByEmailOrHandle (options: {
152 email: string
153 username: string
154 channelHandle?: string
155 }): Promise<MRegistration> {
156 const { email, username, channelHandle } = options
157
158 let or: WhereOptions = [
159 { email },
160 { channelHandle: username },
161 { username }
162 ]
163
164 if (channelHandle) {
165 or = or.concat([
166 { username: channelHandle },
167 { channelHandle }
168 ])
169 }
170
171 const query = {
172 where: {
173 [Op.or]: or
174 }
175 }
176
177 return UserRegistrationModel.findOne(query)
178 }
179
180 // ---------------------------------------------------------------------------
181
182 static listForApi (options: {
183 start: number
184 count: number
185 sort: string
186 search?: string
187 }) {
188 const { start, count, sort, search } = options
189
190 const where: WhereOptions = {}
191
192 if (search) {
193 Object.assign(where, {
194 [Op.or]: [
195 {
196 email: {
197 [Op.iLike]: '%' + search + '%'
198 }
199 },
200 {
201 username: {
202 [Op.iLike]: '%' + search + '%'
203 }
204 }
205 ]
206 })
207 }
208
209 const query: FindOptions = {
210 offset: start,
211 limit: count,
212 order: getSort(sort),
213 where,
214 include: [
215 {
216 model: UserModel.unscoped(),
217 required: false
218 }
219 ]
220 }
221
222 return Promise.all([
223 UserRegistrationModel.count(query),
224 UserRegistrationModel.findAll<MRegistrationFormattable>(query)
225 ]).then(([ total, data ]) => ({ total, data }))
226 }
227
228 // ---------------------------------------------------------------------------
229
230 toFormattedJSON (this: MRegistrationFormattable): UserRegistration {
231 return {
232 id: this.id,
233
234 state: {
235 id: this.state,
236 label: USER_REGISTRATION_STATES[this.state]
237 },
238
239 registrationReason: this.registrationReason,
240 moderationResponse: this.moderationResponse,
241
242 username: this.username,
243 email: this.email,
244 emailVerified: this.emailVerified,
245
246 accountDisplayName: this.accountDisplayName,
247
248 channelHandle: this.channelHandle,
249 channelDisplayName: this.channelDisplayName,
250
251 createdAt: this.createdAt,
252 updatedAt: this.updatedAt,
253
254 user: this.User
255 ? { id: this.User.id }
256 : null
257 }
258 }
259}
diff --git a/server/models/user/user-video-history.ts b/server/models/user/user-video-history.ts
deleted file mode 100644
index f4d0889a1..000000000
--- a/server/models/user/user-video-history.ts
+++ /dev/null
@@ -1,111 +0,0 @@
1import { DestroyOptions, Op, Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MUserAccountId, MUserId } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoModel } from '../video/video'
6import { UserModel } from './user'
7
8@Table({
9 tableName: 'userVideoHistory',
10 indexes: [
11 {
12 fields: [ 'userId', 'videoId' ],
13 unique: true
14 },
15 {
16 fields: [ 'userId' ]
17 },
18 {
19 fields: [ 'videoId' ]
20 }
21 ]
22})
23export class UserVideoHistoryModel extends Model<Partial<AttributesOnly<UserVideoHistoryModel>>> {
24 @CreatedAt
25 createdAt: Date
26
27 @UpdatedAt
28 updatedAt: Date
29
30 @AllowNull(false)
31 @IsInt
32 @Column
33 currentTime: number
34
35 @ForeignKey(() => VideoModel)
36 @Column
37 videoId: number
38
39 @BelongsTo(() => VideoModel, {
40 foreignKey: {
41 allowNull: false
42 },
43 onDelete: 'CASCADE'
44 })
45 Video: VideoModel
46
47 @ForeignKey(() => UserModel)
48 @Column
49 userId: number
50
51 @BelongsTo(() => UserModel, {
52 foreignKey: {
53 allowNull: false
54 },
55 onDelete: 'CASCADE'
56 })
57 User: UserModel
58
59 static listForApi (user: MUserAccountId, start: number, count: number, search?: string) {
60 return VideoModel.listForApi({
61 start,
62 count,
63 search,
64 sort: '-"userVideoHistory"."updatedAt"',
65 nsfw: null, // All
66 displayOnlyForFollower: null,
67 user,
68 historyOfUser: user
69 })
70 }
71
72 static removeUserHistoryElement (user: MUserId, videoId: number) {
73 const query: DestroyOptions = {
74 where: {
75 userId: user.id,
76 videoId
77 }
78 }
79
80 return UserVideoHistoryModel.destroy(query)
81 }
82
83 static removeUserHistoryBefore (user: MUserId, beforeDate: string, t: Transaction) {
84 const query: DestroyOptions = {
85 where: {
86 userId: user.id
87 },
88 transaction: t
89 }
90
91 if (beforeDate) {
92 query.where['updatedAt'] = {
93 [Op.lt]: beforeDate
94 }
95 }
96
97 return UserVideoHistoryModel.destroy(query)
98 }
99
100 static removeOldHistory (beforeDate: string) {
101 const query: DestroyOptions = {
102 where: {
103 updatedAt: {
104 [Op.lt]: beforeDate
105 }
106 }
107 }
108
109 return UserVideoHistoryModel.destroy(query)
110 }
111}
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
deleted file mode 100644
index ff6328d48..000000000
--- a/server/models/user/user.ts
+++ /dev/null
@@ -1,983 +0,0 @@
1import { col, FindOptions, fn, literal, Op, QueryTypes, where, WhereOptions } from 'sequelize'
2import {
3 AfterDestroy,
4 AfterUpdate,
5 AllowNull,
6 BeforeCreate,
7 BeforeUpdate,
8 Column,
9 CreatedAt,
10 DataType,
11 Default,
12 DefaultScope,
13 HasMany,
14 HasOne,
15 Is,
16 IsEmail,
17 IsUUID,
18 Model,
19 Scopes,
20 Table,
21 UpdatedAt
22} from 'sequelize-typescript'
23import { TokensCache } from '@server/lib/auth/tokens-cache'
24import { LiveQuotaStore } from '@server/lib/live'
25import {
26 MMyUserFormattable,
27 MUser,
28 MUserDefault,
29 MUserFormattable,
30 MUserNotifSettingChannelDefault,
31 MUserWithNotificationSetting
32} from '@server/types/models'
33import { forceNumber } from '@shared/core-utils'
34import { AttributesOnly } from '@shared/typescript-utils'
35import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users'
36import { AbuseState, MyUser, UserRight, VideoPlaylistType } from '../../../shared/models'
37import { User, UserRole } from '../../../shared/models/users'
38import { UserAdminFlag } from '../../../shared/models/users/user-flag.model'
39import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
40import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
41import {
42 isUserAdminFlagsValid,
43 isUserAutoPlayNextVideoPlaylistValid,
44 isUserAutoPlayNextVideoValid,
45 isUserAutoPlayVideoValid,
46 isUserBlockedReasonValid,
47 isUserBlockedValid,
48 isUserEmailVerifiedValid,
49 isUserNoModal,
50 isUserNSFWPolicyValid,
51 isUserP2PEnabledValid,
52 isUserPasswordValid,
53 isUserRoleValid,
54 isUserVideoLanguages,
55 isUserVideoQuotaDailyValid,
56 isUserVideoQuotaValid,
57 isUserVideosHistoryEnabledValid
58} from '../../helpers/custom-validators/users'
59import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto'
60import { DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants'
61import { getThemeOrDefault } from '../../lib/plugins/theme-utils'
62import { AccountModel } from '../account/account'
63import { ActorModel } from '../actor/actor'
64import { ActorFollowModel } from '../actor/actor-follow'
65import { ActorImageModel } from '../actor/actor-image'
66import { OAuthTokenModel } from '../oauth/oauth-token'
67import { getAdminUsersSort, throwIfNotValid } from '../shared'
68import { VideoModel } from '../video/video'
69import { VideoChannelModel } from '../video/video-channel'
70import { VideoImportModel } from '../video/video-import'
71import { VideoLiveModel } from '../video/video-live'
72import { VideoPlaylistModel } from '../video/video-playlist'
73import { UserNotificationSettingModel } from './user-notification-setting'
74
75enum ScopeNames {
76 FOR_ME_API = 'FOR_ME_API',
77 WITH_VIDEOCHANNELS = 'WITH_VIDEOCHANNELS',
78 WITH_QUOTA = 'WITH_QUOTA',
79 WITH_STATS = 'WITH_STATS'
80}
81
82@DefaultScope(() => ({
83 include: [
84 {
85 model: AccountModel,
86 required: true
87 },
88 {
89 model: UserNotificationSettingModel,
90 required: true
91 }
92 ]
93}))
94@Scopes(() => ({
95 [ScopeNames.FOR_ME_API]: {
96 include: [
97 {
98 model: AccountModel,
99 include: [
100 {
101 model: VideoChannelModel.unscoped(),
102 include: [
103 {
104 model: ActorModel,
105 required: true,
106 include: [
107 {
108 model: ActorImageModel,
109 as: 'Banners',
110 required: false
111 }
112 ]
113 }
114 ]
115 },
116 {
117 attributes: [ 'id', 'name', 'type' ],
118 model: VideoPlaylistModel.unscoped(),
119 required: true,
120 where: {
121 type: {
122 [Op.ne]: VideoPlaylistType.REGULAR
123 }
124 }
125 }
126 ]
127 },
128 {
129 model: UserNotificationSettingModel,
130 required: true
131 }
132 ]
133 },
134 [ScopeNames.WITH_VIDEOCHANNELS]: {
135 include: [
136 {
137 model: AccountModel,
138 include: [
139 {
140 model: VideoChannelModel
141 },
142 {
143 attributes: [ 'id', 'name', 'type' ],
144 model: VideoPlaylistModel.unscoped(),
145 required: true,
146 where: {
147 type: {
148 [Op.ne]: VideoPlaylistType.REGULAR
149 }
150 }
151 }
152 ]
153 }
154 ]
155 },
156 [ScopeNames.WITH_QUOTA]: {
157 attributes: {
158 include: [
159 [
160 literal(
161 '(' +
162 UserModel.generateUserQuotaBaseSQL({
163 withSelect: false,
164 whereUserId: '"UserModel"."id"',
165 daily: false
166 }) +
167 ')'
168 ),
169 'videoQuotaUsed'
170 ],
171 [
172 literal(
173 '(' +
174 UserModel.generateUserQuotaBaseSQL({
175 withSelect: false,
176 whereUserId: '"UserModel"."id"',
177 daily: true
178 }) +
179 ')'
180 ),
181 'videoQuotaUsedDaily'
182 ]
183 ]
184 }
185 },
186 [ScopeNames.WITH_STATS]: {
187 attributes: {
188 include: [
189 [
190 literal(
191 '(' +
192 'SELECT COUNT("video"."id") ' +
193 'FROM "video" ' +
194 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
195 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
196 'WHERE "account"."userId" = "UserModel"."id"' +
197 ')'
198 ),
199 'videosCount'
200 ],
201 [
202 literal(
203 '(' +
204 `SELECT concat_ws(':', "abuses", "acceptedAbuses") ` +
205 'FROM (' +
206 'SELECT COUNT("abuse"."id") AS "abuses", ' +
207 `COUNT("abuse"."id") FILTER (WHERE "abuse"."state" = ${AbuseState.ACCEPTED}) AS "acceptedAbuses" ` +
208 'FROM "abuse" ' +
209 'INNER JOIN "account" ON "account"."id" = "abuse"."flaggedAccountId" ' +
210 'WHERE "account"."userId" = "UserModel"."id"' +
211 ') t' +
212 ')'
213 ),
214 'abusesCount'
215 ],
216 [
217 literal(
218 '(' +
219 'SELECT COUNT("abuse"."id") ' +
220 'FROM "abuse" ' +
221 'INNER JOIN "account" ON "account"."id" = "abuse"."reporterAccountId" ' +
222 'WHERE "account"."userId" = "UserModel"."id"' +
223 ')'
224 ),
225 'abusesCreatedCount'
226 ],
227 [
228 literal(
229 '(' +
230 'SELECT COUNT("videoComment"."id") ' +
231 'FROM "videoComment" ' +
232 'INNER JOIN "account" ON "account"."id" = "videoComment"."accountId" ' +
233 'WHERE "account"."userId" = "UserModel"."id"' +
234 ')'
235 ),
236 'videoCommentsCount'
237 ]
238 ]
239 }
240 }
241}))
242@Table({
243 tableName: 'user',
244 indexes: [
245 {
246 fields: [ 'username' ],
247 unique: true
248 },
249 {
250 fields: [ 'email' ],
251 unique: true
252 }
253 ]
254})
255export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
256
257 @AllowNull(true)
258 @Is('UserPassword', value => throwIfNotValid(value, isUserPasswordValid, 'user password', true))
259 @Column
260 password: string
261
262 @AllowNull(false)
263 @Column
264 username: string
265
266 @AllowNull(false)
267 @IsEmail
268 @Column(DataType.STRING(400))
269 email: string
270
271 @AllowNull(true)
272 @IsEmail
273 @Column(DataType.STRING(400))
274 pendingEmail: string
275
276 @AllowNull(true)
277 @Default(null)
278 @Is('UserEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
279 @Column
280 emailVerified: boolean
281
282 @AllowNull(false)
283 @Is('UserNSFWPolicy', value => throwIfNotValid(value, isUserNSFWPolicyValid, 'NSFW policy'))
284 @Column(DataType.ENUM(...Object.values(NSFW_POLICY_TYPES)))
285 nsfwPolicy: NSFWPolicyType
286
287 @AllowNull(false)
288 @Is('p2pEnabled', value => throwIfNotValid(value, isUserP2PEnabledValid, 'P2P enabled'))
289 @Column
290 p2pEnabled: boolean
291
292 @AllowNull(false)
293 @Default(true)
294 @Is('UserVideosHistoryEnabled', value => throwIfNotValid(value, isUserVideosHistoryEnabledValid, 'Videos history enabled'))
295 @Column
296 videosHistoryEnabled: boolean
297
298 @AllowNull(false)
299 @Default(true)
300 @Is('UserAutoPlayVideo', value => throwIfNotValid(value, isUserAutoPlayVideoValid, 'auto play video boolean'))
301 @Column
302 autoPlayVideo: boolean
303
304 @AllowNull(false)
305 @Default(false)
306 @Is('UserAutoPlayNextVideo', value => throwIfNotValid(value, isUserAutoPlayNextVideoValid, 'auto play next video boolean'))
307 @Column
308 autoPlayNextVideo: boolean
309
310 @AllowNull(false)
311 @Default(true)
312 @Is(
313 'UserAutoPlayNextVideoPlaylist',
314 value => throwIfNotValid(value, isUserAutoPlayNextVideoPlaylistValid, 'auto play next video for playlists boolean')
315 )
316 @Column
317 autoPlayNextVideoPlaylist: boolean
318
319 @AllowNull(true)
320 @Default(null)
321 @Is('UserVideoLanguages', value => throwIfNotValid(value, isUserVideoLanguages, 'video languages'))
322 @Column(DataType.ARRAY(DataType.STRING))
323 videoLanguages: string[]
324
325 @AllowNull(false)
326 @Default(UserAdminFlag.NONE)
327 @Is('UserAdminFlags', value => throwIfNotValid(value, isUserAdminFlagsValid, 'user admin flags'))
328 @Column
329 adminFlags?: UserAdminFlag
330
331 @AllowNull(false)
332 @Default(false)
333 @Is('UserBlocked', value => throwIfNotValid(value, isUserBlockedValid, 'blocked boolean'))
334 @Column
335 blocked: boolean
336
337 @AllowNull(true)
338 @Default(null)
339 @Is('UserBlockedReason', value => throwIfNotValid(value, isUserBlockedReasonValid, 'blocked reason', true))
340 @Column
341 blockedReason: string
342
343 @AllowNull(false)
344 @Is('UserRole', value => throwIfNotValid(value, isUserRoleValid, 'role'))
345 @Column
346 role: number
347
348 @AllowNull(false)
349 @Is('UserVideoQuota', value => throwIfNotValid(value, isUserVideoQuotaValid, 'video quota'))
350 @Column(DataType.BIGINT)
351 videoQuota: number
352
353 @AllowNull(false)
354 @Is('UserVideoQuotaDaily', value => throwIfNotValid(value, isUserVideoQuotaDailyValid, 'video quota daily'))
355 @Column(DataType.BIGINT)
356 videoQuotaDaily: number
357
358 @AllowNull(false)
359 @Default(DEFAULT_USER_THEME_NAME)
360 @Is('UserTheme', value => throwIfNotValid(value, isThemeNameValid, 'theme'))
361 @Column
362 theme: string
363
364 @AllowNull(false)
365 @Default(false)
366 @Is(
367 'UserNoInstanceConfigWarningModal',
368 value => throwIfNotValid(value, isUserNoModal, 'no instance config warning modal')
369 )
370 @Column
371 noInstanceConfigWarningModal: boolean
372
373 @AllowNull(false)
374 @Default(false)
375 @Is(
376 'UserNoWelcomeModal',
377 value => throwIfNotValid(value, isUserNoModal, 'no welcome modal')
378 )
379 @Column
380 noWelcomeModal: boolean
381
382 @AllowNull(false)
383 @Default(false)
384 @Is(
385 'UserNoAccountSetupWarningModal',
386 value => throwIfNotValid(value, isUserNoModal, 'no account setup warning modal')
387 )
388 @Column
389 noAccountSetupWarningModal: boolean
390
391 @AllowNull(true)
392 @Default(null)
393 @Column
394 pluginAuth: string
395
396 @AllowNull(false)
397 @Default(DataType.UUIDV4)
398 @IsUUID(4)
399 @Column(DataType.UUID)
400 feedToken: string
401
402 @AllowNull(true)
403 @Default(null)
404 @Column
405 lastLoginDate: Date
406
407 @AllowNull(false)
408 @Default(false)
409 @Column
410 emailPublic: boolean
411
412 @AllowNull(true)
413 @Default(null)
414 @Column
415 otpSecret: string
416
417 @CreatedAt
418 createdAt: Date
419
420 @UpdatedAt
421 updatedAt: Date
422
423 @HasOne(() => AccountModel, {
424 foreignKey: 'userId',
425 onDelete: 'cascade',
426 hooks: true
427 })
428 Account: AccountModel
429
430 @HasOne(() => UserNotificationSettingModel, {
431 foreignKey: 'userId',
432 onDelete: 'cascade',
433 hooks: true
434 })
435 NotificationSetting: UserNotificationSettingModel
436
437 @HasMany(() => VideoImportModel, {
438 foreignKey: 'userId',
439 onDelete: 'cascade'
440 })
441 VideoImports: VideoImportModel[]
442
443 @HasMany(() => OAuthTokenModel, {
444 foreignKey: 'userId',
445 onDelete: 'cascade'
446 })
447 OAuthTokens: OAuthTokenModel[]
448
449 // Used if we already set an encrypted password in user model
450 skipPasswordEncryption = false
451
452 @BeforeCreate
453 @BeforeUpdate
454 static async cryptPasswordIfNeeded (instance: UserModel) {
455 if (instance.skipPasswordEncryption) return
456 if (!instance.changed('password')) return
457 if (!instance.password) return
458
459 instance.password = await cryptPassword(instance.password)
460 }
461
462 @AfterUpdate
463 @AfterDestroy
464 static removeTokenCache (instance: UserModel) {
465 return TokensCache.Instance.clearCacheByUserId(instance.id)
466 }
467
468 static countTotal () {
469 return UserModel.unscoped().count()
470 }
471
472 static listForAdminApi (parameters: {
473 start: number
474 count: number
475 sort: string
476 search?: string
477 blocked?: boolean
478 }) {
479 const { start, count, sort, search, blocked } = parameters
480 const where: WhereOptions = {}
481
482 if (search) {
483 Object.assign(where, {
484 [Op.or]: [
485 {
486 email: {
487 [Op.iLike]: '%' + search + '%'
488 }
489 },
490 {
491 username: {
492 [Op.iLike]: '%' + search + '%'
493 }
494 }
495 ]
496 })
497 }
498
499 if (blocked !== undefined) {
500 Object.assign(where, { blocked })
501 }
502
503 const query: FindOptions = {
504 offset: start,
505 limit: count,
506 order: getAdminUsersSort(sort),
507 where
508 }
509
510 return Promise.all([
511 UserModel.unscoped().count(query),
512 UserModel.scope([ 'defaultScope', ScopeNames.WITH_QUOTA ]).findAll(query)
513 ]).then(([ total, data ]) => ({ total, data }))
514 }
515
516 static listWithRight (right: UserRight): Promise<MUserDefault[]> {
517 const roles = Object.keys(USER_ROLE_LABELS)
518 .map(k => parseInt(k, 10) as UserRole)
519 .filter(role => hasUserRight(role, right))
520
521 const query = {
522 where: {
523 role: {
524 [Op.in]: roles
525 }
526 }
527 }
528
529 return UserModel.findAll(query)
530 }
531
532 static listUserSubscribersOf (actorId: number): Promise<MUserWithNotificationSetting[]> {
533 const query = {
534 include: [
535 {
536 model: UserNotificationSettingModel.unscoped(),
537 required: true
538 },
539 {
540 attributes: [ 'userId' ],
541 model: AccountModel.unscoped(),
542 required: true,
543 include: [
544 {
545 attributes: [],
546 model: ActorModel.unscoped(),
547 required: true,
548 where: {
549 serverId: null
550 },
551 include: [
552 {
553 attributes: [],
554 as: 'ActorFollowings',
555 model: ActorFollowModel.unscoped(),
556 required: true,
557 where: {
558 state: 'accepted',
559 targetActorId: actorId
560 }
561 }
562 ]
563 }
564 ]
565 }
566 ]
567 }
568
569 return UserModel.unscoped().findAll(query)
570 }
571
572 static listByUsernames (usernames: string[]): Promise<MUserDefault[]> {
573 const query = {
574 where: {
575 username: usernames
576 }
577 }
578
579 return UserModel.findAll(query)
580 }
581
582 static loadById (id: number): Promise<MUser> {
583 return UserModel.unscoped().findByPk(id)
584 }
585
586 static loadByIdFull (id: number): Promise<MUserDefault> {
587 return UserModel.findByPk(id)
588 }
589
590 static loadByIdWithChannels (id: number, withStats = false): Promise<MUserDefault> {
591 const scopes = [
592 ScopeNames.WITH_VIDEOCHANNELS
593 ]
594
595 if (withStats) {
596 scopes.push(ScopeNames.WITH_QUOTA)
597 scopes.push(ScopeNames.WITH_STATS)
598 }
599
600 return UserModel.scope(scopes).findByPk(id)
601 }
602
603 static loadByUsername (username: string): Promise<MUserDefault> {
604 const query = {
605 where: {
606 username
607 }
608 }
609
610 return UserModel.findOne(query)
611 }
612
613 static loadForMeAPI (id: number): Promise<MUserNotifSettingChannelDefault> {
614 const query = {
615 where: {
616 id
617 }
618 }
619
620 return UserModel.scope(ScopeNames.FOR_ME_API).findOne(query)
621 }
622
623 static loadByEmail (email: string): Promise<MUserDefault> {
624 const query = {
625 where: {
626 email
627 }
628 }
629
630 return UserModel.findOne(query)
631 }
632
633 static loadByUsernameOrEmail (username: string, email?: string): Promise<MUserDefault> {
634 if (!email) email = username
635
636 const query = {
637 where: {
638 [Op.or]: [
639 where(fn('lower', col('username')), fn('lower', username) as any),
640
641 { email }
642 ]
643 }
644 }
645
646 return UserModel.findOne(query)
647 }
648
649 static loadByVideoId (videoId: number): Promise<MUserDefault> {
650 const query = {
651 include: [
652 {
653 required: true,
654 attributes: [ 'id' ],
655 model: AccountModel.unscoped(),
656 include: [
657 {
658 required: true,
659 attributes: [ 'id' ],
660 model: VideoChannelModel.unscoped(),
661 include: [
662 {
663 required: true,
664 attributes: [ 'id' ],
665 model: VideoModel.unscoped(),
666 where: {
667 id: videoId
668 }
669 }
670 ]
671 }
672 ]
673 }
674 ]
675 }
676
677 return UserModel.findOne(query)
678 }
679
680 static loadByVideoImportId (videoImportId: number): Promise<MUserDefault> {
681 const query = {
682 include: [
683 {
684 required: true,
685 attributes: [ 'id' ],
686 model: VideoImportModel.unscoped(),
687 where: {
688 id: videoImportId
689 }
690 }
691 ]
692 }
693
694 return UserModel.findOne(query)
695 }
696
697 static loadByChannelActorId (videoChannelActorId: number): Promise<MUserDefault> {
698 const query = {
699 include: [
700 {
701 required: true,
702 attributes: [ 'id' ],
703 model: AccountModel.unscoped(),
704 include: [
705 {
706 required: true,
707 attributes: [ 'id' ],
708 model: VideoChannelModel.unscoped(),
709 where: {
710 actorId: videoChannelActorId
711 }
712 }
713 ]
714 }
715 ]
716 }
717
718 return UserModel.findOne(query)
719 }
720
721 static loadByAccountActorId (accountActorId: number): Promise<MUserDefault> {
722 const query = {
723 include: [
724 {
725 required: true,
726 attributes: [ 'id' ],
727 model: AccountModel.unscoped(),
728 where: {
729 actorId: accountActorId
730 }
731 }
732 ]
733 }
734
735 return UserModel.findOne(query)
736 }
737
738 static loadByLiveId (liveId: number): Promise<MUser> {
739 const query = {
740 include: [
741 {
742 attributes: [ 'id' ],
743 model: AccountModel.unscoped(),
744 required: true,
745 include: [
746 {
747 attributes: [ 'id' ],
748 model: VideoChannelModel.unscoped(),
749 required: true,
750 include: [
751 {
752 attributes: [ 'id' ],
753 model: VideoModel.unscoped(),
754 required: true,
755 include: [
756 {
757 attributes: [],
758 model: VideoLiveModel.unscoped(),
759 required: true,
760 where: {
761 id: liveId
762 }
763 }
764 ]
765 }
766 ]
767 }
768 ]
769 }
770 ]
771 }
772
773 return UserModel.unscoped().findOne(query)
774 }
775
776 static generateUserQuotaBaseSQL (options: {
777 whereUserId: '$userId' | '"UserModel"."id"'
778 withSelect: boolean
779 daily: boolean
780 }) {
781 const andWhere = options.daily === true
782 ? 'AND "video"."createdAt" > now() - interval \'24 hours\''
783 : ''
784
785 const videoChannelJoin = 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
786 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
787 `WHERE "account"."userId" = ${options.whereUserId} ${andWhere}`
788
789 const webVideoFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
790 'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" AND "video"."isLive" IS FALSE ' +
791 videoChannelJoin
792
793 const hlsFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
794 'INNER JOIN "videoStreamingPlaylist" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id ' +
795 'INNER JOIN "video" ON "videoStreamingPlaylist"."videoId" = "video"."id" AND "video"."isLive" IS FALSE ' +
796 videoChannelJoin
797
798 return 'SELECT COALESCE(SUM("size"), 0) AS "total" ' +
799 'FROM (' +
800 `SELECT MAX("t1"."size") AS "size" FROM (${webVideoFiles} UNION ${hlsFiles}) t1 ` +
801 'GROUP BY "t1"."videoId"' +
802 ') t2'
803 }
804
805 static getTotalRawQuery (query: string, userId: number) {
806 const options = {
807 bind: { userId },
808 type: QueryTypes.SELECT as QueryTypes.SELECT
809 }
810
811 return UserModel.sequelize.query<{ total: string }>(query, options)
812 .then(([ { total } ]) => {
813 if (total === null) return 0
814
815 return parseInt(total, 10)
816 })
817 }
818
819 static async getStats () {
820 function getActiveUsers (days: number) {
821 const query = {
822 where: {
823 [Op.and]: [
824 literal(`"lastLoginDate" > NOW() - INTERVAL '${days}d'`)
825 ]
826 }
827 }
828
829 return UserModel.unscoped().count(query)
830 }
831
832 const totalUsers = await UserModel.unscoped().count()
833 const totalDailyActiveUsers = await getActiveUsers(1)
834 const totalWeeklyActiveUsers = await getActiveUsers(7)
835 const totalMonthlyActiveUsers = await getActiveUsers(30)
836 const totalHalfYearActiveUsers = await getActiveUsers(180)
837
838 return {
839 totalUsers,
840 totalDailyActiveUsers,
841 totalWeeklyActiveUsers,
842 totalMonthlyActiveUsers,
843 totalHalfYearActiveUsers
844 }
845 }
846
847 static autoComplete (search: string) {
848 const query = {
849 where: {
850 username: {
851 [Op.like]: `%${search}%`
852 }
853 },
854 limit: 10
855 }
856
857 return UserModel.findAll(query)
858 .then(u => u.map(u => u.username))
859 }
860
861 hasRight (right: UserRight) {
862 return hasUserRight(this.role, right)
863 }
864
865 hasAdminFlag (flag: UserAdminFlag) {
866 return this.adminFlags & flag
867 }
868
869 isPasswordMatch (password: string) {
870 return comparePassword(password, this.password)
871 }
872
873 toFormattedJSON (this: MUserFormattable, parameters: { withAdminFlags?: boolean } = {}): User {
874 const videoQuotaUsed = this.get('videoQuotaUsed')
875 const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily')
876 const videosCount = this.get('videosCount')
877 const [ abusesCount, abusesAcceptedCount ] = (this.get('abusesCount') as string || ':').split(':')
878 const abusesCreatedCount = this.get('abusesCreatedCount')
879 const videoCommentsCount = this.get('videoCommentsCount')
880
881 const json: User = {
882 id: this.id,
883 username: this.username,
884 email: this.email,
885 theme: getThemeOrDefault(this.theme, DEFAULT_USER_THEME_NAME),
886
887 pendingEmail: this.pendingEmail,
888 emailPublic: this.emailPublic,
889 emailVerified: this.emailVerified,
890
891 nsfwPolicy: this.nsfwPolicy,
892
893 p2pEnabled: this.p2pEnabled,
894
895 videosHistoryEnabled: this.videosHistoryEnabled,
896 autoPlayVideo: this.autoPlayVideo,
897 autoPlayNextVideo: this.autoPlayNextVideo,
898 autoPlayNextVideoPlaylist: this.autoPlayNextVideoPlaylist,
899 videoLanguages: this.videoLanguages,
900
901 role: {
902 id: this.role,
903 label: USER_ROLE_LABELS[this.role]
904 },
905
906 videoQuota: this.videoQuota,
907 videoQuotaDaily: this.videoQuotaDaily,
908
909 videoQuotaUsed: videoQuotaUsed !== undefined
910 ? forceNumber(videoQuotaUsed) + LiveQuotaStore.Instance.getLiveQuotaOf(this.id)
911 : undefined,
912
913 videoQuotaUsedDaily: videoQuotaUsedDaily !== undefined
914 ? forceNumber(videoQuotaUsedDaily) + LiveQuotaStore.Instance.getLiveQuotaOf(this.id)
915 : undefined,
916
917 videosCount: videosCount !== undefined
918 ? forceNumber(videosCount)
919 : undefined,
920 abusesCount: abusesCount
921 ? forceNumber(abusesCount)
922 : undefined,
923 abusesAcceptedCount: abusesAcceptedCount
924 ? forceNumber(abusesAcceptedCount)
925 : undefined,
926 abusesCreatedCount: abusesCreatedCount !== undefined
927 ? forceNumber(abusesCreatedCount)
928 : undefined,
929 videoCommentsCount: videoCommentsCount !== undefined
930 ? forceNumber(videoCommentsCount)
931 : undefined,
932
933 noInstanceConfigWarningModal: this.noInstanceConfigWarningModal,
934 noWelcomeModal: this.noWelcomeModal,
935 noAccountSetupWarningModal: this.noAccountSetupWarningModal,
936
937 blocked: this.blocked,
938 blockedReason: this.blockedReason,
939
940 account: this.Account.toFormattedJSON(),
941
942 notificationSettings: this.NotificationSetting
943 ? this.NotificationSetting.toFormattedJSON()
944 : undefined,
945
946 videoChannels: [],
947
948 createdAt: this.createdAt,
949
950 pluginAuth: this.pluginAuth,
951
952 lastLoginDate: this.lastLoginDate,
953
954 twoFactorEnabled: !!this.otpSecret
955 }
956
957 if (parameters.withAdminFlags) {
958 Object.assign(json, { adminFlags: this.adminFlags })
959 }
960
961 if (Array.isArray(this.Account.VideoChannels) === true) {
962 json.videoChannels = this.Account.VideoChannels
963 .map(c => c.toFormattedJSON())
964 .sort((v1, v2) => {
965 if (v1.createdAt < v2.createdAt) return -1
966 if (v1.createdAt === v2.createdAt) return 0
967
968 return 1
969 })
970 }
971
972 return json
973 }
974
975 toMeFormattedJSON (this: MMyUserFormattable): MyUser {
976 const formatted = this.toFormattedJSON({ withAdminFlags: true })
977
978 const specialPlaylists = this.Account.VideoPlaylists
979 .map(p => ({ id: p.id, name: p.name, type: p.type }))
980
981 return Object.assign(formatted, { specialPlaylists })
982 }
983}
diff --git a/server/models/video/formatter/index.ts b/server/models/video/formatter/index.ts
deleted file mode 100644
index 77b406559..000000000
--- a/server/models/video/formatter/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
1export * from './video-activity-pub-format'
2export * from './video-api-format'
diff --git a/server/models/video/formatter/shared/index.ts b/server/models/video/formatter/shared/index.ts
deleted file mode 100644
index d558fa7d6..000000000
--- a/server/models/video/formatter/shared/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './video-format-utils'
diff --git a/server/models/video/formatter/shared/video-format-utils.ts b/server/models/video/formatter/shared/video-format-utils.ts
deleted file mode 100644
index df3bbdf1c..000000000
--- a/server/models/video/formatter/shared/video-format-utils.ts
+++ /dev/null
@@ -1,7 +0,0 @@
1import { MVideoFile } from '@server/types/models'
2
3export function sortByResolutionDesc (fileA: MVideoFile, fileB: MVideoFile) {
4 if (fileA.resolution < fileB.resolution) return 1
5 if (fileA.resolution === fileB.resolution) return 0
6 return -1
7}
diff --git a/server/models/video/formatter/video-activity-pub-format.ts b/server/models/video/formatter/video-activity-pub-format.ts
deleted file mode 100644
index 694c66c33..000000000
--- a/server/models/video/formatter/video-activity-pub-format.ts
+++ /dev/null
@@ -1,296 +0,0 @@
1import { isArray } from '@server/helpers/custom-validators/misc'
2import { generateMagnetUri } from '@server/helpers/webtorrent'
3import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
4import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
5import {
6 ActivityIconObject,
7 ActivityPlaylistUrlObject,
8 ActivityPubStoryboard,
9 ActivityTagObject,
10 ActivityTrackerUrlObject,
11 ActivityUrlObject,
12 VideoObject
13} from '@shared/models'
14import { MIMETYPES, WEBSERVER } from '../../../initializers/constants'
15import {
16 getLocalVideoCommentsActivityPubUrl,
17 getLocalVideoDislikesActivityPubUrl,
18 getLocalVideoLikesActivityPubUrl,
19 getLocalVideoSharesActivityPubUrl
20} from '../../../lib/activitypub/url'
21import { MStreamingPlaylistFiles, MUserId, MVideo, MVideoAP, MVideoFile } from '../../../types/models'
22import { VideoCaptionModel } from '../video-caption'
23import { sortByResolutionDesc } from './shared'
24import { getCategoryLabel, getLanguageLabel, getLicenceLabel } from './video-api-format'
25
26export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
27 const language = video.language
28 ? { identifier: video.language, name: getLanguageLabel(video.language) }
29 : undefined
30
31 const category = video.category
32 ? { identifier: video.category + '', name: getCategoryLabel(video.category) }
33 : undefined
34
35 const licence = video.licence
36 ? { identifier: video.licence + '', name: getLicenceLabel(video.licence) }
37 : undefined
38
39 const url: ActivityUrlObject[] = [
40 // HTML url should be the first element in the array so Mastodon correctly displays the embed
41 {
42 type: 'Link',
43 mediaType: 'text/html',
44 href: WEBSERVER.URL + '/videos/watch/' + video.uuid
45 } as ActivityUrlObject,
46
47 ...buildVideoFileUrls({ video, files: video.VideoFiles }),
48
49 ...buildStreamingPlaylistUrls(video),
50
51 ...buildTrackerUrls(video)
52 ]
53
54 return {
55 type: 'Video' as 'Video',
56 id: video.url,
57 name: video.name,
58 duration: getActivityStreamDuration(video.duration),
59 uuid: video.uuid,
60 category,
61 licence,
62 language,
63 views: video.views,
64 sensitive: video.nsfw,
65 waitTranscoding: video.waitTranscoding,
66
67 state: video.state,
68 commentsEnabled: video.commentsEnabled,
69 downloadEnabled: video.downloadEnabled,
70 published: video.publishedAt.toISOString(),
71
72 originallyPublishedAt: video.originallyPublishedAt
73 ? video.originallyPublishedAt.toISOString()
74 : null,
75
76 updated: video.updatedAt.toISOString(),
77
78 uploadDate: video.inputFileUpdatedAt?.toISOString(),
79
80 tag: buildTags(video),
81
82 mediaType: 'text/markdown',
83 content: video.description,
84 support: video.support,
85
86 subtitleLanguage: buildSubtitleLanguage(video),
87
88 icon: buildIcon(video),
89
90 preview: buildPreviewAPAttribute(video),
91
92 url,
93
94 likes: getLocalVideoLikesActivityPubUrl(video),
95 dislikes: getLocalVideoDislikesActivityPubUrl(video),
96 shares: getLocalVideoSharesActivityPubUrl(video),
97 comments: getLocalVideoCommentsActivityPubUrl(video),
98
99 attributedTo: [
100 {
101 type: 'Person',
102 id: video.VideoChannel.Account.Actor.url
103 },
104 {
105 type: 'Group',
106 id: video.VideoChannel.Actor.url
107 }
108 ],
109
110 ...buildLiveAPAttributes(video)
111 }
112}
113
114// ---------------------------------------------------------------------------
115// Private
116// ---------------------------------------------------------------------------
117
118function buildLiveAPAttributes (video: MVideoAP) {
119 if (!video.isLive) {
120 return {
121 isLiveBroadcast: false,
122 liveSaveReplay: null,
123 permanentLive: null,
124 latencyMode: null
125 }
126 }
127
128 return {
129 isLiveBroadcast: true,
130 liveSaveReplay: video.VideoLive.saveReplay,
131 permanentLive: video.VideoLive.permanentLive,
132 latencyMode: video.VideoLive.latencyMode
133 }
134}
135
136function buildPreviewAPAttribute (video: MVideoAP): ActivityPubStoryboard[] {
137 if (!video.Storyboard) return undefined
138
139 const storyboard = video.Storyboard
140
141 return [
142 {
143 type: 'Image',
144 rel: [ 'storyboard' ],
145 url: [
146 {
147 mediaType: 'image/jpeg',
148
149 href: storyboard.getOriginFileUrl(video),
150
151 width: storyboard.totalWidth,
152 height: storyboard.totalHeight,
153
154 tileWidth: storyboard.spriteWidth,
155 tileHeight: storyboard.spriteHeight,
156 tileDuration: getActivityStreamDuration(storyboard.spriteDuration)
157 }
158 ]
159 }
160 ]
161}
162
163function buildVideoFileUrls (options: {
164 video: MVideo
165 files: MVideoFile[]
166 user?: MUserId
167}): ActivityUrlObject[] {
168 const { video, files } = options
169
170 if (!isArray(files)) return []
171
172 const urls: ActivityUrlObject[] = []
173
174 const trackerUrls = video.getTrackerUrls()
175 const sortedFiles = files
176 .filter(f => !f.isLive())
177 .sort(sortByResolutionDesc)
178
179 for (const file of sortedFiles) {
180 urls.push({
181 type: 'Link',
182 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] as any,
183 href: file.getFileUrl(video),
184 height: file.resolution,
185 size: file.size,
186 fps: file.fps
187 })
188
189 urls.push({
190 type: 'Link',
191 rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ],
192 mediaType: 'application/json' as 'application/json',
193 href: getLocalVideoFileMetadataUrl(video, file),
194 height: file.resolution,
195 fps: file.fps
196 })
197
198 if (file.hasTorrent()) {
199 urls.push({
200 type: 'Link',
201 mediaType: 'application/x-bittorrent' as 'application/x-bittorrent',
202 href: file.getTorrentUrl(),
203 height: file.resolution
204 })
205
206 urls.push({
207 type: 'Link',
208 mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
209 href: generateMagnetUri(video, file, trackerUrls),
210 height: file.resolution
211 })
212 }
213 }
214
215 return urls
216}
217
218// ---------------------------------------------------------------------------
219
220function buildStreamingPlaylistUrls (video: MVideoAP): ActivityPlaylistUrlObject[] {
221 if (!isArray(video.VideoStreamingPlaylists)) return []
222
223 return video.VideoStreamingPlaylists
224 .map(playlist => ({
225 type: 'Link',
226 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
227 href: playlist.getMasterPlaylistUrl(video),
228 tag: buildStreamingPlaylistTags(video, playlist)
229 }))
230}
231
232function buildStreamingPlaylistTags (video: MVideoAP, playlist: MStreamingPlaylistFiles) {
233 return [
234 ...playlist.p2pMediaLoaderInfohashes.map(i => ({ type: 'Infohash' as 'Infohash', name: i })),
235
236 {
237 type: 'Link',
238 name: 'sha256',
239 mediaType: 'application/json' as 'application/json',
240 href: playlist.getSha256SegmentsUrl(video)
241 },
242
243 ...buildVideoFileUrls({ video, files: playlist.VideoFiles })
244 ] as ActivityTagObject[]
245}
246
247// ---------------------------------------------------------------------------
248
249function buildTrackerUrls (video: MVideoAP): ActivityTrackerUrlObject[] {
250 return video.getTrackerUrls()
251 .map(trackerUrl => {
252 const rel2 = trackerUrl.startsWith('http')
253 ? 'http'
254 : 'websocket'
255
256 return {
257 type: 'Link',
258 name: `tracker-${rel2}`,
259 rel: [ 'tracker', rel2 ],
260 href: trackerUrl
261 }
262 })
263}
264
265// ---------------------------------------------------------------------------
266
267function buildTags (video: MVideoAP) {
268 if (!isArray(video.Tags)) return []
269
270 return video.Tags.map(t => ({
271 type: 'Hashtag' as 'Hashtag',
272 name: t.name
273 }))
274}
275
276function buildIcon (video: MVideoAP): ActivityIconObject[] {
277 return [ video.getMiniature(), video.getPreview() ]
278 .map(i => ({
279 type: 'Image',
280 url: i.getOriginFileUrl(video),
281 mediaType: 'image/jpeg',
282 width: i.width,
283 height: i.height
284 }))
285}
286
287function buildSubtitleLanguage (video: MVideoAP) {
288 if (!isArray(video.VideoCaptions)) return []
289
290 return video.VideoCaptions
291 .map(caption => ({
292 identifier: caption.language,
293 name: VideoCaptionModel.getLanguageLabel(caption.language),
294 url: caption.getFileUrl(video)
295 }))
296}
diff --git a/server/models/video/formatter/video-api-format.ts b/server/models/video/formatter/video-api-format.ts
deleted file mode 100644
index 7a58f5d3c..000000000
--- a/server/models/video/formatter/video-api-format.ts
+++ /dev/null
@@ -1,305 +0,0 @@
1import { generateMagnetUri } from '@server/helpers/webtorrent'
2import { tracer } from '@server/lib/opentelemetry/tracing'
3import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
4import { VideoViewsManager } from '@server/lib/views/video-views-manager'
5import { uuidToShort } from '@shared/extra-utils'
6import {
7 Video,
8 VideoAdditionalAttributes,
9 VideoDetails,
10 VideoFile,
11 VideoInclude,
12 VideosCommonQueryAfterSanitize,
13 VideoStreamingPlaylist
14} from '@shared/models'
15import { isArray } from '../../../helpers/custom-validators/misc'
16import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, VIDEO_STATES } from '../../../initializers/constants'
17import { MServer, MStreamingPlaylistRedundanciesOpt, MVideoFormattable, MVideoFormattableDetails } from '../../../types/models'
18import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file'
19import { sortByResolutionDesc } from './shared'
20
21export type VideoFormattingJSONOptions = {
22 completeDescription?: boolean
23
24 additionalAttributes?: {
25 state?: boolean
26 waitTranscoding?: boolean
27 scheduledUpdate?: boolean
28 blacklistInfo?: boolean
29 files?: boolean
30 blockedOwner?: boolean
31 }
32}
33
34export function guessAdditionalAttributesFromQuery (query: VideosCommonQueryAfterSanitize): VideoFormattingJSONOptions {
35 if (!query?.include) return {}
36
37 return {
38 additionalAttributes: {
39 state: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
40 waitTranscoding: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
41 scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
42 blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED),
43 files: !!(query.include & VideoInclude.FILES),
44 blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER)
45 }
46 }
47}
48
49// ---------------------------------------------------------------------------
50
51export function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoFormattingJSONOptions = {}): Video {
52 const span = tracer.startSpan('peertube.VideoModel.toFormattedJSON')
53
54 const userHistory = isArray(video.UserVideoHistories)
55 ? video.UserVideoHistories[0]
56 : undefined
57
58 const videoObject: Video = {
59 id: video.id,
60 uuid: video.uuid,
61 shortUUID: uuidToShort(video.uuid),
62
63 url: video.url,
64
65 name: video.name,
66 category: {
67 id: video.category,
68 label: getCategoryLabel(video.category)
69 },
70 licence: {
71 id: video.licence,
72 label: getLicenceLabel(video.licence)
73 },
74 language: {
75 id: video.language,
76 label: getLanguageLabel(video.language)
77 },
78 privacy: {
79 id: video.privacy,
80 label: getPrivacyLabel(video.privacy)
81 },
82 nsfw: video.nsfw,
83
84 truncatedDescription: video.getTruncatedDescription(),
85 description: options && options.completeDescription === true
86 ? video.description
87 : video.getTruncatedDescription(),
88
89 isLocal: video.isOwned(),
90 duration: video.duration,
91
92 views: video.views,
93 viewers: VideoViewsManager.Instance.getViewers(video),
94
95 likes: video.likes,
96 dislikes: video.dislikes,
97 thumbnailPath: video.getMiniatureStaticPath(),
98 previewPath: video.getPreviewStaticPath(),
99 embedPath: video.getEmbedStaticPath(),
100 createdAt: video.createdAt,
101 updatedAt: video.updatedAt,
102 publishedAt: video.publishedAt,
103 originallyPublishedAt: video.originallyPublishedAt,
104
105 isLive: video.isLive,
106
107 account: video.VideoChannel.Account.toFormattedSummaryJSON(),
108 channel: video.VideoChannel.toFormattedSummaryJSON(),
109
110 userHistory: userHistory
111 ? { currentTime: userHistory.currentTime }
112 : undefined,
113
114 // Can be added by external plugins
115 pluginData: (video as any).pluginData,
116
117 ...buildAdditionalAttributes(video, options)
118 }
119
120 span.end()
121
122 return videoObject
123}
124
125export function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetails): VideoDetails {
126 const span = tracer.startSpan('peertube.VideoModel.toFormattedDetailsJSON')
127
128 const videoJSON = video.toFormattedJSON({
129 completeDescription: true,
130 additionalAttributes: {
131 scheduledUpdate: true,
132 blacklistInfo: true,
133 files: true
134 }
135 }) as Video & Required<Pick<Video, 'files' | 'streamingPlaylists' | 'scheduledUpdate' | 'blacklisted' | 'blacklistedReason'>>
136
137 const tags = video.Tags
138 ? video.Tags.map(t => t.name)
139 : []
140
141 const detailsJSON = {
142 ...videoJSON,
143
144 support: video.support,
145 descriptionPath: video.getDescriptionAPIPath(),
146 channel: video.VideoChannel.toFormattedJSON(),
147 account: video.VideoChannel.Account.toFormattedJSON(),
148 tags,
149 commentsEnabled: video.commentsEnabled,
150 downloadEnabled: video.downloadEnabled,
151 waitTranscoding: video.waitTranscoding,
152 inputFileUpdatedAt: video.inputFileUpdatedAt,
153 state: {
154 id: video.state,
155 label: getStateLabel(video.state)
156 },
157
158 trackerUrls: video.getTrackerUrls()
159 }
160
161 span.end()
162
163 return detailsJSON
164}
165
166export function streamingPlaylistsModelToFormattedJSON (
167 video: MVideoFormattable,
168 playlists: MStreamingPlaylistRedundanciesOpt[]
169): VideoStreamingPlaylist[] {
170 if (isArray(playlists) === false) return []
171
172 return playlists
173 .map(playlist => ({
174 id: playlist.id,
175 type: playlist.type,
176
177 playlistUrl: playlist.getMasterPlaylistUrl(video),
178 segmentsSha256Url: playlist.getSha256SegmentsUrl(video),
179
180 redundancies: isArray(playlist.RedundancyVideos)
181 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
182 : [],
183
184 files: videoFilesModelToFormattedJSON(video, playlist.VideoFiles)
185 }))
186}
187
188export function videoFilesModelToFormattedJSON (
189 video: MVideoFormattable,
190 videoFiles: MVideoFileRedundanciesOpt[],
191 options: {
192 includeMagnet?: boolean // default true
193 } = {}
194): VideoFile[] {
195 const { includeMagnet = true } = options
196
197 if (isArray(videoFiles) === false) return []
198
199 const trackerUrls = includeMagnet
200 ? video.getTrackerUrls()
201 : []
202
203 return videoFiles
204 .filter(f => !f.isLive())
205 .sort(sortByResolutionDesc)
206 .map(videoFile => {
207 return {
208 id: videoFile.id,
209
210 resolution: {
211 id: videoFile.resolution,
212 label: videoFile.resolution === 0
213 ? 'Audio'
214 : `${videoFile.resolution}p`
215 },
216
217 magnetUri: includeMagnet && videoFile.hasTorrent()
218 ? generateMagnetUri(video, videoFile, trackerUrls)
219 : undefined,
220
221 size: videoFile.size,
222 fps: videoFile.fps,
223
224 torrentUrl: videoFile.getTorrentUrl(),
225 torrentDownloadUrl: videoFile.getTorrentDownloadUrl(),
226
227 fileUrl: videoFile.getFileUrl(video),
228 fileDownloadUrl: videoFile.getFileDownloadUrl(video),
229
230 metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile)
231 }
232 })
233}
234
235// ---------------------------------------------------------------------------
236
237export function getCategoryLabel (id: number) {
238 return VIDEO_CATEGORIES[id] || 'Unknown'
239}
240
241export function getLicenceLabel (id: number) {
242 return VIDEO_LICENCES[id] || 'Unknown'
243}
244
245export function getLanguageLabel (id: string) {
246 return VIDEO_LANGUAGES[id] || 'Unknown'
247}
248
249export function getPrivacyLabel (id: number) {
250 return VIDEO_PRIVACIES[id] || 'Unknown'
251}
252
253export function getStateLabel (id: number) {
254 return VIDEO_STATES[id] || 'Unknown'
255}
256
257// ---------------------------------------------------------------------------
258// Private
259// ---------------------------------------------------------------------------
260
261function buildAdditionalAttributes (video: MVideoFormattable, options: VideoFormattingJSONOptions) {
262 const add = options.additionalAttributes
263
264 const result: Partial<VideoAdditionalAttributes> = {}
265
266 if (add?.state === true) {
267 result.state = {
268 id: video.state,
269 label: getStateLabel(video.state)
270 }
271 }
272
273 if (add?.waitTranscoding === true) {
274 result.waitTranscoding = video.waitTranscoding
275 }
276
277 if (add?.scheduledUpdate === true && video.ScheduleVideoUpdate) {
278 result.scheduledUpdate = {
279 updateAt: video.ScheduleVideoUpdate.updateAt,
280 privacy: video.ScheduleVideoUpdate.privacy || undefined
281 }
282 }
283
284 if (add?.blacklistInfo === true) {
285 result.blacklisted = !!video.VideoBlacklist
286 result.blacklistedReason =
287 video.VideoBlacklist
288 ? video.VideoBlacklist.reason
289 : null
290 }
291
292 if (add?.blockedOwner === true) {
293 result.blockedOwner = video.VideoChannel.Account.isBlocked()
294
295 const server = video.VideoChannel.Account.Actor.Server as MServer
296 result.blockedServer = !!(server?.isBlocked())
297 }
298
299 if (add?.files === true) {
300 result.streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
301 result.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
302 }
303
304 return result
305}
diff --git a/server/models/video/schedule-video-update.ts b/server/models/video/schedule-video-update.ts
deleted file mode 100644
index b3cf26966..000000000
--- a/server/models/video/schedule-video-update.ts
+++ /dev/null
@@ -1,95 +0,0 @@
1import { Op, Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MScheduleVideoUpdateFormattable, MScheduleVideoUpdate } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoPrivacy } from '../../../shared/models/videos'
6import { VideoModel } from './video'
7
8@Table({
9 tableName: 'scheduleVideoUpdate',
10 indexes: [
11 {
12 fields: [ 'videoId' ],
13 unique: true
14 },
15 {
16 fields: [ 'updateAt' ]
17 }
18 ]
19})
20export class ScheduleVideoUpdateModel extends Model<Partial<AttributesOnly<ScheduleVideoUpdateModel>>> {
21
22 @AllowNull(false)
23 @Default(null)
24 @Column
25 updateAt: Date
26
27 @AllowNull(true)
28 @Default(null)
29 @Column
30 privacy: VideoPrivacy.PUBLIC | VideoPrivacy.UNLISTED | VideoPrivacy.INTERNAL
31
32 @CreatedAt
33 createdAt: Date
34
35 @UpdatedAt
36 updatedAt: Date
37
38 @ForeignKey(() => VideoModel)
39 @Column
40 videoId: number
41
42 @BelongsTo(() => VideoModel, {
43 foreignKey: {
44 allowNull: false
45 },
46 onDelete: 'cascade'
47 })
48 Video: VideoModel
49
50 static areVideosToUpdate () {
51 const query = {
52 logging: false,
53 attributes: [ 'id' ],
54 where: {
55 updateAt: {
56 [Op.lte]: new Date()
57 }
58 }
59 }
60
61 return ScheduleVideoUpdateModel.findOne(query)
62 .then(res => !!res)
63 }
64
65 static listVideosToUpdate (transaction?: Transaction) {
66 const query = {
67 where: {
68 updateAt: {
69 [Op.lte]: new Date()
70 }
71 },
72 transaction
73 }
74
75 return ScheduleVideoUpdateModel.findAll<MScheduleVideoUpdate>(query)
76 }
77
78 static deleteByVideoId (videoId: number, t: Transaction) {
79 const query = {
80 where: {
81 videoId
82 },
83 transaction: t
84 }
85
86 return ScheduleVideoUpdateModel.destroy(query)
87 }
88
89 toFormattedJSON (this: MScheduleVideoUpdateFormattable) {
90 return {
91 updateAt: this.updateAt,
92 privacy: this.privacy || undefined
93 }
94 }
95}
diff --git a/server/models/video/sql/comment/video-comment-list-query-builder.ts b/server/models/video/sql/comment/video-comment-list-query-builder.ts
deleted file mode 100644
index a7eed22a1..000000000
--- a/server/models/video/sql/comment/video-comment-list-query-builder.ts
+++ /dev/null
@@ -1,400 +0,0 @@
1import { Model, Sequelize, Transaction } from 'sequelize'
2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
3import { ActorImageType, VideoPrivacy } from '@shared/models'
4import { createSafeIn, getSort, parseRowCountResult } from '../../../shared'
5import { VideoCommentTableAttributes } from './video-comment-table-attributes'
6
7export interface ListVideoCommentsOptions {
8 selectType: 'api' | 'feed' | 'comment-only'
9
10 start?: number
11 count?: number
12 sort?: string
13
14 videoId?: number
15 threadId?: number
16 accountId?: number
17 videoChannelId?: number
18
19 blockerAccountIds?: number[]
20
21 isThread?: boolean
22 notDeleted?: boolean
23 isLocal?: boolean
24 onLocalVideo?: boolean
25 onPublicVideo?: boolean
26 videoAccountOwnerId?: boolean
27
28 search?: string
29 searchAccount?: string
30 searchVideo?: string
31
32 includeReplyCounters?: boolean
33
34 transaction?: Transaction
35}
36
37export class VideoCommentListQueryBuilder extends AbstractRunQuery {
38 private readonly tableAttributes = new VideoCommentTableAttributes()
39
40 private innerQuery: string
41
42 private select = ''
43 private joins = ''
44
45 private innerSelect = ''
46 private innerJoins = ''
47 private innerLateralJoins = ''
48 private innerWhere = ''
49
50 private readonly built = {
51 cte: false,
52 accountJoin: false,
53 videoJoin: false,
54 videoChannelJoin: false,
55 avatarJoin: false
56 }
57
58 constructor (
59 protected readonly sequelize: Sequelize,
60 private readonly options: ListVideoCommentsOptions
61 ) {
62 super(sequelize)
63
64 if (this.options.includeReplyCounters && !this.options.videoId) {
65 throw new Error('Cannot include reply counters without videoId')
66 }
67 }
68
69 async listComments <T extends Model> () {
70 this.buildListQuery()
71
72 const results = await this.runQuery({ nest: true, transaction: this.options.transaction })
73 const modelBuilder = new ModelBuilder<T>(this.sequelize)
74
75 return modelBuilder.createModels(results, 'VideoComment')
76 }
77
78 async countComments () {
79 this.buildCountQuery()
80
81 const result = await this.runQuery({ transaction: this.options.transaction })
82
83 return parseRowCountResult(result)
84 }
85
86 // ---------------------------------------------------------------------------
87
88 private buildListQuery () {
89 this.buildInnerListQuery()
90 this.buildListSelect()
91
92 this.query = `${this.select} ` +
93 `FROM (${this.innerQuery}) AS "VideoCommentModel" ` +
94 `${this.joins} ` +
95 `${this.getOrder()}`
96 }
97
98 private buildInnerListQuery () {
99 this.buildWhere()
100 this.buildInnerListSelect()
101
102 this.innerQuery = `${this.innerSelect} ` +
103 `FROM "videoComment" AS "VideoCommentModel" ` +
104 `${this.innerJoins} ` +
105 `${this.innerLateralJoins} ` +
106 `${this.innerWhere} ` +
107 `${this.getOrder()} ` +
108 `${this.getInnerLimit()}`
109 }
110
111 // ---------------------------------------------------------------------------
112
113 private buildCountQuery () {
114 this.buildWhere()
115
116 this.query = `SELECT COUNT(*) AS "total" ` +
117 `FROM "videoComment" AS "VideoCommentModel" ` +
118 `${this.innerJoins} ` +
119 `${this.innerWhere}`
120 }
121
122 // ---------------------------------------------------------------------------
123
124 private buildWhere () {
125 let where: string[] = []
126
127 if (this.options.videoId) {
128 this.replacements.videoId = this.options.videoId
129
130 where.push('"VideoCommentModel"."videoId" = :videoId')
131 }
132
133 if (this.options.threadId) {
134 this.replacements.threadId = this.options.threadId
135
136 where.push('("VideoCommentModel"."id" = :threadId OR "VideoCommentModel"."originCommentId" = :threadId)')
137 }
138
139 if (this.options.accountId) {
140 this.replacements.accountId = this.options.accountId
141
142 where.push('"VideoCommentModel"."accountId" = :accountId')
143 }
144
145 if (this.options.videoChannelId) {
146 this.buildVideoChannelJoin()
147
148 this.replacements.videoChannelId = this.options.videoChannelId
149
150 where.push('"Account->VideoChannel"."id" = :videoChannelId')
151 }
152
153 if (this.options.blockerAccountIds) {
154 this.buildVideoChannelJoin()
155
156 where = where.concat(this.getBlockWhere('VideoCommentModel', 'Video->VideoChannel'))
157 }
158
159 if (this.options.isThread === true) {
160 where.push('"VideoCommentModel"."inReplyToCommentId" IS NULL')
161 }
162
163 if (this.options.notDeleted === true) {
164 where.push('"VideoCommentModel"."deletedAt" IS NULL')
165 }
166
167 if (this.options.isLocal === true) {
168 this.buildAccountJoin()
169
170 where.push('"Account->Actor"."serverId" IS NULL')
171 } else if (this.options.isLocal === false) {
172 this.buildAccountJoin()
173
174 where.push('"Account->Actor"."serverId" IS NOT NULL')
175 }
176
177 if (this.options.onLocalVideo === true) {
178 this.buildVideoJoin()
179
180 where.push('"Video"."remote" IS FALSE')
181 } else if (this.options.onLocalVideo === false) {
182 this.buildVideoJoin()
183
184 where.push('"Video"."remote" IS TRUE')
185 }
186
187 if (this.options.onPublicVideo === true) {
188 this.buildVideoJoin()
189
190 where.push(`"Video"."privacy" = ${VideoPrivacy.PUBLIC}`)
191 }
192
193 if (this.options.videoAccountOwnerId) {
194 this.buildVideoChannelJoin()
195
196 this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId
197
198 where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`)
199 }
200
201 if (this.options.search) {
202 this.buildVideoJoin()
203 this.buildAccountJoin()
204
205 const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
206
207 where.push(
208 `(` +
209 `"VideoCommentModel"."text" ILIKE ${escapedLikeSearch} OR ` +
210 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
211 `"Account"."name" ILIKE ${escapedLikeSearch} OR ` +
212 `"Video"."name" ILIKE ${escapedLikeSearch} ` +
213 `)`
214 )
215 }
216
217 if (this.options.searchAccount) {
218 this.buildAccountJoin()
219
220 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchAccount + '%')
221
222 where.push(
223 `(` +
224 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
225 `"Account"."name" ILIKE ${escapedLikeSearch} ` +
226 `)`
227 )
228 }
229
230 if (this.options.searchVideo) {
231 this.buildVideoJoin()
232
233 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchVideo + '%')
234
235 where.push(`"Video"."name" ILIKE ${escapedLikeSearch}`)
236 }
237
238 if (where.length !== 0) {
239 this.innerWhere = `WHERE ${where.join(' AND ')}`
240 }
241 }
242
243 private buildAccountJoin () {
244 if (this.built.accountJoin) return
245
246 this.innerJoins += ' LEFT JOIN "account" "Account" ON "Account"."id" = "VideoCommentModel"."accountId" ' +
247 'LEFT JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' +
248 'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" '
249
250 this.built.accountJoin = true
251 }
252
253 private buildVideoJoin () {
254 if (this.built.videoJoin) return
255
256 this.innerJoins += ' LEFT JOIN "video" "Video" ON "Video"."id" = "VideoCommentModel"."videoId" '
257
258 this.built.videoJoin = true
259 }
260
261 private buildVideoChannelJoin () {
262 if (this.built.videoChannelJoin) return
263
264 this.buildVideoJoin()
265
266 this.innerJoins += ' LEFT JOIN "videoChannel" "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" '
267
268 this.built.videoChannelJoin = true
269 }
270
271 private buildAvatarsJoin () {
272 if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return ''
273 if (this.built.avatarJoin) return
274
275 this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` +
276 `ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` +
277 `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
278
279 this.built.avatarJoin = true
280 }
281
282 // ---------------------------------------------------------------------------
283
284 private buildListSelect () {
285 const toSelect = [ '"VideoCommentModel".*' ]
286
287 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
288 this.buildAvatarsJoin()
289
290 toSelect.push(this.tableAttributes.getAvatarAttributes())
291 }
292
293 this.select = this.buildSelect(toSelect)
294 }
295
296 private buildInnerListSelect () {
297 let toSelect = [ this.tableAttributes.getVideoCommentAttributes() ]
298
299 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
300 this.buildAccountJoin()
301 this.buildVideoJoin()
302
303 toSelect = toSelect.concat([
304 this.tableAttributes.getVideoAttributes(),
305 this.tableAttributes.getAccountAttributes(),
306 this.tableAttributes.getActorAttributes(),
307 this.tableAttributes.getServerAttributes()
308 ])
309 }
310
311 if (this.options.includeReplyCounters === true) {
312 this.buildTotalRepliesSelect()
313 this.buildAuthorTotalRepliesSelect()
314
315 toSelect.push('"totalRepliesFromVideoAuthor"."count" AS "totalRepliesFromVideoAuthor"')
316 toSelect.push('"totalReplies"."count" AS "totalReplies"')
317 }
318
319 this.innerSelect = this.buildSelect(toSelect)
320 }
321
322 // ---------------------------------------------------------------------------
323
324 private getBlockWhere (commentTableName: string, channelTableName: string) {
325 const where: string[] = []
326
327 const blockerIdsString = createSafeIn(
328 this.sequelize,
329 this.options.blockerAccountIds,
330 [ `"${channelTableName}"."accountId"` ]
331 )
332
333 where.push(
334 `NOT EXISTS (` +
335 `SELECT 1 FROM "accountBlocklist" ` +
336 `WHERE "targetAccountId" = "${commentTableName}"."accountId" ` +
337 `AND "accountId" IN (${blockerIdsString})` +
338 `)`
339 )
340
341 where.push(
342 `NOT EXISTS (` +
343 `SELECT 1 FROM "account" ` +
344 `INNER JOIN "actor" ON account."actorId" = actor.id ` +
345 `INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
346 `WHERE "account"."id" = "${commentTableName}"."accountId" ` +
347 `AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
348 `)`
349 )
350
351 return where
352 }
353
354 // ---------------------------------------------------------------------------
355
356 private buildTotalRepliesSelect () {
357 const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ')
358
359 // Help the planner by providing videoId that should filter out many comments
360 this.replacements.videoId = this.options.videoId
361
362 this.innerLateralJoins += `LEFT JOIN LATERAL (` +
363 `SELECT COUNT("replies"."id") AS "count" FROM "videoComment" AS "replies" ` +
364 `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` +
365 `LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
366 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` +
367 `AND "deletedAt" IS NULL ` +
368 `AND ${blockWhereString} ` +
369 `) "totalReplies" ON TRUE `
370 }
371
372 private buildAuthorTotalRepliesSelect () {
373 // Help the planner by providing videoId that should filter out many comments
374 this.replacements.videoId = this.options.videoId
375
376 this.innerLateralJoins += `LEFT JOIN LATERAL (` +
377 `SELECT COUNT("replies"."id") AS "count" FROM "videoComment" AS "replies" ` +
378 `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` +
379 `INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
380 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" AND "replies"."accountId" = "videoChannel"."accountId"` +
381 `) "totalRepliesFromVideoAuthor" ON TRUE `
382 }
383
384 private getOrder () {
385 if (!this.options.sort) return ''
386
387 const orders = getSort(this.options.sort)
388
389 return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
390 }
391
392 private getInnerLimit () {
393 if (!this.options.count) return ''
394
395 this.replacements.limit = this.options.count
396 this.replacements.offset = this.options.start || 0
397
398 return `LIMIT :limit OFFSET :offset `
399 }
400}
diff --git a/server/models/video/sql/comment/video-comment-table-attributes.ts b/server/models/video/sql/comment/video-comment-table-attributes.ts
deleted file mode 100644
index 87f8750c1..000000000
--- a/server/models/video/sql/comment/video-comment-table-attributes.ts
+++ /dev/null
@@ -1,43 +0,0 @@
1import { Memoize } from '@server/helpers/memoize'
2import { AccountModel } from '@server/models/account/account'
3import { ActorModel } from '@server/models/actor/actor'
4import { ActorImageModel } from '@server/models/actor/actor-image'
5import { ServerModel } from '@server/models/server/server'
6import { VideoCommentModel } from '../../video-comment'
7
8export class VideoCommentTableAttributes {
9
10 @Memoize()
11 getVideoCommentAttributes () {
12 return VideoCommentModel.getSQLAttributes('VideoCommentModel').join(', ')
13 }
14
15 @Memoize()
16 getAccountAttributes () {
17 return AccountModel.getSQLAttributes('Account', 'Account.').join(', ')
18 }
19
20 @Memoize()
21 getVideoAttributes () {
22 return [
23 `"Video"."id" AS "Video.id"`,
24 `"Video"."uuid" AS "Video.uuid"`,
25 `"Video"."name" AS "Video.name"`
26 ].join(', ')
27 }
28
29 @Memoize()
30 getActorAttributes () {
31 return ActorModel.getSQLAPIAttributes('Account->Actor', `Account.Actor.`).join(', ')
32 }
33
34 @Memoize()
35 getServerAttributes () {
36 return ServerModel.getSQLAttributes('Account->Actor->Server', `Account.Actor.Server.`).join(', ')
37 }
38
39 @Memoize()
40 getAvatarAttributes () {
41 return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ')
42 }
43}
diff --git a/server/models/video/sql/video/index.ts b/server/models/video/sql/video/index.ts
deleted file mode 100644
index e9132d5e1..000000000
--- a/server/models/video/sql/video/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
1export * from './video-model-get-query-builder'
2export * from './videos-id-list-query-builder'
3export * from './videos-model-list-query-builder'
diff --git a/server/models/video/sql/video/shared/abstract-video-query-builder.ts b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
deleted file mode 100644
index 56a00aa0c..000000000
--- a/server/models/video/sql/video/shared/abstract-video-query-builder.ts
+++ /dev/null
@@ -1,340 +0,0 @@
1import { Sequelize } from 'sequelize'
2import validator from 'validator'
3import { MUserAccountId } from '@server/types/models'
4import { ActorImageType } from '@shared/models'
5import { AbstractRunQuery } from '../../../../shared/abstract-run-query'
6import { createSafeIn } from '../../../../shared'
7import { VideoTableAttributes } from './video-table-attributes'
8
9/**
10 *
11 * Abstract builder to create SQL query and fetch video models
12 *
13 */
14
15export class AbstractVideoQueryBuilder extends AbstractRunQuery {
16 protected attributes: { [key: string]: string } = {}
17
18 protected joins = ''
19 protected where: string
20
21 protected tables: VideoTableAttributes
22
23 constructor (
24 protected readonly sequelize: Sequelize,
25 protected readonly mode: 'list' | 'get'
26 ) {
27 super(sequelize)
28
29 this.tables = new VideoTableAttributes(this.mode)
30 }
31
32 protected buildSelect () {
33 return 'SELECT ' + Object.keys(this.attributes).map(key => {
34 const value = this.attributes[key]
35 if (value) return `${key} AS ${value}`
36
37 return key
38 }).join(', ')
39 }
40
41 protected includeChannels () {
42 this.addJoin('INNER JOIN "videoChannel" AS "VideoChannel" ON "video"."channelId" = "VideoChannel"."id"')
43 this.addJoin('INNER JOIN "actor" AS "VideoChannel->Actor" ON "VideoChannel"."actorId" = "VideoChannel->Actor"."id"')
44
45 this.addJoin(
46 'LEFT OUTER JOIN "server" AS "VideoChannel->Actor->Server" ON "VideoChannel->Actor"."serverId" = "VideoChannel->Actor->Server"."id"'
47 )
48
49 this.addJoin(
50 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatars" ' +
51 'ON "VideoChannel->Actor"."id" = "VideoChannel->Actor->Avatars"."actorId" ' +
52 `AND "VideoChannel->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
53 )
54
55 this.attributes = {
56 ...this.attributes,
57
58 ...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()),
59 ...this.buildActorInclude('VideoChannel->Actor'),
60 ...this.buildAvatarInclude('VideoChannel->Actor->Avatars'),
61 ...this.buildServerInclude('VideoChannel->Actor->Server')
62 }
63 }
64
65 protected includeAccounts () {
66 this.addJoin('INNER JOIN "account" AS "VideoChannel->Account" ON "VideoChannel"."accountId" = "VideoChannel->Account"."id"')
67 this.addJoin(
68 'INNER JOIN "actor" AS "VideoChannel->Account->Actor" ON "VideoChannel->Account"."actorId" = "VideoChannel->Account->Actor"."id"'
69 )
70
71 this.addJoin(
72 'LEFT OUTER JOIN "server" AS "VideoChannel->Account->Actor->Server" ' +
73 'ON "VideoChannel->Account->Actor"."serverId" = "VideoChannel->Account->Actor->Server"."id"'
74 )
75
76 this.addJoin(
77 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatars" ' +
78 'ON "VideoChannel->Account"."actorId"= "VideoChannel->Account->Actor->Avatars"."actorId" ' +
79 `AND "VideoChannel->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
80 )
81
82 this.attributes = {
83 ...this.attributes,
84
85 ...this.buildAttributesObject('VideoChannel->Account', this.tables.getAccountAttributes()),
86 ...this.buildActorInclude('VideoChannel->Account->Actor'),
87 ...this.buildAvatarInclude('VideoChannel->Account->Actor->Avatars'),
88 ...this.buildServerInclude('VideoChannel->Account->Actor->Server')
89 }
90 }
91
92 protected includeOwnerUser () {
93 this.addJoin('INNER JOIN "videoChannel" AS "VideoChannel" ON "video"."channelId" = "VideoChannel"."id"')
94 this.addJoin('INNER JOIN "account" AS "VideoChannel->Account" ON "VideoChannel"."accountId" = "VideoChannel->Account"."id"')
95
96 this.attributes = {
97 ...this.attributes,
98
99 ...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()),
100 ...this.buildAttributesObject('VideoChannel->Account', this.tables.getUserAccountAttributes())
101 }
102 }
103
104 protected includeThumbnails () {
105 this.addJoin('LEFT OUTER JOIN "thumbnail" AS "Thumbnails" ON "video"."id" = "Thumbnails"."videoId"')
106
107 this.attributes = {
108 ...this.attributes,
109
110 ...this.buildAttributesObject('Thumbnails', this.tables.getThumbnailAttributes())
111 }
112 }
113
114 protected includeWebVideoFiles () {
115 this.addJoin('LEFT JOIN "videoFile" AS "VideoFiles" ON "VideoFiles"."videoId" = "video"."id"')
116
117 this.attributes = {
118 ...this.attributes,
119
120 ...this.buildAttributesObject('VideoFiles', this.tables.getFileAttributes())
121 }
122 }
123
124 protected includeStreamingPlaylistFiles () {
125 this.addJoin(
126 'LEFT JOIN "videoStreamingPlaylist" AS "VideoStreamingPlaylists" ON "VideoStreamingPlaylists"."videoId" = "video"."id"'
127 )
128
129 this.addJoin(
130 'LEFT JOIN "videoFile" AS "VideoStreamingPlaylists->VideoFiles" ' +
131 'ON "VideoStreamingPlaylists->VideoFiles"."videoStreamingPlaylistId" = "VideoStreamingPlaylists"."id"'
132 )
133
134 this.attributes = {
135 ...this.attributes,
136
137 ...this.buildAttributesObject('VideoStreamingPlaylists', this.tables.getStreamingPlaylistAttributes()),
138 ...this.buildAttributesObject('VideoStreamingPlaylists->VideoFiles', this.tables.getFileAttributes())
139 }
140 }
141
142 protected includeUserHistory (userId: number) {
143 this.addJoin(
144 'LEFT OUTER JOIN "userVideoHistory" ' +
145 'ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :userVideoHistoryId'
146 )
147
148 this.replacements.userVideoHistoryId = userId
149
150 this.attributes = {
151 ...this.attributes,
152
153 ...this.buildAttributesObject('userVideoHistory', this.tables.getUserHistoryAttributes())
154 }
155 }
156
157 protected includePlaylist (playlistId: number) {
158 this.addJoin(
159 'INNER JOIN "videoPlaylistElement" as "VideoPlaylistElement" ON "videoPlaylistElement"."videoId" = "video"."id" ' +
160 'AND "VideoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
161 )
162
163 this.replacements.videoPlaylistId = playlistId
164
165 this.attributes = {
166 ...this.attributes,
167
168 ...this.buildAttributesObject('VideoPlaylistElement', this.tables.getPlaylistAttributes())
169 }
170 }
171
172 protected includeTags () {
173 this.addJoin(
174 'LEFT OUTER JOIN (' +
175 '"videoTag" AS "Tags->VideoTagModel" INNER JOIN "tag" AS "Tags" ON "Tags"."id" = "Tags->VideoTagModel"."tagId"' +
176 ') ' +
177 'ON "video"."id" = "Tags->VideoTagModel"."videoId"'
178 )
179
180 this.attributes = {
181 ...this.attributes,
182
183 ...this.buildAttributesObject('Tags', this.tables.getTagAttributes()),
184 ...this.buildAttributesObject('Tags->VideoTagModel', this.tables.getVideoTagAttributes())
185 }
186 }
187
188 protected includeBlacklisted () {
189 this.addJoin(
190 'LEFT OUTER JOIN "videoBlacklist" AS "VideoBlacklist" ON "video"."id" = "VideoBlacklist"."videoId"'
191 )
192
193 this.attributes = {
194 ...this.attributes,
195
196 ...this.buildAttributesObject('VideoBlacklist', this.tables.getBlacklistedAttributes())
197 }
198 }
199
200 protected includeBlockedOwnerAndServer (serverAccountId: number, user?: MUserAccountId) {
201 const blockerIds = [ serverAccountId ]
202 if (user) blockerIds.push(user.Account.id)
203
204 const inClause = createSafeIn(this.sequelize, blockerIds)
205
206 this.addJoin(
207 'LEFT JOIN "accountBlocklist" AS "VideoChannel->Account->AccountBlocklist" ' +
208 'ON "VideoChannel->Account"."id" = "VideoChannel->Account->AccountBlocklist"."targetAccountId" ' +
209 'AND "VideoChannel->Account->AccountBlocklist"."accountId" IN (' + inClause + ')'
210 )
211
212 this.addJoin(
213 'LEFT JOIN "serverBlocklist" AS "VideoChannel->Account->Actor->Server->ServerBlocklist" ' +
214 'ON "VideoChannel->Account->Actor->Server->ServerBlocklist"."targetServerId" = "VideoChannel->Account->Actor"."serverId" ' +
215 'AND "VideoChannel->Account->Actor->Server->ServerBlocklist"."accountId" IN (' + inClause + ') '
216 )
217
218 this.attributes = {
219 ...this.attributes,
220
221 ...this.buildAttributesObject('VideoChannel->Account->AccountBlocklist', this.tables.getBlocklistAttributes()),
222 ...this.buildAttributesObject('VideoChannel->Account->Actor->Server->ServerBlocklist', this.tables.getBlocklistAttributes())
223 }
224 }
225
226 protected includeScheduleUpdate () {
227 this.addJoin(
228 'LEFT OUTER JOIN "scheduleVideoUpdate" AS "ScheduleVideoUpdate" ON "video"."id" = "ScheduleVideoUpdate"."videoId"'
229 )
230
231 this.attributes = {
232 ...this.attributes,
233
234 ...this.buildAttributesObject('ScheduleVideoUpdate', this.tables.getScheduleUpdateAttributes())
235 }
236 }
237
238 protected includeLive () {
239 this.addJoin(
240 'LEFT OUTER JOIN "videoLive" AS "VideoLive" ON "video"."id" = "VideoLive"."videoId"'
241 )
242
243 this.attributes = {
244 ...this.attributes,
245
246 ...this.buildAttributesObject('VideoLive', this.tables.getLiveAttributes())
247 }
248 }
249
250 protected includeTrackers () {
251 this.addJoin(
252 'LEFT OUTER JOIN (' +
253 '"videoTracker" AS "Trackers->VideoTrackerModel" ' +
254 'INNER JOIN "tracker" AS "Trackers" ON "Trackers"."id" = "Trackers->VideoTrackerModel"."trackerId"' +
255 ') ON "video"."id" = "Trackers->VideoTrackerModel"."videoId"'
256 )
257
258 this.attributes = {
259 ...this.attributes,
260
261 ...this.buildAttributesObject('Trackers', this.tables.getTrackerAttributes()),
262 ...this.buildAttributesObject('Trackers->VideoTrackerModel', this.tables.getVideoTrackerAttributes())
263 }
264 }
265
266 protected includeWebVideoRedundancies () {
267 this.addJoin(
268 'LEFT OUTER JOIN "videoRedundancy" AS "VideoFiles->RedundancyVideos" ON ' +
269 '"VideoFiles"."id" = "VideoFiles->RedundancyVideos"."videoFileId"'
270 )
271
272 this.attributes = {
273 ...this.attributes,
274
275 ...this.buildAttributesObject('VideoFiles->RedundancyVideos', this.tables.getRedundancyAttributes())
276 }
277 }
278
279 protected includeStreamingPlaylistRedundancies () {
280 this.addJoin(
281 'LEFT OUTER JOIN "videoRedundancy" AS "VideoStreamingPlaylists->RedundancyVideos" ' +
282 'ON "VideoStreamingPlaylists"."id" = "VideoStreamingPlaylists->RedundancyVideos"."videoStreamingPlaylistId"'
283 )
284
285 this.attributes = {
286 ...this.attributes,
287
288 ...this.buildAttributesObject('VideoStreamingPlaylists->RedundancyVideos', this.tables.getRedundancyAttributes())
289 }
290 }
291
292 protected buildActorInclude (prefixKey: string) {
293 return this.buildAttributesObject(prefixKey, this.tables.getActorAttributes())
294 }
295
296 protected buildAvatarInclude (prefixKey: string) {
297 return this.buildAttributesObject(prefixKey, this.tables.getAvatarAttributes())
298 }
299
300 protected buildServerInclude (prefixKey: string) {
301 return this.buildAttributesObject(prefixKey, this.tables.getServerAttributes())
302 }
303
304 protected buildAttributesObject (prefixKey: string, attributeKeys: string[]) {
305 const result: { [id: string]: string } = {}
306
307 const prefixValue = prefixKey.replace(/->/g, '.')
308
309 for (const attribute of attributeKeys) {
310 result[`"${prefixKey}"."${attribute}"`] = `"${prefixValue}.${attribute}"`
311 }
312
313 return result
314 }
315
316 protected whereId (options: { ids?: number[], id?: string | number, url?: string }) {
317 if (options.ids) {
318 this.where = `WHERE "video"."id" IN (${createSafeIn(this.sequelize, options.ids)})`
319 return
320 }
321
322 if (options.url) {
323 this.where = 'WHERE "video"."url" = :videoUrl'
324 this.replacements.videoUrl = options.url
325 return
326 }
327
328 if (validator.isInt('' + options.id)) {
329 this.where = 'WHERE "video".id = :videoId'
330 } else {
331 this.where = 'WHERE uuid = :videoId'
332 }
333
334 this.replacements.videoId = options.id
335 }
336
337 protected addJoin (join: string) {
338 this.joins += join + ' '
339 }
340}
diff --git a/server/models/video/sql/video/shared/video-file-query-builder.ts b/server/models/video/sql/video/shared/video-file-query-builder.ts
deleted file mode 100644
index 196b72b43..000000000
--- a/server/models/video/sql/video/shared/video-file-query-builder.ts
+++ /dev/null
@@ -1,75 +0,0 @@
1import { Sequelize, Transaction } from 'sequelize'
2import { AbstractVideoQueryBuilder } from './abstract-video-query-builder'
3
4export type FileQueryOptions = {
5 id?: string | number
6 url?: string
7
8 includeRedundancy: boolean
9
10 transaction?: Transaction
11
12 logging?: boolean
13}
14
15/**
16 *
17 * Fetch files (web videos and streaming playlist) according to a video
18 *
19 */
20
21export class VideoFileQueryBuilder extends AbstractVideoQueryBuilder {
22 protected attributes: { [key: string]: string }
23
24 constructor (protected readonly sequelize: Sequelize) {
25 super(sequelize, 'get')
26 }
27
28 queryWebVideos (options: FileQueryOptions) {
29 this.buildWebVideoFilesQuery(options)
30
31 return this.runQuery(options)
32 }
33
34 queryStreamingPlaylistVideos (options: FileQueryOptions) {
35 this.buildVideoStreamingPlaylistFilesQuery(options)
36
37 return this.runQuery(options)
38 }
39
40 private buildWebVideoFilesQuery (options: FileQueryOptions) {
41 this.attributes = {
42 '"video"."id"': ''
43 }
44
45 this.includeWebVideoFiles()
46
47 if (options.includeRedundancy) {
48 this.includeWebVideoRedundancies()
49 }
50
51 this.whereId(options)
52
53 this.query = this.buildQuery()
54 }
55
56 private buildVideoStreamingPlaylistFilesQuery (options: FileQueryOptions) {
57 this.attributes = {
58 '"video"."id"': ''
59 }
60
61 this.includeStreamingPlaylistFiles()
62
63 if (options.includeRedundancy) {
64 this.includeStreamingPlaylistRedundancies()
65 }
66
67 this.whereId(options)
68
69 this.query = this.buildQuery()
70 }
71
72 private buildQuery () {
73 return `${this.buildSelect()} FROM "video" ${this.joins} ${this.where}`
74 }
75}
diff --git a/server/models/video/sql/video/shared/video-model-builder.ts b/server/models/video/sql/video/shared/video-model-builder.ts
deleted file mode 100644
index 740aa842f..000000000
--- a/server/models/video/sql/video/shared/video-model-builder.ts
+++ /dev/null
@@ -1,408 +0,0 @@
1
2import { AccountModel } from '@server/models/account/account'
3import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
4import { ActorModel } from '@server/models/actor/actor'
5import { ActorImageModel } from '@server/models/actor/actor-image'
6import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy'
7import { ServerModel } from '@server/models/server/server'
8import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
9import { TrackerModel } from '@server/models/server/tracker'
10import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
11import { VideoInclude } from '@shared/models'
12import { ScheduleVideoUpdateModel } from '../../../schedule-video-update'
13import { TagModel } from '../../../tag'
14import { ThumbnailModel } from '../../../thumbnail'
15import { VideoModel } from '../../../video'
16import { VideoBlacklistModel } from '../../../video-blacklist'
17import { VideoChannelModel } from '../../../video-channel'
18import { VideoFileModel } from '../../../video-file'
19import { VideoLiveModel } from '../../../video-live'
20import { VideoStreamingPlaylistModel } from '../../../video-streaming-playlist'
21import { VideoTableAttributes } from './video-table-attributes'
22
23type SQLRow = { [id: string]: string | number }
24
25/**
26 *
27 * Build video models from SQL rows
28 *
29 */
30
31export class VideoModelBuilder {
32 private videosMemo: { [ id: number ]: VideoModel }
33 private videoStreamingPlaylistMemo: { [ id: number ]: VideoStreamingPlaylistModel }
34 private videoFileMemo: { [ id: number ]: VideoFileModel }
35
36 private thumbnailsDone: Set<any>
37 private actorImagesDone: Set<any>
38 private historyDone: Set<any>
39 private blacklistDone: Set<any>
40 private accountBlocklistDone: Set<any>
41 private serverBlocklistDone: Set<any>
42 private liveDone: Set<any>
43 private redundancyDone: Set<any>
44 private scheduleVideoUpdateDone: Set<any>
45
46 private trackersDone: Set<string>
47 private tagsDone: Set<string>
48
49 private videos: VideoModel[]
50
51 private readonly buildOpts = { raw: true, isNewRecord: false }
52
53 constructor (
54 private readonly mode: 'get' | 'list',
55 private readonly tables: VideoTableAttributes
56 ) {
57
58 }
59
60 buildVideosFromRows (options: {
61 rows: SQLRow[]
62 include?: VideoInclude
63 rowsWebVideoFiles?: SQLRow[]
64 rowsStreamingPlaylist?: SQLRow[]
65 }) {
66 const { rows, rowsWebVideoFiles, rowsStreamingPlaylist, include } = options
67
68 this.reinit()
69
70 for (const row of rows) {
71 this.buildVideoAndAccount(row)
72
73 const videoModel = this.videosMemo[row.id as number]
74
75 this.setUserHistory(row, videoModel)
76 this.addThumbnail(row, videoModel)
77
78 const channelActor = videoModel.VideoChannel?.Actor
79 if (channelActor) {
80 this.addActorAvatar(row, 'VideoChannel.Actor', channelActor)
81 }
82
83 const accountActor = videoModel.VideoChannel?.Account?.Actor
84 if (accountActor) {
85 this.addActorAvatar(row, 'VideoChannel.Account.Actor', accountActor)
86 }
87
88 if (!rowsWebVideoFiles) {
89 this.addWebVideoFile(row, videoModel)
90 }
91
92 if (!rowsStreamingPlaylist) {
93 this.addStreamingPlaylist(row, videoModel)
94 this.addStreamingPlaylistFile(row)
95 }
96
97 if (this.mode === 'get') {
98 this.addTag(row, videoModel)
99 this.addTracker(row, videoModel)
100 this.setBlacklisted(row, videoModel)
101 this.setScheduleVideoUpdate(row, videoModel)
102 this.setLive(row, videoModel)
103 } else {
104 if (include & VideoInclude.BLACKLISTED) {
105 this.setBlacklisted(row, videoModel)
106 }
107
108 if (include & VideoInclude.BLOCKED_OWNER) {
109 this.setBlockedOwner(row, videoModel)
110 this.setBlockedServer(row, videoModel)
111 }
112 }
113 }
114
115 this.grabSeparateWebVideoFiles(rowsWebVideoFiles)
116 this.grabSeparateStreamingPlaylistFiles(rowsStreamingPlaylist)
117
118 return this.videos
119 }
120
121 private reinit () {
122 this.videosMemo = {}
123 this.videoStreamingPlaylistMemo = {}
124 this.videoFileMemo = {}
125
126 this.thumbnailsDone = new Set()
127 this.actorImagesDone = new Set()
128 this.historyDone = new Set()
129 this.blacklistDone = new Set()
130 this.liveDone = new Set()
131 this.redundancyDone = new Set()
132 this.scheduleVideoUpdateDone = new Set()
133
134 this.accountBlocklistDone = new Set()
135 this.serverBlocklistDone = new Set()
136
137 this.trackersDone = new Set()
138 this.tagsDone = new Set()
139
140 this.videos = []
141 }
142
143 private grabSeparateWebVideoFiles (rowsWebVideoFiles?: SQLRow[]) {
144 if (!rowsWebVideoFiles) return
145
146 for (const row of rowsWebVideoFiles) {
147 const id = row['VideoFiles.id']
148 if (!id) continue
149
150 const videoModel = this.videosMemo[row.id]
151 this.addWebVideoFile(row, videoModel)
152 this.addRedundancy(row, 'VideoFiles', this.videoFileMemo[id])
153 }
154 }
155
156 private grabSeparateStreamingPlaylistFiles (rowsStreamingPlaylist?: SQLRow[]) {
157 if (!rowsStreamingPlaylist) return
158
159 for (const row of rowsStreamingPlaylist) {
160 const id = row['VideoStreamingPlaylists.id']
161 if (!id) continue
162
163 const videoModel = this.videosMemo[row.id]
164
165 this.addStreamingPlaylist(row, videoModel)
166 this.addStreamingPlaylistFile(row)
167 this.addRedundancy(row, 'VideoStreamingPlaylists', this.videoStreamingPlaylistMemo[id])
168 }
169 }
170
171 private buildVideoAndAccount (row: SQLRow) {
172 if (this.videosMemo[row.id]) return
173
174 const videoModel = new VideoModel(this.grab(row, this.tables.getVideoAttributes(), ''), this.buildOpts)
175
176 videoModel.UserVideoHistories = []
177 videoModel.Thumbnails = []
178 videoModel.VideoFiles = []
179 videoModel.VideoStreamingPlaylists = []
180 videoModel.Tags = []
181 videoModel.Trackers = []
182
183 this.buildAccount(row, videoModel)
184
185 this.videosMemo[row.id] = videoModel
186
187 // Keep rows order
188 this.videos.push(videoModel)
189 }
190
191 private buildAccount (row: SQLRow, videoModel: VideoModel) {
192 const id = row['VideoChannel.Account.id']
193 if (!id) return
194
195 const channelModel = new VideoChannelModel(this.grab(row, this.tables.getChannelAttributes(), 'VideoChannel'), this.buildOpts)
196 channelModel.Actor = this.buildActor(row, 'VideoChannel')
197
198 const accountModel = new AccountModel(this.grab(row, this.tables.getAccountAttributes(), 'VideoChannel.Account'), this.buildOpts)
199 accountModel.Actor = this.buildActor(row, 'VideoChannel.Account')
200
201 accountModel.BlockedBy = []
202
203 channelModel.Account = accountModel
204
205 videoModel.VideoChannel = channelModel
206 }
207
208 private buildActor (row: SQLRow, prefix: string) {
209 const actorPrefix = `${prefix}.Actor`
210 const serverPrefix = `${actorPrefix}.Server`
211
212 const serverModel = row[`${serverPrefix}.id`] !== null
213 ? new ServerModel(this.grab(row, this.tables.getServerAttributes(), serverPrefix), this.buildOpts)
214 : null
215
216 if (serverModel) serverModel.BlockedBy = []
217
218 const actorModel = new ActorModel(this.grab(row, this.tables.getActorAttributes(), actorPrefix), this.buildOpts)
219 actorModel.Server = serverModel
220 actorModel.Avatars = []
221
222 return actorModel
223 }
224
225 private setUserHistory (row: SQLRow, videoModel: VideoModel) {
226 const id = row['userVideoHistory.id']
227 if (!id || this.historyDone.has(id)) return
228
229 const attributes = this.grab(row, this.tables.getUserHistoryAttributes(), 'userVideoHistory')
230 const historyModel = new UserVideoHistoryModel(attributes, this.buildOpts)
231 videoModel.UserVideoHistories.push(historyModel)
232
233 this.historyDone.add(id)
234 }
235
236 private addActorAvatar (row: SQLRow, actorPrefix: string, actor: ActorModel) {
237 const avatarPrefix = `${actorPrefix}.Avatars`
238 const id = row[`${avatarPrefix}.id`]
239 const key = `${row.id}${id}`
240
241 if (!id || this.actorImagesDone.has(key)) return
242
243 const attributes = this.grab(row, this.tables.getAvatarAttributes(), avatarPrefix)
244 const avatarModel = new ActorImageModel(attributes, this.buildOpts)
245 actor.Avatars.push(avatarModel)
246
247 this.actorImagesDone.add(key)
248 }
249
250 private addThumbnail (row: SQLRow, videoModel: VideoModel) {
251 const id = row['Thumbnails.id']
252 if (!id || this.thumbnailsDone.has(id)) return
253
254 const attributes = this.grab(row, this.tables.getThumbnailAttributes(), 'Thumbnails')
255 const thumbnailModel = new ThumbnailModel(attributes, this.buildOpts)
256 videoModel.Thumbnails.push(thumbnailModel)
257
258 this.thumbnailsDone.add(id)
259 }
260
261 private addWebVideoFile (row: SQLRow, videoModel: VideoModel) {
262 const id = row['VideoFiles.id']
263 if (!id || this.videoFileMemo[id]) return
264
265 const attributes = this.grab(row, this.tables.getFileAttributes(), 'VideoFiles')
266 const videoFileModel = new VideoFileModel(attributes, this.buildOpts)
267 videoModel.VideoFiles.push(videoFileModel)
268
269 this.videoFileMemo[id] = videoFileModel
270 }
271
272 private addStreamingPlaylist (row: SQLRow, videoModel: VideoModel) {
273 const id = row['VideoStreamingPlaylists.id']
274 if (!id || this.videoStreamingPlaylistMemo[id]) return
275
276 const attributes = this.grab(row, this.tables.getStreamingPlaylistAttributes(), 'VideoStreamingPlaylists')
277 const streamingPlaylist = new VideoStreamingPlaylistModel(attributes, this.buildOpts)
278 streamingPlaylist.VideoFiles = []
279
280 videoModel.VideoStreamingPlaylists.push(streamingPlaylist)
281
282 this.videoStreamingPlaylistMemo[id] = streamingPlaylist
283 }
284
285 private addStreamingPlaylistFile (row: SQLRow) {
286 const id = row['VideoStreamingPlaylists.VideoFiles.id']
287 if (!id || this.videoFileMemo[id]) return
288
289 const streamingPlaylist = this.videoStreamingPlaylistMemo[row['VideoStreamingPlaylists.id']]
290
291 const attributes = this.grab(row, this.tables.getFileAttributes(), 'VideoStreamingPlaylists.VideoFiles')
292 const videoFileModel = new VideoFileModel(attributes, this.buildOpts)
293 streamingPlaylist.VideoFiles.push(videoFileModel)
294
295 this.videoFileMemo[id] = videoFileModel
296 }
297
298 private addRedundancy (row: SQLRow, prefix: string, to: VideoFileModel | VideoStreamingPlaylistModel) {
299 if (!to.RedundancyVideos) to.RedundancyVideos = []
300
301 const redundancyPrefix = `${prefix}.RedundancyVideos`
302 const id = row[`${redundancyPrefix}.id`]
303
304 if (!id || this.redundancyDone.has(id)) return
305
306 const attributes = this.grab(row, this.tables.getRedundancyAttributes(), redundancyPrefix)
307 const redundancyModel = new VideoRedundancyModel(attributes, this.buildOpts)
308 to.RedundancyVideos.push(redundancyModel)
309
310 this.redundancyDone.add(id)
311 }
312
313 private addTag (row: SQLRow, videoModel: VideoModel) {
314 if (!row['Tags.name']) return
315
316 const key = `${row['Tags.VideoTagModel.videoId']}-${row['Tags.VideoTagModel.tagId']}`
317 if (this.tagsDone.has(key)) return
318
319 const attributes = this.grab(row, this.tables.getTagAttributes(), 'Tags')
320 const tagModel = new TagModel(attributes, this.buildOpts)
321 videoModel.Tags.push(tagModel)
322
323 this.tagsDone.add(key)
324 }
325
326 private addTracker (row: SQLRow, videoModel: VideoModel) {
327 if (!row['Trackers.id']) return
328
329 const key = `${row['Trackers.VideoTrackerModel.videoId']}-${row['Trackers.VideoTrackerModel.trackerId']}`
330 if (this.trackersDone.has(key)) return
331
332 const attributes = this.grab(row, this.tables.getTrackerAttributes(), 'Trackers')
333 const trackerModel = new TrackerModel(attributes, this.buildOpts)
334 videoModel.Trackers.push(trackerModel)
335
336 this.trackersDone.add(key)
337 }
338
339 private setBlacklisted (row: SQLRow, videoModel: VideoModel) {
340 const id = row['VideoBlacklist.id']
341 if (!id || this.blacklistDone.has(id)) return
342
343 const attributes = this.grab(row, this.tables.getBlacklistedAttributes(), 'VideoBlacklist')
344 videoModel.VideoBlacklist = new VideoBlacklistModel(attributes, this.buildOpts)
345
346 this.blacklistDone.add(id)
347 }
348
349 private setBlockedOwner (row: SQLRow, videoModel: VideoModel) {
350 const id = row['VideoChannel.Account.AccountBlocklist.id']
351 if (!id) return
352
353 const key = `${videoModel.id}-${id}`
354 if (this.accountBlocklistDone.has(key)) return
355
356 const attributes = this.grab(row, this.tables.getBlocklistAttributes(), 'VideoChannel.Account.AccountBlocklist')
357 videoModel.VideoChannel.Account.BlockedBy.push(new AccountBlocklistModel(attributes, this.buildOpts))
358
359 this.accountBlocklistDone.add(key)
360 }
361
362 private setBlockedServer (row: SQLRow, videoModel: VideoModel) {
363 const id = row['VideoChannel.Account.Actor.Server.ServerBlocklist.id']
364 if (!id || this.serverBlocklistDone.has(id)) return
365
366 const key = `${videoModel.id}-${id}`
367 if (this.serverBlocklistDone.has(key)) return
368
369 const attributes = this.grab(row, this.tables.getBlocklistAttributes(), 'VideoChannel.Account.Actor.Server.ServerBlocklist')
370 videoModel.VideoChannel.Account.Actor.Server.BlockedBy.push(new ServerBlocklistModel(attributes, this.buildOpts))
371
372 this.serverBlocklistDone.add(key)
373 }
374
375 private setScheduleVideoUpdate (row: SQLRow, videoModel: VideoModel) {
376 const id = row['ScheduleVideoUpdate.id']
377 if (!id || this.scheduleVideoUpdateDone.has(id)) return
378
379 const attributes = this.grab(row, this.tables.getScheduleUpdateAttributes(), 'ScheduleVideoUpdate')
380 videoModel.ScheduleVideoUpdate = new ScheduleVideoUpdateModel(attributes, this.buildOpts)
381
382 this.scheduleVideoUpdateDone.add(id)
383 }
384
385 private setLive (row: SQLRow, videoModel: VideoModel) {
386 const id = row['VideoLive.id']
387 if (!id || this.liveDone.has(id)) return
388
389 const attributes = this.grab(row, this.tables.getLiveAttributes(), 'VideoLive')
390 videoModel.VideoLive = new VideoLiveModel(attributes, this.buildOpts)
391
392 this.liveDone.add(id)
393 }
394
395 private grab (row: SQLRow, attributes: string[], prefix: string) {
396 const result: { [ id: string ]: string | number } = {}
397
398 for (const a of attributes) {
399 const key = prefix
400 ? prefix + '.' + a
401 : a
402
403 result[a] = row[key]
404 }
405
406 return result
407 }
408}
diff --git a/server/models/video/sql/video/shared/video-table-attributes.ts b/server/models/video/sql/video/shared/video-table-attributes.ts
deleted file mode 100644
index ef625c57b..000000000
--- a/server/models/video/sql/video/shared/video-table-attributes.ts
+++ /dev/null
@@ -1,273 +0,0 @@
1
2/**
3 *
4 * Class to build video attributes/join names we want to fetch from the database
5 *
6 */
7export class VideoTableAttributes {
8
9 constructor (private readonly mode: 'get' | 'list') {
10
11 }
12
13 getChannelAttributesForUser () {
14 return [ 'id', 'accountId' ]
15 }
16
17 getChannelAttributes () {
18 let attributeKeys = [
19 'id',
20 'name',
21 'description',
22 'actorId'
23 ]
24
25 if (this.mode === 'get') {
26 attributeKeys = attributeKeys.concat([
27 'support',
28 'createdAt',
29 'updatedAt'
30 ])
31 }
32
33 return attributeKeys
34 }
35
36 getUserAccountAttributes () {
37 return [ 'id', 'userId' ]
38 }
39
40 getAccountAttributes () {
41 let attributeKeys = [ 'id', 'name', 'actorId' ]
42
43 if (this.mode === 'get') {
44 attributeKeys = attributeKeys.concat([
45 'description',
46 'userId',
47 'createdAt',
48 'updatedAt'
49 ])
50 }
51
52 return attributeKeys
53 }
54
55 getThumbnailAttributes () {
56 let attributeKeys = [ 'id', 'type', 'filename' ]
57
58 if (this.mode === 'get') {
59 attributeKeys = attributeKeys.concat([
60 'height',
61 'width',
62 'fileUrl',
63 'onDisk',
64 'automaticallyGenerated',
65 'videoId',
66 'videoPlaylistId',
67 'createdAt',
68 'updatedAt'
69 ])
70 }
71
72 return attributeKeys
73 }
74
75 getFileAttributes () {
76 return [
77 'id',
78 'createdAt',
79 'updatedAt',
80 'resolution',
81 'size',
82 'extname',
83 'filename',
84 'fileUrl',
85 'torrentFilename',
86 'torrentUrl',
87 'infoHash',
88 'fps',
89 'metadataUrl',
90 'videoStreamingPlaylistId',
91 'videoId',
92 'storage'
93 ]
94 }
95
96 getStreamingPlaylistAttributes () {
97 return [
98 'id',
99 'playlistUrl',
100 'playlistFilename',
101 'type',
102 'p2pMediaLoaderInfohashes',
103 'p2pMediaLoaderPeerVersion',
104 'segmentsSha256Filename',
105 'segmentsSha256Url',
106 'videoId',
107 'createdAt',
108 'updatedAt',
109 'storage'
110 ]
111 }
112
113 getUserHistoryAttributes () {
114 return [ 'id', 'currentTime' ]
115 }
116
117 getPlaylistAttributes () {
118 return [
119 'createdAt',
120 'updatedAt',
121 'url',
122 'position',
123 'startTimestamp',
124 'stopTimestamp',
125 'videoPlaylistId'
126 ]
127 }
128
129 getTagAttributes () {
130 return [ 'id', 'name' ]
131 }
132
133 getVideoTagAttributes () {
134 return [ 'videoId', 'tagId', 'createdAt', 'updatedAt' ]
135 }
136
137 getBlacklistedAttributes () {
138 return [ 'id', 'reason', 'unfederated' ]
139 }
140
141 getBlocklistAttributes () {
142 return [ 'id' ]
143 }
144
145 getScheduleUpdateAttributes () {
146 return [
147 'id',
148 'updateAt',
149 'privacy',
150 'videoId',
151 'createdAt',
152 'updatedAt'
153 ]
154 }
155
156 getLiveAttributes () {
157 return [
158 'id',
159 'streamKey',
160 'saveReplay',
161 'permanentLive',
162 'latencyMode',
163 'videoId',
164 'replaySettingId',
165 'createdAt',
166 'updatedAt'
167 ]
168 }
169
170 getTrackerAttributes () {
171 return [ 'id', 'url' ]
172 }
173
174 getVideoTrackerAttributes () {
175 return [
176 'videoId',
177 'trackerId',
178 'createdAt',
179 'updatedAt'
180 ]
181 }
182
183 getRedundancyAttributes () {
184 return [ 'id', 'fileUrl' ]
185 }
186
187 getActorAttributes () {
188 let attributeKeys = [
189 'id',
190 'preferredUsername',
191 'url',
192 'serverId'
193 ]
194
195 if (this.mode === 'get') {
196 attributeKeys = attributeKeys.concat([
197 'type',
198 'followersCount',
199 'followingCount',
200 'inboxUrl',
201 'outboxUrl',
202 'sharedInboxUrl',
203 'followersUrl',
204 'followingUrl',
205 'remoteCreatedAt',
206 'createdAt',
207 'updatedAt'
208 ])
209 }
210
211 return attributeKeys
212 }
213
214 getAvatarAttributes () {
215 let attributeKeys = [
216 'id',
217 'width',
218 'filename',
219 'type',
220 'fileUrl',
221 'onDisk',
222 'createdAt',
223 'updatedAt'
224 ]
225
226 if (this.mode === 'get') {
227 attributeKeys = attributeKeys.concat([
228 'height',
229 'width',
230 'type'
231 ])
232 }
233
234 return attributeKeys
235 }
236
237 getServerAttributes () {
238 return [ 'id', 'host' ]
239 }
240
241 getVideoAttributes () {
242 return [
243 'id',
244 'uuid',
245 'name',
246 'category',
247 'licence',
248 'language',
249 'privacy',
250 'nsfw',
251 'description',
252 'support',
253 'duration',
254 'views',
255 'likes',
256 'dislikes',
257 'remote',
258 'isLive',
259 'url',
260 'commentsEnabled',
261 'downloadEnabled',
262 'waitTranscoding',
263 'state',
264 'publishedAt',
265 'originallyPublishedAt',
266 'inputFileUpdatedAt',
267 'channelId',
268 'createdAt',
269 'updatedAt',
270 'moveJobsRunning'
271 ]
272 }
273}
diff --git a/server/models/video/sql/video/video-model-get-query-builder.ts b/server/models/video/sql/video/video-model-get-query-builder.ts
deleted file mode 100644
index 3f43d4d92..000000000
--- a/server/models/video/sql/video/video-model-get-query-builder.ts
+++ /dev/null
@@ -1,189 +0,0 @@
1import { Sequelize, Transaction } from 'sequelize'
2import { pick } from '@shared/core-utils'
3import { AbstractVideoQueryBuilder } from './shared/abstract-video-query-builder'
4import { VideoFileQueryBuilder } from './shared/video-file-query-builder'
5import { VideoModelBuilder } from './shared/video-model-builder'
6import { VideoTableAttributes } from './shared/video-table-attributes'
7
8/**
9 *
10 * Build a GET SQL query, fetch rows and create the video model
11 *
12 */
13
14export type GetType =
15 'api' |
16 'full' |
17 'account-blacklist-files' |
18 'all-files' |
19 'thumbnails' |
20 'thumbnails-blacklist' |
21 'id' |
22 'blacklist-rights'
23
24export type BuildVideoGetQueryOptions = {
25 id?: number | string
26 url?: string
27
28 type: GetType
29
30 userId?: number
31 transaction?: Transaction
32
33 logging?: boolean
34}
35
36export class VideoModelGetQueryBuilder {
37 videoQueryBuilder: VideosModelGetQuerySubBuilder
38 webVideoFilesQueryBuilder: VideoFileQueryBuilder
39 streamingPlaylistFilesQueryBuilder: VideoFileQueryBuilder
40
41 private readonly videoModelBuilder: VideoModelBuilder
42
43 private static readonly videoFilesInclude = new Set<GetType>([ 'api', 'full', 'account-blacklist-files', 'all-files' ])
44
45 constructor (protected readonly sequelize: Sequelize) {
46 this.videoQueryBuilder = new VideosModelGetQuerySubBuilder(sequelize)
47 this.webVideoFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
48 this.streamingPlaylistFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
49
50 this.videoModelBuilder = new VideoModelBuilder('get', new VideoTableAttributes('get'))
51 }
52
53 async queryVideo (options: BuildVideoGetQueryOptions) {
54 const fileQueryOptions = {
55 ...pick(options, [ 'id', 'url', 'transaction', 'logging' ]),
56
57 includeRedundancy: this.shouldIncludeRedundancies(options)
58 }
59
60 const [ videoRows, webVideoFilesRows, streamingPlaylistFilesRows ] = await Promise.all([
61 this.videoQueryBuilder.queryVideos(options),
62
63 VideoModelGetQueryBuilder.videoFilesInclude.has(options.type)
64 ? this.webVideoFilesQueryBuilder.queryWebVideos(fileQueryOptions)
65 : Promise.resolve(undefined),
66
67 VideoModelGetQueryBuilder.videoFilesInclude.has(options.type)
68 ? this.streamingPlaylistFilesQueryBuilder.queryStreamingPlaylistVideos(fileQueryOptions)
69 : Promise.resolve(undefined)
70 ])
71
72 const videos = this.videoModelBuilder.buildVideosFromRows({
73 rows: videoRows,
74 rowsWebVideoFiles: webVideoFilesRows,
75 rowsStreamingPlaylist: streamingPlaylistFilesRows
76 })
77
78 if (videos.length > 1) {
79 throw new Error('Video results is more than 1')
80 }
81
82 if (videos.length === 0) return null
83
84 return videos[0]
85 }
86
87 private shouldIncludeRedundancies (options: BuildVideoGetQueryOptions) {
88 return options.type === 'api'
89 }
90}
91
92export class VideosModelGetQuerySubBuilder extends AbstractVideoQueryBuilder {
93 protected attributes: { [key: string]: string }
94
95 protected webVideoFilesQuery: string
96 protected streamingPlaylistFilesQuery: string
97
98 private static readonly trackersInclude = new Set<GetType>([ 'api' ])
99 private static readonly liveInclude = new Set<GetType>([ 'api', 'full' ])
100 private static readonly scheduleUpdateInclude = new Set<GetType>([ 'api', 'full' ])
101 private static readonly tagsInclude = new Set<GetType>([ 'api', 'full' ])
102 private static readonly userHistoryInclude = new Set<GetType>([ 'api', 'full' ])
103 private static readonly accountInclude = new Set<GetType>([ 'api', 'full', 'account-blacklist-files' ])
104 private static readonly ownerUserInclude = new Set<GetType>([ 'blacklist-rights' ])
105
106 private static readonly blacklistedInclude = new Set<GetType>([
107 'api',
108 'full',
109 'account-blacklist-files',
110 'thumbnails-blacklist',
111 'blacklist-rights'
112 ])
113
114 private static readonly thumbnailsInclude = new Set<GetType>([
115 'api',
116 'full',
117 'account-blacklist-files',
118 'all-files',
119 'thumbnails',
120 'thumbnails-blacklist'
121 ])
122
123 constructor (protected readonly sequelize: Sequelize) {
124 super(sequelize, 'get')
125 }
126
127 queryVideos (options: BuildVideoGetQueryOptions) {
128 this.buildMainGetQuery(options)
129
130 return this.runQuery(options)
131 }
132
133 private buildMainGetQuery (options: BuildVideoGetQueryOptions) {
134 this.attributes = {
135 '"video".*': ''
136 }
137
138 if (VideosModelGetQuerySubBuilder.thumbnailsInclude.has(options.type)) {
139 this.includeThumbnails()
140 }
141
142 if (VideosModelGetQuerySubBuilder.blacklistedInclude.has(options.type)) {
143 this.includeBlacklisted()
144 }
145
146 if (VideosModelGetQuerySubBuilder.accountInclude.has(options.type)) {
147 this.includeChannels()
148 this.includeAccounts()
149 }
150
151 if (VideosModelGetQuerySubBuilder.tagsInclude.has(options.type)) {
152 this.includeTags()
153 }
154
155 if (VideosModelGetQuerySubBuilder.scheduleUpdateInclude.has(options.type)) {
156 this.includeScheduleUpdate()
157 }
158
159 if (VideosModelGetQuerySubBuilder.liveInclude.has(options.type)) {
160 this.includeLive()
161 }
162
163 if (options.userId && VideosModelGetQuerySubBuilder.userHistoryInclude.has(options.type)) {
164 this.includeUserHistory(options.userId)
165 }
166
167 if (VideosModelGetQuerySubBuilder.ownerUserInclude.has(options.type)) {
168 this.includeOwnerUser()
169 }
170
171 if (VideosModelGetQuerySubBuilder.trackersInclude.has(options.type)) {
172 this.includeTrackers()
173 }
174
175 this.whereId(options)
176
177 this.query = this.buildQuery(options)
178 }
179
180 private buildQuery (options: BuildVideoGetQueryOptions) {
181 const order = VideosModelGetQuerySubBuilder.tagsInclude.has(options.type)
182 ? 'ORDER BY "Tags"."name" ASC'
183 : ''
184
185 const from = `SELECT * FROM "video" ${this.where} LIMIT 1`
186
187 return `${this.buildSelect()} FROM (${from}) AS "video" ${this.joins} ${order}`
188 }
189}
diff --git a/server/models/video/sql/video/videos-id-list-query-builder.ts b/server/models/video/sql/video/videos-id-list-query-builder.ts
deleted file mode 100644
index 7f2376102..000000000
--- a/server/models/video/sql/video/videos-id-list-query-builder.ts
+++ /dev/null
@@ -1,728 +0,0 @@
1import { Sequelize, Transaction } from 'sequelize'
2import validator from 'validator'
3import { exists } from '@server/helpers/custom-validators/misc'
4import { WEBSERVER } from '@server/initializers/constants'
5import { buildSortDirectionAndField } from '@server/models/shared'
6import { MUserAccountId, MUserId } from '@server/types/models'
7import { forceNumber } from '@shared/core-utils'
8import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models'
9import { createSafeIn, parseRowCountResult } from '../../../shared'
10import { AbstractRunQuery } from '../../../shared/abstract-run-query'
11
12/**
13 *
14 * Build videos list SQL query to fetch rows
15 *
16 */
17
18export type DisplayOnlyForFollowerOptions = {
19 actorId: number
20 orLocalVideos: boolean
21}
22
23export type BuildVideosListQueryOptions = {
24 attributes?: string[]
25
26 serverAccountIdForBlock: number
27
28 displayOnlyForFollower: DisplayOnlyForFollowerOptions
29
30 count: number
31 start: number
32 sort: string
33
34 nsfw?: boolean
35 host?: string
36 isLive?: boolean
37 isLocal?: boolean
38 include?: VideoInclude
39
40 categoryOneOf?: number[]
41 licenceOneOf?: number[]
42 languageOneOf?: string[]
43 tagsOneOf?: string[]
44 tagsAllOf?: string[]
45 privacyOneOf?: VideoPrivacy[]
46
47 uuids?: string[]
48
49 hasFiles?: boolean
50 hasHLSFiles?: boolean
51
52 hasWebVideoFiles?: boolean
53 hasWebtorrentFiles?: boolean // TODO: Remove in v7
54
55 accountId?: number
56 videoChannelId?: number
57
58 videoPlaylistId?: number
59
60 trendingAlgorithm?: string // best, hot, or any other algorithm implemented
61 trendingDays?: number
62
63 user?: MUserAccountId
64 historyOfUser?: MUserId
65
66 startDate?: string // ISO 8601
67 endDate?: string // ISO 8601
68 originallyPublishedStartDate?: string
69 originallyPublishedEndDate?: string
70
71 durationMin?: number // seconds
72 durationMax?: number // seconds
73
74 search?: string
75
76 isCount?: boolean
77
78 group?: string
79 having?: string
80
81 transaction?: Transaction
82 logging?: boolean
83
84 excludeAlreadyWatched?: boolean
85}
86
87export class VideosIdListQueryBuilder extends AbstractRunQuery {
88 protected replacements: any = {}
89
90 private attributes: string[]
91 private joins: string[] = []
92
93 private readonly and: string[] = []
94
95 private readonly cte: string[] = []
96
97 private group = ''
98 private having = ''
99
100 private sort = ''
101 private limit = ''
102 private offset = ''
103
104 constructor (protected readonly sequelize: Sequelize) {
105 super(sequelize)
106 }
107
108 queryVideoIds (options: BuildVideosListQueryOptions) {
109 this.buildIdsListQuery(options)
110
111 return this.runQuery()
112 }
113
114 countVideoIds (countOptions: BuildVideosListQueryOptions): Promise<number> {
115 this.buildIdsListQuery(countOptions)
116
117 return this.runQuery().then(rows => parseRowCountResult(rows))
118 }
119
120 getQuery (options: BuildVideosListQueryOptions) {
121 this.buildIdsListQuery(options)
122
123 return { query: this.query, sort: this.sort, replacements: this.replacements }
124 }
125
126 private buildIdsListQuery (options: BuildVideosListQueryOptions) {
127 this.attributes = options.attributes || [ '"video"."id"' ]
128
129 if (options.group) this.group = options.group
130 if (options.having) this.having = options.having
131
132 this.joins = this.joins.concat([
133 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId"',
134 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId"',
135 'INNER JOIN "actor" "accountActor" ON "account"."actorId" = "accountActor"."id"'
136 ])
137
138 if (!(options.include & VideoInclude.BLACKLISTED)) {
139 this.whereNotBlacklisted()
140 }
141
142 if (options.serverAccountIdForBlock && !(options.include & VideoInclude.BLOCKED_OWNER)) {
143 this.whereNotBlocked(options.serverAccountIdForBlock, options.user)
144 }
145
146 // Only list published videos
147 if (!(options.include & VideoInclude.NOT_PUBLISHED_STATE)) {
148 this.whereStateAvailable()
149 }
150
151 if (options.videoPlaylistId) {
152 this.joinPlaylist(options.videoPlaylistId)
153 }
154
155 if (exists(options.isLocal)) {
156 this.whereLocal(options.isLocal)
157 }
158
159 if (options.host) {
160 this.whereHost(options.host)
161 }
162
163 if (options.accountId) {
164 this.whereAccountId(options.accountId)
165 }
166
167 if (options.videoChannelId) {
168 this.whereChannelId(options.videoChannelId)
169 }
170
171 if (options.displayOnlyForFollower) {
172 this.whereFollowerActorId(options.displayOnlyForFollower)
173 }
174
175 if (options.hasFiles === true) {
176 this.whereFileExists()
177 }
178
179 if (exists(options.hasWebtorrentFiles)) {
180 this.whereWebVideoFileExists(options.hasWebtorrentFiles)
181 } else if (exists(options.hasWebVideoFiles)) {
182 this.whereWebVideoFileExists(options.hasWebVideoFiles)
183 }
184
185 if (exists(options.hasHLSFiles)) {
186 this.whereHLSFileExists(options.hasHLSFiles)
187 }
188
189 if (options.tagsOneOf) {
190 this.whereTagsOneOf(options.tagsOneOf)
191 }
192
193 if (options.tagsAllOf) {
194 this.whereTagsAllOf(options.tagsAllOf)
195 }
196
197 if (options.privacyOneOf) {
198 this.wherePrivacyOneOf(options.privacyOneOf)
199 } else {
200 // Only list videos with the appropriate privacy
201 this.wherePrivacyAvailable(options.user)
202 }
203
204 if (options.uuids) {
205 this.whereUUIDs(options.uuids)
206 }
207
208 if (options.nsfw === true) {
209 this.whereNSFW()
210 } else if (options.nsfw === false) {
211 this.whereSFW()
212 }
213
214 if (options.isLive === true) {
215 this.whereLive()
216 } else if (options.isLive === false) {
217 this.whereVOD()
218 }
219
220 if (options.categoryOneOf) {
221 this.whereCategoryOneOf(options.categoryOneOf)
222 }
223
224 if (options.licenceOneOf) {
225 this.whereLicenceOneOf(options.licenceOneOf)
226 }
227
228 if (options.languageOneOf) {
229 this.whereLanguageOneOf(options.languageOneOf)
230 }
231
232 // We don't exclude results in this so if we do a count we don't need to add this complex clause
233 if (options.isCount !== true) {
234 if (options.trendingDays) {
235 this.groupForTrending(options.trendingDays)
236 } else if ([ 'best', 'hot' ].includes(options.trendingAlgorithm)) {
237 this.groupForHotOrBest(options.trendingAlgorithm, options.user)
238 }
239 }
240
241 if (options.historyOfUser) {
242 this.joinHistory(options.historyOfUser.id)
243 }
244
245 if (options.startDate) {
246 this.whereStartDate(options.startDate)
247 }
248
249 if (options.endDate) {
250 this.whereEndDate(options.endDate)
251 }
252
253 if (options.originallyPublishedStartDate) {
254 this.whereOriginallyPublishedStartDate(options.originallyPublishedStartDate)
255 }
256
257 if (options.originallyPublishedEndDate) {
258 this.whereOriginallyPublishedEndDate(options.originallyPublishedEndDate)
259 }
260
261 if (options.durationMin) {
262 this.whereDurationMin(options.durationMin)
263 }
264
265 if (options.durationMax) {
266 this.whereDurationMax(options.durationMax)
267 }
268
269 if (options.excludeAlreadyWatched) {
270 if (exists(options.user.id)) {
271 this.whereExcludeAlreadyWatched(options.user.id)
272 } else {
273 throw new Error('Cannot use excludeAlreadyWatched parameter when auth token is not provided')
274 }
275 }
276
277 this.whereSearch(options.search)
278
279 if (options.isCount === true) {
280 this.setCountAttribute()
281 } else {
282 if (exists(options.sort)) {
283 this.setSort(options.sort)
284 }
285
286 if (exists(options.count)) {
287 this.setLimit(options.count)
288 }
289
290 if (exists(options.start)) {
291 this.setOffset(options.start)
292 }
293 }
294
295 const cteString = this.cte.length !== 0
296 ? `WITH ${this.cte.join(', ')} `
297 : ''
298
299 this.query = cteString +
300 'SELECT ' + this.attributes.join(', ') + ' ' +
301 'FROM "video" ' + this.joins.join(' ') + ' ' +
302 'WHERE ' + this.and.join(' AND ') + ' ' +
303 this.group + ' ' +
304 this.having + ' ' +
305 this.sort + ' ' +
306 this.limit + ' ' +
307 this.offset
308 }
309
310 private setCountAttribute () {
311 this.attributes = [ 'COUNT(*) as "total"' ]
312 }
313
314 private joinHistory (userId: number) {
315 this.joins.push('INNER JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId"')
316
317 this.and.push('"userVideoHistory"."userId" = :historyOfUser')
318
319 this.replacements.historyOfUser = userId
320 }
321
322 private joinPlaylist (playlistId: number) {
323 this.joins.push(
324 'INNER JOIN "videoPlaylistElement" "video"."id" = "videoPlaylistElement"."videoId" ' +
325 'AND "videoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
326 )
327
328 this.replacements.videoPlaylistId = playlistId
329 }
330
331 private whereStateAvailable () {
332 this.and.push(
333 `("video"."state" = ${VideoState.PUBLISHED} OR ` +
334 `("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))`
335 )
336 }
337
338 private wherePrivacyAvailable (user?: MUserAccountId) {
339 if (user) {
340 this.and.push(
341 `("video"."privacy" = ${VideoPrivacy.PUBLIC} OR "video"."privacy" = ${VideoPrivacy.INTERNAL})`
342 )
343 } else { // Or only public videos
344 this.and.push(
345 `"video"."privacy" = ${VideoPrivacy.PUBLIC}`
346 )
347 }
348 }
349
350 private whereLocal (isLocal: boolean) {
351 const isRemote = isLocal ? 'FALSE' : 'TRUE'
352
353 this.and.push('"video"."remote" IS ' + isRemote)
354 }
355
356 private whereHost (host: string) {
357 // Local instance
358 if (host === WEBSERVER.HOST) {
359 this.and.push('"accountActor"."serverId" IS NULL')
360 return
361 }
362
363 this.joins.push('INNER JOIN "server" ON "server"."id" = "accountActor"."serverId"')
364
365 this.and.push('"server"."host" = :host')
366 this.replacements.host = host
367 }
368
369 private whereAccountId (accountId: number) {
370 this.and.push('"account"."id" = :accountId')
371 this.replacements.accountId = accountId
372 }
373
374 private whereChannelId (channelId: number) {
375 this.and.push('"videoChannel"."id" = :videoChannelId')
376 this.replacements.videoChannelId = channelId
377 }
378
379 private whereFollowerActorId (options: { actorId: number, orLocalVideos: boolean }) {
380 let query =
381 '(' +
382 ' EXISTS (' + // Videos shared by actors we follow
383 ' SELECT 1 FROM "videoShare" ' +
384 ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' +
385 ' AND "actorFollowShare"."actorId" = :followerActorId AND "actorFollowShare"."state" = \'accepted\' ' +
386 ' WHERE "videoShare"."videoId" = "video"."id"' +
387 ' )' +
388 ' OR' +
389 ' EXISTS (' + // Videos published by channels or accounts we follow
390 ' SELECT 1 from "actorFollow" ' +
391 ' WHERE ("actorFollow"."targetActorId" = "account"."actorId" OR "actorFollow"."targetActorId" = "videoChannel"."actorId") ' +
392 ' AND "actorFollow"."actorId" = :followerActorId ' +
393 ' AND "actorFollow"."state" = \'accepted\'' +
394 ' )'
395
396 if (options.orLocalVideos) {
397 query += ' OR "video"."remote" IS FALSE'
398 }
399
400 query += ')'
401
402 this.and.push(query)
403 this.replacements.followerActorId = options.actorId
404 }
405
406 private whereFileExists () {
407 this.and.push(`(${this.buildWebVideoFileExistsQuery(true)} OR ${this.buildHLSFileExistsQuery(true)})`)
408 }
409
410 private whereWebVideoFileExists (exists: boolean) {
411 this.and.push(this.buildWebVideoFileExistsQuery(exists))
412 }
413
414 private whereHLSFileExists (exists: boolean) {
415 this.and.push(this.buildHLSFileExistsQuery(exists))
416 }
417
418 private buildWebVideoFileExistsQuery (exists: boolean) {
419 const prefix = exists ? '' : 'NOT '
420
421 return prefix + 'EXISTS (SELECT 1 FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id")'
422 }
423
424 private buildHLSFileExistsQuery (exists: boolean) {
425 const prefix = exists ? '' : 'NOT '
426
427 return prefix + 'EXISTS (' +
428 ' SELECT 1 FROM "videoStreamingPlaylist" ' +
429 ' INNER JOIN "videoFile" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ' +
430 ' WHERE "videoStreamingPlaylist"."videoId" = "video"."id"' +
431 ')'
432 }
433
434 private whereTagsOneOf (tagsOneOf: string[]) {
435 const tagsOneOfLower = tagsOneOf.map(t => t.toLowerCase())
436
437 this.and.push(
438 'EXISTS (' +
439 ' SELECT 1 FROM "videoTag" ' +
440 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
441 ' WHERE lower("tag"."name") IN (' + createSafeIn(this.sequelize, tagsOneOfLower) + ') ' +
442 ' AND "video"."id" = "videoTag"."videoId"' +
443 ')'
444 )
445 }
446
447 private whereTagsAllOf (tagsAllOf: string[]) {
448 const tagsAllOfLower = tagsAllOf.map(t => t.toLowerCase())
449
450 this.and.push(
451 'EXISTS (' +
452 ' SELECT 1 FROM "videoTag" ' +
453 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
454 ' WHERE lower("tag"."name") IN (' + createSafeIn(this.sequelize, tagsAllOfLower) + ') ' +
455 ' AND "video"."id" = "videoTag"."videoId" ' +
456 ' GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length +
457 ')'
458 )
459 }
460
461 private wherePrivacyOneOf (privacyOneOf: VideoPrivacy[]) {
462 this.and.push('"video"."privacy" IN (:privacyOneOf)')
463 this.replacements.privacyOneOf = privacyOneOf
464 }
465
466 private whereUUIDs (uuids: string[]) {
467 this.and.push('"video"."uuid" IN (' + createSafeIn(this.sequelize, uuids) + ')')
468 }
469
470 private whereCategoryOneOf (categoryOneOf: number[]) {
471 this.and.push('"video"."category" IN (:categoryOneOf)')
472 this.replacements.categoryOneOf = categoryOneOf
473 }
474
475 private whereLicenceOneOf (licenceOneOf: number[]) {
476 this.and.push('"video"."licence" IN (:licenceOneOf)')
477 this.replacements.licenceOneOf = licenceOneOf
478 }
479
480 private whereLanguageOneOf (languageOneOf: string[]) {
481 const languages = languageOneOf.filter(l => l && l !== '_unknown')
482 const languagesQueryParts: string[] = []
483
484 if (languages.length !== 0) {
485 languagesQueryParts.push('"video"."language" IN (:languageOneOf)')
486 this.replacements.languageOneOf = languages
487
488 languagesQueryParts.push(
489 'EXISTS (' +
490 ' SELECT 1 FROM "videoCaption" WHERE "videoCaption"."language" ' +
491 ' IN (' + createSafeIn(this.sequelize, languages) + ') AND ' +
492 ' "videoCaption"."videoId" = "video"."id"' +
493 ')'
494 )
495 }
496
497 if (languageOneOf.includes('_unknown')) {
498 languagesQueryParts.push('"video"."language" IS NULL')
499 }
500
501 if (languagesQueryParts.length !== 0) {
502 this.and.push('(' + languagesQueryParts.join(' OR ') + ')')
503 }
504 }
505
506 private whereNSFW () {
507 this.and.push('"video"."nsfw" IS TRUE')
508 }
509
510 private whereSFW () {
511 this.and.push('"video"."nsfw" IS FALSE')
512 }
513
514 private whereLive () {
515 this.and.push('"video"."isLive" IS TRUE')
516 }
517
518 private whereVOD () {
519 this.and.push('"video"."isLive" IS FALSE')
520 }
521
522 private whereNotBlocked (serverAccountId: number, user?: MUserAccountId) {
523 const blockerIds = [ serverAccountId ]
524 if (user) blockerIds.push(user.Account.id)
525
526 const inClause = createSafeIn(this.sequelize, blockerIds)
527
528 this.and.push(
529 'NOT EXISTS (' +
530 ' SELECT 1 FROM "accountBlocklist" ' +
531 ' WHERE "accountBlocklist"."accountId" IN (' + inClause + ') ' +
532 ' AND "accountBlocklist"."targetAccountId" = "account"."id" ' +
533 ')' +
534 'AND NOT EXISTS (' +
535 ' SELECT 1 FROM "serverBlocklist" WHERE "serverBlocklist"."accountId" IN (' + inClause + ') ' +
536 ' AND "serverBlocklist"."targetServerId" = "accountActor"."serverId"' +
537 ')'
538 )
539 }
540
541 private whereSearch (search?: string) {
542 if (!search) {
543 this.attributes.push('0 as similarity')
544 return
545 }
546
547 const escapedSearch = this.sequelize.escape(search)
548 const escapedLikeSearch = this.sequelize.escape('%' + search + '%')
549
550 this.cte.push(
551 '"trigramSearch" AS (' +
552 ' SELECT "video"."id", ' +
553 ` similarity(lower(immutable_unaccent("video"."name")), lower(immutable_unaccent(${escapedSearch}))) as similarity ` +
554 ' FROM "video" ' +
555 ' WHERE lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
556 ' lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
557 ')'
558 )
559
560 this.joins.push('LEFT JOIN "trigramSearch" ON "video"."id" = "trigramSearch"."id"')
561
562 let base = '(' +
563 ' "trigramSearch"."id" IS NOT NULL OR ' +
564 ' EXISTS (' +
565 ' SELECT 1 FROM "videoTag" ' +
566 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
567 ` WHERE lower("tag"."name") = lower(${escapedSearch}) ` +
568 ' AND "video"."id" = "videoTag"."videoId"' +
569 ' )'
570
571 if (validator.isUUID(search)) {
572 base += ` OR "video"."uuid" = ${escapedSearch}`
573 }
574
575 base += ')'
576
577 this.and.push(base)
578 this.attributes.push(`COALESCE("trigramSearch"."similarity", 0) as similarity`)
579 }
580
581 private whereNotBlacklisted () {
582 this.and.push('"video"."id" NOT IN (SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")')
583 }
584
585 private whereStartDate (startDate: string) {
586 this.and.push('"video"."publishedAt" >= :startDate')
587 this.replacements.startDate = startDate
588 }
589
590 private whereEndDate (endDate: string) {
591 this.and.push('"video"."publishedAt" <= :endDate')
592 this.replacements.endDate = endDate
593 }
594
595 private whereOriginallyPublishedStartDate (startDate: string) {
596 this.and.push('"video"."originallyPublishedAt" >= :originallyPublishedStartDate')
597 this.replacements.originallyPublishedStartDate = startDate
598 }
599
600 private whereOriginallyPublishedEndDate (endDate: string) {
601 this.and.push('"video"."originallyPublishedAt" <= :originallyPublishedEndDate')
602 this.replacements.originallyPublishedEndDate = endDate
603 }
604
605 private whereDurationMin (durationMin: number) {
606 this.and.push('"video"."duration" >= :durationMin')
607 this.replacements.durationMin = durationMin
608 }
609
610 private whereDurationMax (durationMax: number) {
611 this.and.push('"video"."duration" <= :durationMax')
612 this.replacements.durationMax = durationMax
613 }
614
615 private whereExcludeAlreadyWatched (userId: number) {
616 this.and.push(
617 'NOT EXISTS (' +
618 ' SELECT 1' +
619 ' FROM "userVideoHistory"' +
620 ' WHERE "video"."id" = "userVideoHistory"."videoId"' +
621 ' AND "userVideoHistory"."userId" = :excludeAlreadyWatchedUserId' +
622 ')'
623 )
624 this.replacements.excludeAlreadyWatchedUserId = userId
625 }
626
627 private groupForTrending (trendingDays: number) {
628 const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
629
630 this.joins.push('LEFT JOIN "videoView" ON "video"."id" = "videoView"."videoId" AND "videoView"."startDate" >= :viewsGteDate')
631 this.replacements.viewsGteDate = viewsGteDate
632
633 this.attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "score"')
634
635 this.group = 'GROUP BY "video"."id"'
636 }
637
638 private groupForHotOrBest (trendingAlgorithm: string, user?: MUserAccountId) {
639 /**
640 * "Hotness" is a measure based on absolute view/comment/like/dislike numbers,
641 * with fixed weights only applied to their log values.
642 *
643 * This algorithm gives little chance for an old video to have a good score,
644 * for which recent spikes in interactions could be a sign of "hotness" and
645 * justify a better score. However there are multiple ways to achieve that
646 * goal, which is left for later. Yes, this is a TODO :)
647 *
648 * notes:
649 * - weights and base score are in number of half-days.
650 * - all comments are counted, regardless of being written by the video author or not
651 * see https://github.com/reddit-archive/reddit/blob/master/r2/r2/lib/db/_sorts.pyx#L47-L58
652 * - we have less interactions than on reddit, so multiply weights by an arbitrary factor
653 */
654 const weights = {
655 like: 3 * 50,
656 dislike: -3 * 50,
657 view: Math.floor((1 / 3) * 50),
658 comment: 2 * 50, // a comment takes more time than a like to do, but can be done multiple times
659 history: -2 * 50
660 }
661
662 this.joins.push('LEFT JOIN "videoComment" ON "video"."id" = "videoComment"."videoId"')
663
664 let attribute =
665 `LOG(GREATEST(1, "video"."likes" - 1)) * ${weights.like} ` + // likes (+)
666 `+ LOG(GREATEST(1, "video"."dislikes" - 1)) * ${weights.dislike} ` + // dislikes (-)
667 `+ LOG("video"."views" + 1) * ${weights.view} ` + // views (+)
668 `+ LOG(GREATEST(1, COUNT(DISTINCT "videoComment"."id"))) * ${weights.comment} ` + // comments (+)
669 '+ (SELECT (EXTRACT(epoch FROM "video"."publishedAt") - 1446156582) / 47000) ' // base score (in number of half-days)
670
671 if (trendingAlgorithm === 'best' && user) {
672 this.joins.push(
673 'LEFT JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :bestUser'
674 )
675 this.replacements.bestUser = user.id
676
677 attribute += `+ POWER(COUNT(DISTINCT "userVideoHistory"."id"), 2.0) * ${weights.history} `
678 }
679
680 attribute += 'AS "score"'
681 this.attributes.push(attribute)
682
683 this.group = 'GROUP BY "video"."id"'
684 }
685
686 private setSort (sort: string) {
687 if (sort === '-originallyPublishedAt' || sort === 'originallyPublishedAt') {
688 this.attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"')
689 }
690
691 this.sort = this.buildOrder(sort)
692 }
693
694 private buildOrder (value: string) {
695 const { direction, field } = buildSortDirectionAndField(value)
696 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
697
698 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
699
700 if ([ 'trending', 'hot', 'best' ].includes(field.toLowerCase())) { // Sort by aggregation
701 return `ORDER BY "score" ${direction}, "video"."views" ${direction}`
702 }
703
704 let firstSort: string
705
706 if (field.toLowerCase() === 'match') { // Search
707 firstSort = '"similarity"'
708 } else if (field === 'originallyPublishedAt') {
709 firstSort = '"publishedAtForOrder"'
710 } else if (field.includes('.')) {
711 firstSort = field
712 } else {
713 firstSort = `"video"."${field}"`
714 }
715
716 return `ORDER BY ${firstSort} ${direction}, "video"."id" ASC`
717 }
718
719 private setLimit (countArg: number) {
720 const count = forceNumber(countArg)
721 this.limit = `LIMIT ${count}`
722 }
723
724 private setOffset (startArg: number) {
725 const start = forceNumber(startArg)
726 this.offset = `OFFSET ${start}`
727 }
728}
diff --git a/server/models/video/sql/video/videos-model-list-query-builder.ts b/server/models/video/sql/video/videos-model-list-query-builder.ts
deleted file mode 100644
index b73dc28cd..000000000
--- a/server/models/video/sql/video/videos-model-list-query-builder.ts
+++ /dev/null
@@ -1,103 +0,0 @@
1import { Sequelize } from 'sequelize'
2import { pick } from '@shared/core-utils'
3import { VideoInclude } from '@shared/models'
4import { AbstractVideoQueryBuilder } from './shared/abstract-video-query-builder'
5import { VideoFileQueryBuilder } from './shared/video-file-query-builder'
6import { VideoModelBuilder } from './shared/video-model-builder'
7import { BuildVideosListQueryOptions, VideosIdListQueryBuilder } from './videos-id-list-query-builder'
8
9/**
10 *
11 * Build videos list SQL query and create video models
12 *
13 */
14
15export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder {
16 protected attributes: { [key: string]: string }
17
18 private innerQuery: string
19 private innerSort: string
20
21 webVideoFilesQueryBuilder: VideoFileQueryBuilder
22 streamingPlaylistFilesQueryBuilder: VideoFileQueryBuilder
23
24 private readonly videoModelBuilder: VideoModelBuilder
25
26 constructor (protected readonly sequelize: Sequelize) {
27 super(sequelize, 'list')
28
29 this.videoModelBuilder = new VideoModelBuilder(this.mode, this.tables)
30 this.webVideoFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
31 this.streamingPlaylistFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
32 }
33
34 async queryVideos (options: BuildVideosListQueryOptions) {
35 this.buildInnerQuery(options)
36 this.buildMainQuery(options)
37
38 const rows = await this.runQuery()
39
40 if (options.include & VideoInclude.FILES) {
41 const videoIds = Array.from(new Set(rows.map(r => r.id)))
42
43 if (videoIds.length !== 0) {
44 const fileQueryOptions = {
45 ...pick(options, [ 'transaction', 'logging' ]),
46
47 ids: videoIds,
48 includeRedundancy: false
49 }
50
51 const [ rowsWebVideoFiles, rowsStreamingPlaylist ] = await Promise.all([
52 this.webVideoFilesQueryBuilder.queryWebVideos(fileQueryOptions),
53 this.streamingPlaylistFilesQueryBuilder.queryStreamingPlaylistVideos(fileQueryOptions)
54 ])
55
56 return this.videoModelBuilder.buildVideosFromRows({ rows, include: options.include, rowsStreamingPlaylist, rowsWebVideoFiles })
57 }
58 }
59
60 return this.videoModelBuilder.buildVideosFromRows({ rows, include: options.include })
61 }
62
63 private buildInnerQuery (options: BuildVideosListQueryOptions) {
64 const idsQueryBuilder = new VideosIdListQueryBuilder(this.sequelize)
65 const { query, sort, replacements } = idsQueryBuilder.getQuery(options)
66
67 this.replacements = replacements
68 this.innerQuery = query
69 this.innerSort = sort
70 }
71
72 private buildMainQuery (options: BuildVideosListQueryOptions) {
73 this.attributes = {
74 '"video".*': ''
75 }
76
77 this.addJoin('INNER JOIN "video" ON "tmp"."id" = "video"."id"')
78
79 this.includeChannels()
80 this.includeAccounts()
81 this.includeThumbnails()
82
83 if (options.user) {
84 this.includeUserHistory(options.user.id)
85 }
86
87 if (options.videoPlaylistId) {
88 this.includePlaylist(options.videoPlaylistId)
89 }
90
91 if (options.include & VideoInclude.BLACKLISTED) {
92 this.includeBlacklisted()
93 }
94
95 if (options.include & VideoInclude.BLOCKED_OWNER) {
96 this.includeBlockedOwnerAndServer(options.serverAccountIdForBlock, options.user)
97 }
98
99 const select = this.buildSelect()
100
101 this.query = `${select} FROM (${this.innerQuery}) AS "tmp" ${this.joins} ${this.innerSort}`
102 }
103}
diff --git a/server/models/video/storyboard.ts b/server/models/video/storyboard.ts
deleted file mode 100644
index 1c3c6d850..000000000
--- a/server/models/video/storyboard.ts
+++ /dev/null
@@ -1,169 +0,0 @@
1import { remove } from 'fs-extra'
2import { join } from 'path'
3import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
4import { CONFIG } from '@server/initializers/config'
5import { MStoryboard, MStoryboardVideo, MVideo } from '@server/types/models'
6import { Storyboard } from '@shared/models'
7import { AttributesOnly } from '@shared/typescript-utils'
8import { logger } from '../../helpers/logger'
9import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
10import { VideoModel } from './video'
11import { Transaction } from 'sequelize'
12
13@Table({
14 tableName: 'storyboard',
15 indexes: [
16 {
17 fields: [ 'videoId' ],
18 unique: true
19 },
20 {
21 fields: [ 'filename' ],
22 unique: true
23 }
24 ]
25})
26export class StoryboardModel extends Model<Partial<AttributesOnly<StoryboardModel>>> {
27
28 @AllowNull(false)
29 @Column
30 filename: string
31
32 @AllowNull(false)
33 @Column
34 totalHeight: number
35
36 @AllowNull(false)
37 @Column
38 totalWidth: number
39
40 @AllowNull(false)
41 @Column
42 spriteHeight: number
43
44 @AllowNull(false)
45 @Column
46 spriteWidth: number
47
48 @AllowNull(false)
49 @Column
50 spriteDuration: number
51
52 @AllowNull(true)
53 @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
54 fileUrl: string
55
56 @ForeignKey(() => VideoModel)
57 @Column
58 videoId: number
59
60 @BelongsTo(() => VideoModel, {
61 foreignKey: {
62 allowNull: false
63 },
64 onDelete: 'CASCADE'
65 })
66 Video: VideoModel
67
68 @CreatedAt
69 createdAt: Date
70
71 @UpdatedAt
72 updatedAt: Date
73
74 @AfterDestroy
75 static removeInstanceFile (instance: StoryboardModel) {
76 logger.info('Removing storyboard file %s.', instance.filename)
77
78 // Don't block the transaction
79 instance.removeFile()
80 .catch(err => logger.error('Cannot remove storyboard file %s.', instance.filename, { err }))
81 }
82
83 static loadByVideo (videoId: number, transaction?: Transaction): Promise<MStoryboard> {
84 const query = {
85 where: {
86 videoId
87 },
88 transaction
89 }
90
91 return StoryboardModel.findOne(query)
92 }
93
94 static loadByFilename (filename: string): Promise<MStoryboard> {
95 const query = {
96 where: {
97 filename
98 }
99 }
100
101 return StoryboardModel.findOne(query)
102 }
103
104 static loadWithVideoByFilename (filename: string): Promise<MStoryboardVideo> {
105 const query = {
106 where: {
107 filename
108 },
109 include: [
110 {
111 model: VideoModel.unscoped(),
112 required: true
113 }
114 ]
115 }
116
117 return StoryboardModel.findOne(query)
118 }
119
120 // ---------------------------------------------------------------------------
121
122 static async listStoryboardsOf (video: MVideo): Promise<MStoryboardVideo[]> {
123 const query = {
124 where: {
125 videoId: video.id
126 }
127 }
128
129 const storyboards = await StoryboardModel.findAll<MStoryboard>(query)
130
131 return storyboards.map(s => Object.assign(s, { Video: video }))
132 }
133
134 // ---------------------------------------------------------------------------
135
136 getOriginFileUrl (video: MVideo) {
137 if (video.isOwned()) {
138 return WEBSERVER.URL + this.getLocalStaticPath()
139 }
140
141 return this.fileUrl
142 }
143
144 getLocalStaticPath () {
145 return LAZY_STATIC_PATHS.STORYBOARDS + this.filename
146 }
147
148 getPath () {
149 return join(CONFIG.STORAGE.STORYBOARDS_DIR, this.filename)
150 }
151
152 removeFile () {
153 return remove(this.getPath())
154 }
155
156 toFormattedJSON (this: MStoryboardVideo): Storyboard {
157 return {
158 storyboardPath: this.getLocalStaticPath(),
159
160 totalHeight: this.totalHeight,
161 totalWidth: this.totalWidth,
162
163 spriteWidth: this.spriteWidth,
164 spriteHeight: this.spriteHeight,
165
166 spriteDuration: this.spriteDuration
167 }
168 }
169}
diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts
deleted file mode 100644
index cebde3755..000000000
--- a/server/models/video/tag.ts
+++ /dev/null
@@ -1,86 +0,0 @@
1import { col, fn, QueryTypes, Transaction } from 'sequelize'
2import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MTag } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoPrivacy, VideoState } from '../../../shared/models/videos'
6import { isVideoTagValid } from '../../helpers/custom-validators/videos'
7import { throwIfNotValid } from '../shared'
8import { VideoModel } from './video'
9import { VideoTagModel } from './video-tag'
10
11@Table({
12 tableName: 'tag',
13 timestamps: false,
14 indexes: [
15 {
16 fields: [ 'name' ],
17 unique: true
18 },
19 {
20 name: 'tag_lower_name',
21 fields: [ fn('lower', col('name')) ]
22 }
23 ]
24})
25export class TagModel extends Model<Partial<AttributesOnly<TagModel>>> {
26
27 @AllowNull(false)
28 @Is('VideoTag', value => throwIfNotValid(value, isVideoTagValid, 'tag'))
29 @Column
30 name: string
31
32 @CreatedAt
33 createdAt: Date
34
35 @UpdatedAt
36 updatedAt: Date
37
38 @BelongsToMany(() => VideoModel, {
39 foreignKey: 'tagId',
40 through: () => VideoTagModel,
41 onDelete: 'CASCADE'
42 })
43 Videos: VideoModel[]
44
45 static findOrCreateTags (tags: string[], transaction: Transaction): Promise<MTag[]> {
46 if (tags === null) return Promise.resolve([])
47
48 const uniqueTags = new Set(tags)
49
50 const tasks = Array.from(uniqueTags).map(tag => {
51 const query = {
52 where: {
53 name: tag
54 },
55 defaults: {
56 name: tag
57 },
58 transaction
59 }
60
61 return TagModel.findOrCreate<MTag>(query)
62 .then(([ tagInstance ]) => tagInstance)
63 })
64
65 return Promise.all(tasks)
66 }
67
68 // threshold corresponds to how many video the field should have to be returned
69 static getRandomSamples (threshold: number, count: number): Promise<string[]> {
70 const query = 'SELECT tag.name FROM tag ' +
71 'INNER JOIN "videoTag" ON "videoTag"."tagId" = tag.id ' +
72 'INNER JOIN video ON video.id = "videoTag"."videoId" ' +
73 'WHERE video.privacy = $videoPrivacy AND video.state = $videoState ' +
74 'GROUP BY tag.name HAVING COUNT(tag.name) >= $threshold ' +
75 'ORDER BY random() ' +
76 'LIMIT $count'
77
78 const options = {
79 bind: { threshold, count, videoPrivacy: VideoPrivacy.PUBLIC, videoState: VideoState.PUBLISHED },
80 type: QueryTypes.SELECT as QueryTypes.SELECT
81 }
82
83 return TagModel.sequelize.query<{ name: string }>(query, options)
84 .then(data => data.map(d => d.name))
85 }
86}
diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts
deleted file mode 100644
index 1722acdb4..000000000
--- a/server/models/video/thumbnail.ts
+++ /dev/null
@@ -1,208 +0,0 @@
1import { remove } from 'fs-extra'
2import { join } from 'path'
3import {
4 AfterDestroy,
5 AllowNull,
6 BeforeCreate,
7 BeforeUpdate,
8 BelongsTo,
9 Column,
10 CreatedAt,
11 DataType,
12 Default,
13 ForeignKey,
14 Model,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
18import { afterCommitIfTransaction } from '@server/helpers/database-utils'
19import { MThumbnail, MThumbnailVideo, MVideo } from '@server/types/models'
20import { AttributesOnly } from '@shared/typescript-utils'
21import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
22import { logger } from '../../helpers/logger'
23import { CONFIG } from '../../initializers/config'
24import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
25import { VideoModel } from './video'
26import { VideoPlaylistModel } from './video-playlist'
27
28@Table({
29 tableName: 'thumbnail',
30 indexes: [
31 {
32 fields: [ 'videoId' ]
33 },
34 {
35 fields: [ 'videoPlaylistId' ],
36 unique: true
37 },
38 {
39 fields: [ 'filename', 'type' ],
40 unique: true
41 }
42 ]
43})
44export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>>> {
45
46 @AllowNull(false)
47 @Column
48 filename: string
49
50 @AllowNull(true)
51 @Default(null)
52 @Column
53 height: number
54
55 @AllowNull(true)
56 @Default(null)
57 @Column
58 width: number
59
60 @AllowNull(false)
61 @Column
62 type: ThumbnailType
63
64 @AllowNull(true)
65 @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
66 fileUrl: string
67
68 @AllowNull(true)
69 @Column
70 automaticallyGenerated: boolean
71
72 @AllowNull(false)
73 @Column
74 onDisk: boolean
75
76 @ForeignKey(() => VideoModel)
77 @Column
78 videoId: number
79
80 @BelongsTo(() => VideoModel, {
81 foreignKey: {
82 allowNull: true
83 },
84 onDelete: 'CASCADE'
85 })
86 Video: VideoModel
87
88 @ForeignKey(() => VideoPlaylistModel)
89 @Column
90 videoPlaylistId: number
91
92 @BelongsTo(() => VideoPlaylistModel, {
93 foreignKey: {
94 allowNull: true
95 },
96 onDelete: 'CASCADE'
97 })
98 VideoPlaylist: VideoPlaylistModel
99
100 @CreatedAt
101 createdAt: Date
102
103 @UpdatedAt
104 updatedAt: Date
105
106 // If this thumbnail replaced existing one, track the old name
107 previousThumbnailFilename: string
108
109 private static readonly types: { [ id in ThumbnailType ]: { label: string, directory: string, staticPath: string } } = {
110 [ThumbnailType.MINIATURE]: {
111 label: 'miniature',
112 directory: CONFIG.STORAGE.THUMBNAILS_DIR,
113 staticPath: LAZY_STATIC_PATHS.THUMBNAILS
114 },
115 [ThumbnailType.PREVIEW]: {
116 label: 'preview',
117 directory: CONFIG.STORAGE.PREVIEWS_DIR,
118 staticPath: LAZY_STATIC_PATHS.PREVIEWS
119 }
120 }
121
122 @BeforeCreate
123 @BeforeUpdate
124 static removeOldFile (instance: ThumbnailModel, options) {
125 return afterCommitIfTransaction(options.transaction, () => instance.removePreviousFilenameIfNeeded())
126 }
127
128 @AfterDestroy
129 static removeFiles (instance: ThumbnailModel) {
130 logger.info('Removing %s file %s.', ThumbnailModel.types[instance.type].label, instance.filename)
131
132 // Don't block the transaction
133 instance.removeThumbnail()
134 .catch(err => logger.error('Cannot remove thumbnail file %s.', instance.filename, { err }))
135 }
136
137 static loadByFilename (filename: string, thumbnailType: ThumbnailType): Promise<MThumbnail> {
138 const query = {
139 where: {
140 filename,
141 type: thumbnailType
142 }
143 }
144
145 return ThumbnailModel.findOne(query)
146 }
147
148 static loadWithVideoByFilename (filename: string, thumbnailType: ThumbnailType): Promise<MThumbnailVideo> {
149 const query = {
150 where: {
151 filename,
152 type: thumbnailType
153 },
154 include: [
155 {
156 model: VideoModel.unscoped(),
157 required: true
158 }
159 ]
160 }
161
162 return ThumbnailModel.findOne(query)
163 }
164
165 static buildPath (type: ThumbnailType, filename: string) {
166 const directory = ThumbnailModel.types[type].directory
167
168 return join(directory, filename)
169 }
170
171 getOriginFileUrl (video: MVideo) {
172 const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename
173
174 if (video.isOwned()) return WEBSERVER.URL + staticPath
175
176 return this.fileUrl
177 }
178
179 getLocalStaticPath () {
180 return ThumbnailModel.types[this.type].staticPath + this.filename
181 }
182
183 getPath () {
184 return ThumbnailModel.buildPath(this.type, this.filename)
185 }
186
187 getPreviousPath () {
188 return ThumbnailModel.buildPath(this.type, this.previousThumbnailFilename)
189 }
190
191 removeThumbnail () {
192 return remove(this.getPath())
193 }
194
195 removePreviousFilenameIfNeeded () {
196 if (!this.previousThumbnailFilename) return
197
198 const previousPath = this.getPreviousPath()
199 remove(previousPath)
200 .catch(err => logger.error('Cannot remove previous thumbnail file %s.', previousPath, { err }))
201
202 this.previousThumbnailFilename = undefined
203 }
204
205 isOwned () {
206 return !this.fileUrl
207 }
208}
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
deleted file mode 100644
index 9247d0e2b..000000000
--- a/server/models/video/video-blacklist.ts
+++ /dev/null
@@ -1,134 +0,0 @@
1import { FindOptions } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MVideoBlacklist, MVideoBlacklistFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
6import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
7import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
8import { getBlacklistSort, searchAttribute, throwIfNotValid } from '../shared'
9import { ThumbnailModel } from './thumbnail'
10import { VideoModel } from './video'
11import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
12
13@Table({
14 tableName: 'videoBlacklist',
15 indexes: [
16 {
17 fields: [ 'videoId' ],
18 unique: true
19 }
20 ]
21})
22export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlacklistModel>>> {
23
24 @AllowNull(true)
25 @Is('VideoBlacklistReason', value => throwIfNotValid(value, isVideoBlacklistReasonValid, 'reason', true))
26 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max))
27 reason: string
28
29 @AllowNull(false)
30 @Column
31 unfederated: boolean
32
33 @AllowNull(false)
34 @Default(null)
35 @Is('VideoBlacklistType', value => throwIfNotValid(value, isVideoBlacklistTypeValid, 'type'))
36 @Column
37 type: VideoBlacklistType
38
39 @CreatedAt
40 createdAt: Date
41
42 @UpdatedAt
43 updatedAt: Date
44
45 @ForeignKey(() => VideoModel)
46 @Column
47 videoId: number
48
49 @BelongsTo(() => VideoModel, {
50 foreignKey: {
51 allowNull: false
52 },
53 onDelete: 'cascade'
54 })
55 Video: VideoModel
56
57 static listForApi (parameters: {
58 start: number
59 count: number
60 sort: string
61 search?: string
62 type?: VideoBlacklistType
63 }) {
64 const { start, count, sort, search, type } = parameters
65
66 function buildBaseQuery (): FindOptions {
67 return {
68 offset: start,
69 limit: count,
70 order: getBlacklistSort(sort)
71 }
72 }
73
74 const countQuery = buildBaseQuery()
75
76 const findQuery = buildBaseQuery()
77 findQuery.include = [
78 {
79 model: VideoModel,
80 required: true,
81 where: searchAttribute(search, 'name'),
82 include: [
83 {
84 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
85 required: true
86 },
87 {
88 model: ThumbnailModel,
89 attributes: [ 'type', 'filename' ],
90 required: false
91 }
92 ]
93 }
94 ]
95
96 if (type) {
97 countQuery.where = { type }
98 findQuery.where = { type }
99 }
100
101 return Promise.all([
102 VideoBlacklistModel.count(countQuery),
103 VideoBlacklistModel.findAll(findQuery)
104 ]).then(([ count, rows ]) => {
105 return {
106 data: rows,
107 total: count
108 }
109 })
110 }
111
112 static loadByVideoId (id: number): Promise<MVideoBlacklist> {
113 const query = {
114 where: {
115 videoId: id
116 }
117 }
118
119 return VideoBlacklistModel.findOne(query)
120 }
121
122 toFormattedJSON (this: MVideoBlacklistFormattable): VideoBlacklist {
123 return {
124 id: this.id,
125 createdAt: this.createdAt,
126 updatedAt: this.updatedAt,
127 reason: this.reason,
128 unfederated: this.unfederated,
129 type: this.type,
130
131 video: this.Video.toFormattedJSON()
132 }
133 }
134}
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
deleted file mode 100644
index dd4cefd65..000000000
--- a/server/models/video/video-caption.ts
+++ /dev/null
@@ -1,247 +0,0 @@
1import { remove } from 'fs-extra'
2import { join } from 'path'
3import { Op, OrderItem, Transaction } from 'sequelize'
4import {
5 AllowNull,
6 BeforeDestroy,
7 BelongsTo,
8 Column,
9 CreatedAt,
10 DataType,
11 ForeignKey,
12 Is,
13 Model,
14 Scopes,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
18import { MVideo, MVideoCaption, MVideoCaptionFormattable, MVideoCaptionLanguageUrl, MVideoCaptionVideo } from '@server/types/models'
19import { buildUUID } from '@shared/extra-utils'
20import { AttributesOnly } from '@shared/typescript-utils'
21import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model'
22import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
23import { logger } from '../../helpers/logger'
24import { CONFIG } from '../../initializers/config'
25import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants'
26import { buildWhereIdOrUUID, throwIfNotValid } from '../shared'
27import { VideoModel } from './video'
28
29export enum ScopeNames {
30 WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE'
31}
32
33@Scopes(() => ({
34 [ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: {
35 include: [
36 {
37 attributes: [ 'id', 'uuid', 'remote' ],
38 model: VideoModel.unscoped(),
39 required: true
40 }
41 ]
42 }
43}))
44
45@Table({
46 tableName: 'videoCaption',
47 indexes: [
48 {
49 fields: [ 'filename' ],
50 unique: true
51 },
52 {
53 fields: [ 'videoId' ]
54 },
55 {
56 fields: [ 'videoId', 'language' ],
57 unique: true
58 }
59 ]
60})
61export class VideoCaptionModel extends Model<Partial<AttributesOnly<VideoCaptionModel>>> {
62 @CreatedAt
63 createdAt: Date
64
65 @UpdatedAt
66 updatedAt: Date
67
68 @AllowNull(false)
69 @Is('VideoCaptionLanguage', value => throwIfNotValid(value, isVideoCaptionLanguageValid, 'language'))
70 @Column
71 language: string
72
73 @AllowNull(false)
74 @Column
75 filename: string
76
77 @AllowNull(true)
78 @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
79 fileUrl: string
80
81 @ForeignKey(() => VideoModel)
82 @Column
83 videoId: number
84
85 @BelongsTo(() => VideoModel, {
86 foreignKey: {
87 allowNull: false
88 },
89 onDelete: 'CASCADE'
90 })
91 Video: VideoModel
92
93 @BeforeDestroy
94 static async removeFiles (instance: VideoCaptionModel, options) {
95 if (!instance.Video) {
96 instance.Video = await instance.$get('Video', { transaction: options.transaction })
97 }
98
99 if (instance.isOwned()) {
100 logger.info('Removing caption %s.', instance.filename)
101
102 try {
103 await instance.removeCaptionFile()
104 } catch (err) {
105 logger.error('Cannot remove caption file %s.', instance.filename)
106 }
107 }
108
109 return undefined
110 }
111
112 static loadByVideoIdAndLanguage (videoId: string | number, language: string, transaction?: Transaction): Promise<MVideoCaptionVideo> {
113 const videoInclude = {
114 model: VideoModel.unscoped(),
115 attributes: [ 'id', 'remote', 'uuid' ],
116 where: buildWhereIdOrUUID(videoId)
117 }
118
119 const query = {
120 where: {
121 language
122 },
123 include: [
124 videoInclude
125 ],
126 transaction
127 }
128
129 return VideoCaptionModel.findOne(query)
130 }
131
132 static loadWithVideoByFilename (filename: string): Promise<MVideoCaptionVideo> {
133 const query = {
134 where: {
135 filename
136 },
137 include: [
138 {
139 model: VideoModel.unscoped(),
140 attributes: [ 'id', 'remote', 'uuid' ]
141 }
142 ]
143 }
144
145 return VideoCaptionModel.findOne(query)
146 }
147
148 static async insertOrReplaceLanguage (caption: MVideoCaption, transaction: Transaction) {
149 const existing = await VideoCaptionModel.loadByVideoIdAndLanguage(caption.videoId, caption.language, transaction)
150
151 // Delete existing file
152 if (existing) await existing.destroy({ transaction })
153
154 return caption.save({ transaction })
155 }
156
157 static listVideoCaptions (videoId: number, transaction?: Transaction): Promise<MVideoCaptionVideo[]> {
158 const query = {
159 order: [ [ 'language', 'ASC' ] ] as OrderItem[],
160 where: {
161 videoId
162 },
163 transaction
164 }
165
166 return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
167 }
168
169 static async listCaptionsOfMultipleVideos (videoIds: number[], transaction?: Transaction) {
170 const query = {
171 order: [ [ 'language', 'ASC' ] ] as OrderItem[],
172 where: {
173 videoId: {
174 [Op.in]: videoIds
175 }
176 },
177 transaction
178 }
179
180 const captions = await VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll<MVideoCaptionVideo>(query)
181 const result: { [ id: number ]: MVideoCaptionVideo[] } = {}
182
183 for (const id of videoIds) {
184 result[id] = []
185 }
186
187 for (const caption of captions) {
188 result[caption.videoId].push(caption)
189 }
190
191 return result
192 }
193
194 static getLanguageLabel (language: string) {
195 return VIDEO_LANGUAGES[language] || 'Unknown'
196 }
197
198 static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Transaction) {
199 const query = {
200 where: {
201 videoId
202 },
203 transaction
204 }
205
206 return VideoCaptionModel.destroy(query)
207 }
208
209 static generateCaptionName (language: string) {
210 return `${buildUUID()}-${language}.vtt`
211 }
212
213 isOwned () {
214 return this.Video.remote === false
215 }
216
217 toFormattedJSON (this: MVideoCaptionFormattable): VideoCaption {
218 return {
219 language: {
220 id: this.language,
221 label: VideoCaptionModel.getLanguageLabel(this.language)
222 },
223 captionPath: this.getCaptionStaticPath(),
224 updatedAt: this.updatedAt.toISOString()
225 }
226 }
227
228 getCaptionStaticPath (this: MVideoCaptionLanguageUrl) {
229 return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.filename)
230 }
231
232 removeCaptionFile (this: MVideoCaption) {
233 return remove(CONFIG.STORAGE.CAPTIONS_DIR + this.filename)
234 }
235
236 getFileUrl (this: MVideoCaptionLanguageUrl, video: MVideo) {
237 if (video.isOwned()) return WEBSERVER.URL + this.getCaptionStaticPath()
238
239 return this.fileUrl
240 }
241
242 isEqual (this: MVideoCaption, other: MVideoCaption) {
243 if (this.fileUrl) return this.fileUrl === other.fileUrl
244
245 return this.filename === other.filename
246 }
247}
diff --git a/server/models/video/video-change-ownership.ts b/server/models/video/video-change-ownership.ts
deleted file mode 100644
index 26f072f4f..000000000
--- a/server/models/video/video-change-ownership.ts
+++ /dev/null
@@ -1,137 +0,0 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@server/types/models/video/video-change-ownership'
3import { AttributesOnly } from '@shared/typescript-utils'
4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos'
5import { AccountModel } from '../account/account'
6import { getSort } from '../shared'
7import { ScopeNames as VideoScopeNames, VideoModel } from './video'
8
9enum ScopeNames {
10 WITH_ACCOUNTS = 'WITH_ACCOUNTS',
11 WITH_VIDEO = 'WITH_VIDEO'
12}
13
14@Table({
15 tableName: 'videoChangeOwnership',
16 indexes: [
17 {
18 fields: [ 'videoId' ]
19 },
20 {
21 fields: [ 'initiatorAccountId' ]
22 },
23 {
24 fields: [ 'nextOwnerAccountId' ]
25 }
26 ]
27})
28@Scopes(() => ({
29 [ScopeNames.WITH_ACCOUNTS]: {
30 include: [
31 {
32 model: AccountModel,
33 as: 'Initiator',
34 required: true
35 },
36 {
37 model: AccountModel,
38 as: 'NextOwner',
39 required: true
40 }
41 ]
42 },
43 [ScopeNames.WITH_VIDEO]: {
44 include: [
45 {
46 model: VideoModel.scope([
47 VideoScopeNames.WITH_THUMBNAILS,
48 VideoScopeNames.WITH_WEB_VIDEO_FILES,
49 VideoScopeNames.WITH_STREAMING_PLAYLISTS,
50 VideoScopeNames.WITH_ACCOUNT_DETAILS
51 ]),
52 required: true
53 }
54 ]
55 }
56}))
57export class VideoChangeOwnershipModel extends Model<Partial<AttributesOnly<VideoChangeOwnershipModel>>> {
58 @CreatedAt
59 createdAt: Date
60
61 @UpdatedAt
62 updatedAt: Date
63
64 @AllowNull(false)
65 @Column
66 status: VideoChangeOwnershipStatus
67
68 @ForeignKey(() => AccountModel)
69 @Column
70 initiatorAccountId: number
71
72 @BelongsTo(() => AccountModel, {
73 foreignKey: {
74 name: 'initiatorAccountId',
75 allowNull: false
76 },
77 onDelete: 'cascade'
78 })
79 Initiator: AccountModel
80
81 @ForeignKey(() => AccountModel)
82 @Column
83 nextOwnerAccountId: number
84
85 @BelongsTo(() => AccountModel, {
86 foreignKey: {
87 name: 'nextOwnerAccountId',
88 allowNull: false
89 },
90 onDelete: 'cascade'
91 })
92 NextOwner: AccountModel
93
94 @ForeignKey(() => VideoModel)
95 @Column
96 videoId: number
97
98 @BelongsTo(() => VideoModel, {
99 foreignKey: {
100 allowNull: false
101 },
102 onDelete: 'cascade'
103 })
104 Video: VideoModel
105
106 static listForApi (nextOwnerId: number, start: number, count: number, sort: string) {
107 const query = {
108 offset: start,
109 limit: count,
110 order: getSort(sort),
111 where: {
112 nextOwnerAccountId: nextOwnerId
113 }
114 }
115
116 return Promise.all([
117 VideoChangeOwnershipModel.scope(ScopeNames.WITH_ACCOUNTS).count(query),
118 VideoChangeOwnershipModel.scope([ ScopeNames.WITH_ACCOUNTS, ScopeNames.WITH_VIDEO ]).findAll<MVideoChangeOwnershipFull>(query)
119 ]).then(([ count, rows ]) => ({ total: count, data: rows }))
120 }
121
122 static load (id: number): Promise<MVideoChangeOwnershipFull> {
123 return VideoChangeOwnershipModel.scope([ ScopeNames.WITH_ACCOUNTS, ScopeNames.WITH_VIDEO ])
124 .findByPk(id)
125 }
126
127 toFormattedJSON (this: MVideoChangeOwnershipFormattable): VideoChangeOwnership {
128 return {
129 id: this.id,
130 status: this.status,
131 initiatorAccount: this.Initiator.toFormattedJSON(),
132 nextOwnerAccount: this.NextOwner.toFormattedJSON(),
133 video: this.Video.toFormattedJSON(),
134 createdAt: this.createdAt
135 }
136 }
137}
diff --git a/server/models/video/video-channel-sync.ts b/server/models/video/video-channel-sync.ts
deleted file mode 100644
index a4cbf51f5..000000000
--- a/server/models/video/video-channel-sync.ts
+++ /dev/null
@@ -1,176 +0,0 @@
1import { Op } from 'sequelize'
2import {
3 AllowNull,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 Default,
9 DefaultScope,
10 ForeignKey,
11 Is,
12 Model,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc'
17import { isVideoChannelSyncStateValid } from '@server/helpers/custom-validators/video-channel-syncs'
18import { CONSTRAINTS_FIELDS, VIDEO_CHANNEL_SYNC_STATE } from '@server/initializers/constants'
19import { MChannelSync, MChannelSyncChannel, MChannelSyncFormattable } from '@server/types/models'
20import { VideoChannelSync, VideoChannelSyncState } from '@shared/models'
21import { AttributesOnly } from '@shared/typescript-utils'
22import { AccountModel } from '../account/account'
23import { UserModel } from '../user/user'
24import { getChannelSyncSort, throwIfNotValid } from '../shared'
25import { VideoChannelModel } from './video-channel'
26
27@DefaultScope(() => ({
28 include: [
29 {
30 model: VideoChannelModel, // Default scope includes avatar and server
31 required: true
32 }
33 ]
34}))
35@Table({
36 tableName: 'videoChannelSync',
37 indexes: [
38 {
39 fields: [ 'videoChannelId' ]
40 }
41 ]
42})
43export class VideoChannelSyncModel extends Model<Partial<AttributesOnly<VideoChannelSyncModel>>> {
44
45 @AllowNull(false)
46 @Default(null)
47 @Is('VideoChannelExternalChannelUrl', value => throwIfNotValid(value, isUrlValid, 'externalChannelUrl', true))
48 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNEL_SYNCS.EXTERNAL_CHANNEL_URL.max))
49 externalChannelUrl: string
50
51 @CreatedAt
52 createdAt: Date
53
54 @UpdatedAt
55 updatedAt: Date
56
57 @ForeignKey(() => VideoChannelModel)
58 @Column
59 videoChannelId: number
60
61 @BelongsTo(() => VideoChannelModel, {
62 foreignKey: {
63 allowNull: false
64 },
65 onDelete: 'cascade'
66 })
67 VideoChannel: VideoChannelModel
68
69 @AllowNull(false)
70 @Default(VideoChannelSyncState.WAITING_FIRST_RUN)
71 @Is('VideoChannelSyncState', value => throwIfNotValid(value, isVideoChannelSyncStateValid, 'state'))
72 @Column
73 state: VideoChannelSyncState
74
75 @AllowNull(true)
76 @Column(DataType.DATE)
77 lastSyncAt: Date
78
79 static listByAccountForAPI (options: {
80 accountId: number
81 start: number
82 count: number
83 sort: string
84 }) {
85 const getQuery = (forCount: boolean) => {
86 const videoChannelModel = forCount
87 ? VideoChannelModel.unscoped()
88 : VideoChannelModel
89
90 return {
91 offset: options.start,
92 limit: options.count,
93 order: getChannelSyncSort(options.sort),
94 include: [
95 {
96 model: videoChannelModel,
97 required: true,
98 where: {
99 accountId: options.accountId
100 }
101 }
102 ]
103 }
104 }
105
106 return Promise.all([
107 VideoChannelSyncModel.unscoped().count(getQuery(true)),
108 VideoChannelSyncModel.unscoped().findAll(getQuery(false))
109 ]).then(([ total, data ]) => ({ total, data }))
110 }
111
112 static countByAccount (accountId: number) {
113 const query = {
114 include: [
115 {
116 model: VideoChannelModel.unscoped(),
117 required: true,
118 where: {
119 accountId
120 }
121 }
122 ]
123 }
124
125 return VideoChannelSyncModel.unscoped().count(query)
126 }
127
128 static loadWithChannel (id: number): Promise<MChannelSyncChannel> {
129 return VideoChannelSyncModel.findByPk(id)
130 }
131
132 static async listSyncs (): Promise<MChannelSync[]> {
133 const query = {
134 include: [
135 {
136 model: VideoChannelModel.unscoped(),
137 required: true,
138 include: [
139 {
140 model: AccountModel.unscoped(),
141 required: true,
142 include: [ {
143 attributes: [],
144 model: UserModel.unscoped(),
145 required: true,
146 where: {
147 videoQuota: {
148 [Op.ne]: 0
149 },
150 videoQuotaDaily: {
151 [Op.ne]: 0
152 }
153 }
154 } ]
155 }
156 ]
157 }
158 ]
159 }
160 return VideoChannelSyncModel.unscoped().findAll(query)
161 }
162
163 toFormattedJSON (this: MChannelSyncFormattable): VideoChannelSync {
164 return {
165 id: this.id,
166 state: {
167 id: this.state,
168 label: VIDEO_CHANNEL_SYNC_STATE[this.state]
169 },
170 externalChannelUrl: this.externalChannelUrl,
171 createdAt: this.createdAt.toISOString(),
172 channel: this.VideoChannel.toFormattedSummaryJSON(),
173 lastSyncAt: this.lastSyncAt?.toISOString()
174 }
175 }
176}
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
deleted file mode 100644
index 2c38850d7..000000000
--- a/server/models/video/video-channel.ts
+++ /dev/null
@@ -1,860 +0,0 @@
1import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
2import {
3 AfterCreate,
4 AfterDestroy,
5 AfterUpdate,
6 AllowNull,
7 BeforeDestroy,
8 BelongsTo,
9 Column,
10 CreatedAt,
11 DataType,
12 Default,
13 DefaultScope,
14 ForeignKey,
15 HasMany,
16 Is,
17 Model,
18 Scopes,
19 Sequelize,
20 Table,
21 UpdatedAt
22} from 'sequelize-typescript'
23import { CONFIG } from '@server/initializers/config'
24import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
25import { MAccountHost } from '@server/types/models'
26import { forceNumber, pick } from '@shared/core-utils'
27import { AttributesOnly } from '@shared/typescript-utils'
28import { ActivityPubActor } from '../../../shared/models/activitypub'
29import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
30import {
31 isVideoChannelDescriptionValid,
32 isVideoChannelDisplayNameValid,
33 isVideoChannelSupportValid
34} from '../../helpers/custom-validators/video-channels'
35import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
36import { sendDeleteActor } from '../../lib/activitypub/send'
37import {
38 MChannel,
39 MChannelActor,
40 MChannelAP,
41 MChannelBannerAccountDefault,
42 MChannelFormattable,
43 MChannelHost,
44 MChannelSummaryFormattable
45} from '../../types/models/video'
46import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
47import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
48import { ActorFollowModel } from '../actor/actor-follow'
49import { ActorImageModel } from '../actor/actor-image'
50import { ServerModel } from '../server/server'
51import {
52 buildServerIdsFollowedBy,
53 buildTrigramSearchIndex,
54 createSimilarityAttribute,
55 getSort,
56 setAsUpdated,
57 throwIfNotValid
58} from '../shared'
59import { VideoModel } from './video'
60import { VideoPlaylistModel } from './video-playlist'
61
62export enum ScopeNames {
63 FOR_API = 'FOR_API',
64 SUMMARY = 'SUMMARY',
65 WITH_ACCOUNT = 'WITH_ACCOUNT',
66 WITH_ACTOR = 'WITH_ACTOR',
67 WITH_ACTOR_BANNER = 'WITH_ACTOR_BANNER',
68 WITH_VIDEOS = 'WITH_VIDEOS',
69 WITH_STATS = 'WITH_STATS'
70}
71
72type AvailableForListOptions = {
73 actorId: number
74 search?: string
75 host?: string
76 handles?: string[]
77 forCount?: boolean
78}
79
80type AvailableWithStatsOptions = {
81 daysPrior: number
82}
83
84export type SummaryOptions = {
85 actorRequired?: boolean // Default: true
86 withAccount?: boolean // Default: false
87 withAccountBlockerIds?: number[]
88}
89
90@DefaultScope(() => ({
91 include: [
92 {
93 model: ActorModel,
94 required: true
95 }
96 ]
97}))
98@Scopes(() => ({
99 [ScopeNames.FOR_API]: (options: AvailableForListOptions) => {
100 // Only list local channels OR channels that are on an instance followed by actorId
101 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
102
103 const whereActorAnd: WhereOptions[] = [
104 {
105 [Op.or]: [
106 {
107 serverId: null
108 },
109 {
110 serverId: {
111 [Op.in]: Sequelize.literal(inQueryInstanceFollow)
112 }
113 }
114 ]
115 }
116 ]
117
118 let serverRequired = false
119 let whereServer: WhereOptions
120
121 if (options.host && options.host !== WEBSERVER.HOST) {
122 serverRequired = true
123 whereServer = { host: options.host }
124 }
125
126 if (options.host === WEBSERVER.HOST) {
127 whereActorAnd.push({
128 serverId: null
129 })
130 }
131
132 if (Array.isArray(options.handles) && options.handles.length !== 0) {
133 const or: string[] = []
134
135 for (const handle of options.handles || []) {
136 const [ preferredUsername, host ] = handle.split('@')
137
138 const sanitizedPreferredUsername = VideoChannelModel.sequelize.escape(preferredUsername.toLowerCase())
139 const sanitizedHost = VideoChannelModel.sequelize.escape(host)
140
141 if (!host || host === WEBSERVER.HOST) {
142 or.push(`(LOWER("preferredUsername") = ${sanitizedPreferredUsername} AND "serverId" IS NULL)`)
143 } else {
144 or.push(
145 `(` +
146 `LOWER("preferredUsername") = ${sanitizedPreferredUsername} ` +
147 `AND "host" = ${sanitizedHost}` +
148 `)`
149 )
150 }
151 }
152
153 whereActorAnd.push({
154 id: {
155 [Op.in]: literal(`(SELECT "actor".id FROM actor LEFT JOIN server on server.id = actor."serverId" WHERE ${or.join(' OR ')})`)
156 }
157 })
158 }
159
160 const channelActorInclude: Includeable[] = []
161 const accountActorInclude: Includeable[] = []
162
163 if (options.forCount !== true) {
164 accountActorInclude.push({
165 model: ServerModel,
166 required: false
167 })
168
169 accountActorInclude.push({
170 model: ActorImageModel,
171 as: 'Avatars',
172 required: false
173 })
174
175 channelActorInclude.push({
176 model: ActorImageModel,
177 as: 'Avatars',
178 required: false
179 })
180
181 channelActorInclude.push({
182 model: ActorImageModel,
183 as: 'Banners',
184 required: false
185 })
186 }
187
188 if (options.forCount !== true || serverRequired) {
189 channelActorInclude.push({
190 model: ServerModel,
191 duplicating: false,
192 required: serverRequired,
193 where: whereServer
194 })
195 }
196
197 return {
198 include: [
199 {
200 attributes: {
201 exclude: unusedActorAttributesForAPI
202 },
203 model: ActorModel.unscoped(),
204 where: {
205 [Op.and]: whereActorAnd
206 },
207 include: channelActorInclude
208 },
209 {
210 model: AccountModel.unscoped(),
211 required: true,
212 include: [
213 {
214 attributes: {
215 exclude: unusedActorAttributesForAPI
216 },
217 model: ActorModel.unscoped(),
218 required: true,
219 include: accountActorInclude
220 }
221 ]
222 }
223 ]
224 }
225 },
226 [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
227 const include: Includeable[] = [
228 {
229 attributes: [ 'id', 'preferredUsername', 'url', 'serverId' ],
230 model: ActorModel.unscoped(),
231 required: options.actorRequired ?? true,
232 include: [
233 {
234 attributes: [ 'host' ],
235 model: ServerModel.unscoped(),
236 required: false
237 },
238 {
239 model: ActorImageModel,
240 as: 'Avatars',
241 required: false
242 }
243 ]
244 }
245 ]
246
247 const base: FindOptions = {
248 attributes: [ 'id', 'name', 'description', 'actorId' ]
249 }
250
251 if (options.withAccount === true) {
252 include.push({
253 model: AccountModel.scope({
254 method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
255 }),
256 required: true
257 })
258 }
259
260 base.include = include
261
262 return base
263 },
264 [ScopeNames.WITH_ACCOUNT]: {
265 include: [
266 {
267 model: AccountModel,
268 required: true
269 }
270 ]
271 },
272 [ScopeNames.WITH_ACTOR]: {
273 include: [
274 ActorModel
275 ]
276 },
277 [ScopeNames.WITH_ACTOR_BANNER]: {
278 include: [
279 {
280 model: ActorModel,
281 include: [
282 {
283 model: ActorImageModel,
284 required: false,
285 as: 'Banners'
286 }
287 ]
288 }
289 ]
290 },
291 [ScopeNames.WITH_VIDEOS]: {
292 include: [
293 VideoModel
294 ]
295 },
296 [ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => {
297 const daysPrior = forceNumber(options.daysPrior)
298
299 return {
300 attributes: {
301 include: [
302 [
303 literal('(SELECT COUNT(*) FROM "video" WHERE "channelId" = "VideoChannelModel"."id")'),
304 'videosCount'
305 ],
306 [
307 literal(
308 '(' +
309 `SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` +
310 'FROM ( ' +
311 'WITH ' +
312 'days AS ( ' +
313 `SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` +
314 `date_trunc('day', now()), '1 day'::interval) AS day ` +
315 ') ' +
316 'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' +
317 'FROM days ' +
318 'LEFT JOIN (' +
319 '"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' +
320 'AND "video"."channelId" = "VideoChannelModel"."id"' +
321 `) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` +
322 'GROUP BY day ' +
323 'ORDER BY day ' +
324 ') t' +
325 ')'
326 ),
327 'viewsPerDay'
328 ],
329 [
330 literal(
331 '(' +
332 'SELECT COALESCE(SUM("video".views), 0) AS totalViews ' +
333 'FROM "video" ' +
334 'WHERE "video"."channelId" = "VideoChannelModel"."id"' +
335 ')'
336 ),
337 'totalViews'
338 ]
339 ]
340 }
341 }
342 }
343}))
344@Table({
345 tableName: 'videoChannel',
346 indexes: [
347 buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
348
349 {
350 fields: [ 'accountId' ]
351 },
352 {
353 fields: [ 'actorId' ]
354 }
355 ]
356})
357export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannelModel>>> {
358
359 @AllowNull(false)
360 @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'name'))
361 @Column
362 name: string
363
364 @AllowNull(true)
365 @Default(null)
366 @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description', true))
367 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
368 description: string
369
370 @AllowNull(true)
371 @Default(null)
372 @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support', true))
373 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
374 support: string
375
376 @CreatedAt
377 createdAt: Date
378
379 @UpdatedAt
380 updatedAt: Date
381
382 @ForeignKey(() => ActorModel)
383 @Column
384 actorId: number
385
386 @BelongsTo(() => ActorModel, {
387 foreignKey: {
388 allowNull: false
389 },
390 onDelete: 'cascade'
391 })
392 Actor: ActorModel
393
394 @ForeignKey(() => AccountModel)
395 @Column
396 accountId: number
397
398 @BelongsTo(() => AccountModel, {
399 foreignKey: {
400 allowNull: false
401 }
402 })
403 Account: AccountModel
404
405 @HasMany(() => VideoModel, {
406 foreignKey: {
407 name: 'channelId',
408 allowNull: false
409 },
410 onDelete: 'CASCADE',
411 hooks: true
412 })
413 Videos: VideoModel[]
414
415 @HasMany(() => VideoPlaylistModel, {
416 foreignKey: {
417 allowNull: true
418 },
419 onDelete: 'CASCADE',
420 hooks: true
421 })
422 VideoPlaylists: VideoPlaylistModel[]
423
424 @AfterCreate
425 static notifyCreate (channel: MChannel) {
426 InternalEventEmitter.Instance.emit('channel-created', { channel })
427 }
428
429 @AfterUpdate
430 static notifyUpdate (channel: MChannel) {
431 InternalEventEmitter.Instance.emit('channel-updated', { channel })
432 }
433
434 @AfterDestroy
435 static notifyDestroy (channel: MChannel) {
436 InternalEventEmitter.Instance.emit('channel-deleted', { channel })
437 }
438
439 @BeforeDestroy
440 static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
441 if (!instance.Actor) {
442 instance.Actor = await instance.$get('Actor', { transaction: options.transaction })
443 }
444
445 await ActorFollowModel.removeFollowsOf(instance.Actor.id, options.transaction)
446
447 if (instance.Actor.isOwned()) {
448 return sendDeleteActor(instance.Actor, options.transaction)
449 }
450
451 return undefined
452 }
453
454 static countByAccount (accountId: number) {
455 const query = {
456 where: {
457 accountId
458 }
459 }
460
461 return VideoChannelModel.unscoped().count(query)
462 }
463
464 static async getStats () {
465
466 function getLocalVideoChannelStats (days?: number) {
467 const options = {
468 type: QueryTypes.SELECT as QueryTypes.SELECT,
469 raw: true
470 }
471
472 const videoJoin = days
473 ? `INNER JOIN "video" AS "Videos" ON "VideoChannelModel"."id" = "Videos"."channelId" ` +
474 `AND ("Videos"."publishedAt" > Now() - interval '${days}d')`
475 : ''
476
477 const query = `
478 SELECT COUNT(DISTINCT("VideoChannelModel"."id")) AS "count"
479 FROM "videoChannel" AS "VideoChannelModel"
480 ${videoJoin}
481 INNER JOIN "account" AS "Account" ON "VideoChannelModel"."accountId" = "Account"."id"
482 INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
483 AND "Account->Actor"."serverId" IS NULL`
484
485 return VideoChannelModel.sequelize.query<{ count: string }>(query, options)
486 .then(r => parseInt(r[0].count, 10))
487 }
488
489 const totalLocalVideoChannels = await getLocalVideoChannelStats()
490 const totalLocalDailyActiveVideoChannels = await getLocalVideoChannelStats(1)
491 const totalLocalWeeklyActiveVideoChannels = await getLocalVideoChannelStats(7)
492 const totalLocalMonthlyActiveVideoChannels = await getLocalVideoChannelStats(30)
493 const totalLocalHalfYearActiveVideoChannels = await getLocalVideoChannelStats(180)
494
495 return {
496 totalLocalVideoChannels,
497 totalLocalDailyActiveVideoChannels,
498 totalLocalWeeklyActiveVideoChannels,
499 totalLocalMonthlyActiveVideoChannels,
500 totalLocalHalfYearActiveVideoChannels
501 }
502 }
503
504 static listLocalsForSitemap (sort: string): Promise<MChannelActor[]> {
505 const query = {
506 attributes: [ ],
507 offset: 0,
508 order: getSort(sort),
509 include: [
510 {
511 attributes: [ 'preferredUsername', 'serverId' ],
512 model: ActorModel.unscoped(),
513 where: {
514 serverId: null
515 }
516 }
517 ]
518 }
519
520 return VideoChannelModel
521 .unscoped()
522 .findAll(query)
523 }
524
525 static listForApi (parameters: Pick<AvailableForListOptions, 'actorId'> & {
526 start: number
527 count: number
528 sort: string
529 }) {
530 const { actorId } = parameters
531
532 const query = {
533 offset: parameters.start,
534 limit: parameters.count,
535 order: getSort(parameters.sort)
536 }
537
538 const getScope = (forCount: boolean) => {
539 return { method: [ ScopeNames.FOR_API, { actorId, forCount } as AvailableForListOptions ] }
540 }
541
542 return Promise.all([
543 VideoChannelModel.scope(getScope(true)).count(),
544 VideoChannelModel.scope(getScope(false)).findAll(query)
545 ]).then(([ total, data ]) => ({ total, data }))
546 }
547
548 static searchForApi (options: Pick<AvailableForListOptions, 'actorId' | 'search' | 'host' | 'handles'> & {
549 start: number
550 count: number
551 sort: string
552 }) {
553 let attributesInclude: any[] = [ literal('0 as similarity') ]
554 let where: WhereOptions
555
556 if (options.search) {
557 const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
558 const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
559 attributesInclude = [ createSimilarityAttribute('VideoChannelModel.name', options.search) ]
560
561 where = {
562 [Op.or]: [
563 Sequelize.literal(
564 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
565 ),
566 Sequelize.literal(
567 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
568 )
569 ]
570 }
571 }
572
573 const query = {
574 attributes: {
575 include: attributesInclude
576 },
577 offset: options.start,
578 limit: options.count,
579 order: getSort(options.sort),
580 where
581 }
582
583 const getScope = (forCount: boolean) => {
584 return {
585 method: [
586 ScopeNames.FOR_API, {
587 ...pick(options, [ 'actorId', 'host', 'handles' ]),
588
589 forCount
590 } as AvailableForListOptions
591 ]
592 }
593 }
594
595 return Promise.all([
596 VideoChannelModel.scope(getScope(true)).count(query),
597 VideoChannelModel.scope(getScope(false)).findAll(query)
598 ]).then(([ total, data ]) => ({ total, data }))
599 }
600
601 static listByAccountForAPI (options: {
602 accountId: number
603 start: number
604 count: number
605 sort: string
606 withStats?: boolean
607 search?: string
608 }) {
609 const escapedSearch = VideoModel.sequelize.escape(options.search)
610 const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
611 const where = options.search
612 ? {
613 [Op.or]: [
614 Sequelize.literal(
615 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
616 ),
617 Sequelize.literal(
618 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
619 )
620 ]
621 }
622 : null
623
624 const getQuery = (forCount: boolean) => {
625 const accountModel = forCount
626 ? AccountModel.unscoped()
627 : AccountModel
628
629 return {
630 offset: options.start,
631 limit: options.count,
632 order: getSort(options.sort),
633 include: [
634 {
635 model: accountModel,
636 where: {
637 id: options.accountId
638 },
639 required: true
640 }
641 ],
642 where
643 }
644 }
645
646 const findScopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR_BANNER ]
647
648 if (options.withStats === true) {
649 findScopes.push({
650 method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ]
651 })
652 }
653
654 return Promise.all([
655 VideoChannelModel.unscoped().count(getQuery(true)),
656 VideoChannelModel.scope(findScopes).findAll(getQuery(false))
657 ]).then(([ total, data ]) => ({ total, data }))
658 }
659
660 static listAllByAccount (accountId: number): Promise<MChannel[]> {
661 const query = {
662 limit: CONFIG.VIDEO_CHANNELS.MAX_PER_USER,
663 include: [
664 {
665 attributes: [],
666 model: AccountModel.unscoped(),
667 where: {
668 id: accountId
669 },
670 required: true
671 }
672 ]
673 }
674
675 return VideoChannelModel.findAll(query)
676 }
677
678 static loadAndPopulateAccount (id: number, transaction?: Transaction): Promise<MChannelBannerAccountDefault> {
679 return VideoChannelModel.unscoped()
680 .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ])
681 .findByPk(id, { transaction })
682 }
683
684 static loadByUrlAndPopulateAccount (url: string): Promise<MChannelBannerAccountDefault> {
685 const query = {
686 include: [
687 {
688 model: ActorModel,
689 required: true,
690 where: {
691 url
692 },
693 include: [
694 {
695 model: ActorImageModel,
696 required: false,
697 as: 'Banners'
698 }
699 ]
700 }
701 ]
702 }
703
704 return VideoChannelModel
705 .scope([ ScopeNames.WITH_ACCOUNT ])
706 .findOne(query)
707 }
708
709 static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
710 const [ name, host ] = nameWithHost.split('@')
711
712 if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
713
714 return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
715 }
716
717 static loadLocalByNameAndPopulateAccount (name: string): Promise<MChannelBannerAccountDefault> {
718 const query = {
719 include: [
720 {
721 model: ActorModel,
722 required: true,
723 where: {
724 [Op.and]: [
725 ActorModel.wherePreferredUsername(name, 'Actor.preferredUsername'),
726 { serverId: null }
727 ]
728 },
729 include: [
730 {
731 model: ActorImageModel,
732 required: false,
733 as: 'Banners'
734 }
735 ]
736 }
737 ]
738 }
739
740 return VideoChannelModel.unscoped()
741 .scope([ ScopeNames.WITH_ACCOUNT ])
742 .findOne(query)
743 }
744
745 static loadByNameAndHostAndPopulateAccount (name: string, host: string): Promise<MChannelBannerAccountDefault> {
746 const query = {
747 include: [
748 {
749 model: ActorModel,
750 required: true,
751 where: ActorModel.wherePreferredUsername(name, 'Actor.preferredUsername'),
752 include: [
753 {
754 model: ServerModel,
755 required: true,
756 where: { host }
757 },
758 {
759 model: ActorImageModel,
760 required: false,
761 as: 'Banners'
762 }
763 ]
764 }
765 ]
766 }
767
768 return VideoChannelModel.unscoped()
769 .scope([ ScopeNames.WITH_ACCOUNT ])
770 .findOne(query)
771 }
772
773 toFormattedSummaryJSON (this: MChannelSummaryFormattable): VideoChannelSummary {
774 const actor = this.Actor.toFormattedSummaryJSON()
775
776 return {
777 id: this.id,
778 name: actor.name,
779 displayName: this.getDisplayName(),
780 url: actor.url,
781 host: actor.host,
782 avatars: actor.avatars
783 }
784 }
785
786 toFormattedJSON (this: MChannelFormattable): VideoChannel {
787 const viewsPerDayString = this.get('viewsPerDay') as string
788 const videosCount = this.get('videosCount') as number
789
790 let viewsPerDay: { date: Date, views: number }[]
791
792 if (viewsPerDayString) {
793 viewsPerDay = viewsPerDayString.split(',')
794 .map(v => {
795 const [ dateString, amount ] = v.split('|')
796
797 return {
798 date: new Date(dateString),
799 views: +amount
800 }
801 })
802 }
803
804 const totalViews = this.get('totalViews') as number
805
806 const actor = this.Actor.toFormattedJSON()
807 const videoChannel = {
808 id: this.id,
809 displayName: this.getDisplayName(),
810 description: this.description,
811 support: this.support,
812 isLocal: this.Actor.isOwned(),
813 updatedAt: this.updatedAt,
814
815 ownerAccount: undefined,
816
817 videosCount,
818 viewsPerDay,
819 totalViews,
820
821 avatars: actor.avatars
822 }
823
824 if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
825
826 return Object.assign(actor, videoChannel)
827 }
828
829 async toActivityPubObject (this: MChannelAP): Promise<ActivityPubActor> {
830 const obj = await this.Actor.toActivityPubObject(this.name)
831
832 return Object.assign(obj, {
833 summary: this.description,
834 support: this.support,
835 attributedTo: [
836 {
837 type: 'Person' as 'Person',
838 id: this.Account.Actor.url
839 }
840 ]
841 })
842 }
843
844 // Avoid error when running this method on MAccount... | MChannel...
845 getClientUrl (this: MAccountHost | MChannelHost) {
846 return WEBSERVER.URL + '/c/' + this.Actor.getIdentifier()
847 }
848
849 getDisplayName () {
850 return this.name
851 }
852
853 isOutdated () {
854 return this.Actor.isOutdated()
855 }
856
857 setAsUpdated (transaction?: Transaction) {
858 return setAsUpdated({ sequelize: this.sequelize, table: 'videoChannel', id: this.id, transaction })
859 }
860}
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
deleted file mode 100644
index ff5142809..000000000
--- a/server/models/video/video-comment.ts
+++ /dev/null
@@ -1,683 +0,0 @@
1import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
2import {
3 AllowNull,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 ForeignKey,
9 HasMany,
10 Is,
11 Model,
12 Scopes,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import { getServerActor } from '@server/models/application/application'
17import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
18import { pick, uniqify } from '@shared/core-utils'
19import { AttributesOnly } from '@shared/typescript-utils'
20import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
21import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
22import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/comment/video-comment.model'
23import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
24import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
25import { regexpCapture } from '../../helpers/regexp'
26import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
27import {
28 MComment,
29 MCommentAdminFormattable,
30 MCommentAP,
31 MCommentFormattable,
32 MCommentId,
33 MCommentOwner,
34 MCommentOwnerReplyVideoLight,
35 MCommentOwnerVideo,
36 MCommentOwnerVideoFeed,
37 MCommentOwnerVideoReply,
38 MVideoImmutable
39} from '../../types/models/video'
40import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
41import { AccountModel } from '../account/account'
42import { ActorModel } from '../actor/actor'
43import { buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared'
44import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder'
45import { VideoModel } from './video'
46import { VideoChannelModel } from './video-channel'
47
48export enum ScopeNames {
49 WITH_ACCOUNT = 'WITH_ACCOUNT',
50 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
51 WITH_VIDEO = 'WITH_VIDEO'
52}
53
54@Scopes(() => ({
55 [ScopeNames.WITH_ACCOUNT]: {
56 include: [
57 {
58 model: AccountModel
59 }
60 ]
61 },
62 [ScopeNames.WITH_IN_REPLY_TO]: {
63 include: [
64 {
65 model: VideoCommentModel,
66 as: 'InReplyToVideoComment'
67 }
68 ]
69 },
70 [ScopeNames.WITH_VIDEO]: {
71 include: [
72 {
73 model: VideoModel,
74 required: true,
75 include: [
76 {
77 model: VideoChannelModel,
78 required: true,
79 include: [
80 {
81 model: AccountModel,
82 required: true
83 }
84 ]
85 }
86 ]
87 }
88 ]
89 }
90}))
91@Table({
92 tableName: 'videoComment',
93 indexes: [
94 {
95 fields: [ 'videoId' ]
96 },
97 {
98 fields: [ 'videoId', 'originCommentId' ]
99 },
100 {
101 fields: [ 'url' ],
102 unique: true
103 },
104 {
105 fields: [ 'accountId' ]
106 },
107 {
108 fields: [
109 { name: 'createdAt', order: 'DESC' }
110 ]
111 }
112 ]
113})
114export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoCommentModel>>> {
115 @CreatedAt
116 createdAt: Date
117
118 @UpdatedAt
119 updatedAt: Date
120
121 @AllowNull(true)
122 @Column(DataType.DATE)
123 deletedAt: Date
124
125 @AllowNull(false)
126 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
127 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
128 url: string
129
130 @AllowNull(false)
131 @Column(DataType.TEXT)
132 text: string
133
134 @ForeignKey(() => VideoCommentModel)
135 @Column
136 originCommentId: number
137
138 @BelongsTo(() => VideoCommentModel, {
139 foreignKey: {
140 name: 'originCommentId',
141 allowNull: true
142 },
143 as: 'OriginVideoComment',
144 onDelete: 'CASCADE'
145 })
146 OriginVideoComment: VideoCommentModel
147
148 @ForeignKey(() => VideoCommentModel)
149 @Column
150 inReplyToCommentId: number
151
152 @BelongsTo(() => VideoCommentModel, {
153 foreignKey: {
154 name: 'inReplyToCommentId',
155 allowNull: true
156 },
157 as: 'InReplyToVideoComment',
158 onDelete: 'CASCADE'
159 })
160 InReplyToVideoComment: VideoCommentModel | null
161
162 @ForeignKey(() => VideoModel)
163 @Column
164 videoId: number
165
166 @BelongsTo(() => VideoModel, {
167 foreignKey: {
168 allowNull: false
169 },
170 onDelete: 'CASCADE'
171 })
172 Video: VideoModel
173
174 @ForeignKey(() => AccountModel)
175 @Column
176 accountId: number
177
178 @BelongsTo(() => AccountModel, {
179 foreignKey: {
180 allowNull: true
181 },
182 onDelete: 'CASCADE'
183 })
184 Account: AccountModel
185
186 @HasMany(() => VideoCommentAbuseModel, {
187 foreignKey: {
188 name: 'videoCommentId',
189 allowNull: true
190 },
191 onDelete: 'set null'
192 })
193 CommentAbuses: VideoCommentAbuseModel[]
194
195 // ---------------------------------------------------------------------------
196
197 static getSQLAttributes (tableName: string, aliasPrefix = '') {
198 return buildSQLAttributes({
199 model: this,
200 tableName,
201 aliasPrefix
202 })
203 }
204
205 // ---------------------------------------------------------------------------
206
207 static loadById (id: number, t?: Transaction): Promise<MComment> {
208 const query: FindOptions = {
209 where: {
210 id
211 }
212 }
213
214 if (t !== undefined) query.transaction = t
215
216 return VideoCommentModel.findOne(query)
217 }
218
219 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Transaction): Promise<MCommentOwnerVideoReply> {
220 const query: FindOptions = {
221 where: {
222 id
223 }
224 }
225
226 if (t !== undefined) query.transaction = t
227
228 return VideoCommentModel
229 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
230 .findOne(query)
231 }
232
233 static loadByUrlAndPopulateAccountAndVideo (url: string, t?: Transaction): Promise<MCommentOwnerVideo> {
234 const query: FindOptions = {
235 where: {
236 url
237 }
238 }
239
240 if (t !== undefined) query.transaction = t
241
242 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEO ]).findOne(query)
243 }
244
245 static loadByUrlAndPopulateReplyAndVideoUrlAndAccount (url: string, t?: Transaction): Promise<MCommentOwnerReplyVideoLight> {
246 const query: FindOptions = {
247 where: {
248 url
249 },
250 include: [
251 {
252 attributes: [ 'id', 'url' ],
253 model: VideoModel.unscoped()
254 }
255 ]
256 }
257
258 if (t !== undefined) query.transaction = t
259
260 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_ACCOUNT ]).findOne(query)
261 }
262
263 static listCommentsForApi (parameters: {
264 start: number
265 count: number
266 sort: string
267
268 onLocalVideo?: boolean
269 isLocal?: boolean
270 search?: string
271 searchAccount?: string
272 searchVideo?: string
273 }) {
274 const queryOptions: ListVideoCommentsOptions = {
275 ...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]),
276
277 selectType: 'api',
278 notDeleted: true
279 }
280
281 return Promise.all([
282 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
283 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
284 ]).then(([ rows, count ]) => {
285 return { total: count, data: rows }
286 })
287 }
288
289 static async listThreadsForApi (parameters: {
290 videoId: number
291 isVideoOwned: boolean
292 start: number
293 count: number
294 sort: string
295 user?: MUserAccountId
296 }) {
297 const { videoId, user } = parameters
298
299 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
300
301 const commonOptions: ListVideoCommentsOptions = {
302 selectType: 'api',
303 videoId,
304 blockerAccountIds
305 }
306
307 const listOptions: ListVideoCommentsOptions = {
308 ...commonOptions,
309 ...pick(parameters, [ 'sort', 'start', 'count' ]),
310
311 isThread: true,
312 includeReplyCounters: true
313 }
314
315 const countOptions: ListVideoCommentsOptions = {
316 ...commonOptions,
317
318 isThread: true
319 }
320
321 const notDeletedCountOptions: ListVideoCommentsOptions = {
322 ...commonOptions,
323
324 notDeleted: true
325 }
326
327 return Promise.all([
328 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(),
329 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
330 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
331 ]).then(([ rows, count, totalNotDeletedComments ]) => {
332 return { total: count, data: rows, totalNotDeletedComments }
333 })
334 }
335
336 static async listThreadCommentsForApi (parameters: {
337 videoId: number
338 threadId: number
339 user?: MUserAccountId
340 }) {
341 const { user } = parameters
342
343 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
344
345 const queryOptions: ListVideoCommentsOptions = {
346 ...pick(parameters, [ 'videoId', 'threadId' ]),
347
348 selectType: 'api',
349 sort: 'createdAt',
350
351 blockerAccountIds,
352 includeReplyCounters: true
353 }
354
355 return Promise.all([
356 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
357 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
358 ]).then(([ rows, count ]) => {
359 return { total: count, data: rows }
360 })
361 }
362
363 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
364 const query = {
365 order: [ [ 'createdAt', order ] ] as Order,
366 where: {
367 id: {
368 [Op.in]: Sequelize.literal('(' +
369 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
370 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
371 'UNION ' +
372 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
373 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
374 ') ' +
375 'SELECT id FROM children' +
376 ')'),
377 [Op.ne]: comment.id
378 }
379 },
380 transaction: t
381 }
382
383 return VideoCommentModel
384 .scope([ ScopeNames.WITH_ACCOUNT ])
385 .findAll(query)
386 }
387
388 static async listAndCountByVideoForAP (parameters: {
389 video: MVideoImmutable
390 start: number
391 count: number
392 }) {
393 const { video } = parameters
394
395 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
396
397 const queryOptions: ListVideoCommentsOptions = {
398 ...pick(parameters, [ 'start', 'count' ]),
399
400 selectType: 'comment-only',
401 videoId: video.id,
402 sort: 'createdAt',
403
404 blockerAccountIds
405 }
406
407 return Promise.all([
408 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(),
409 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
410 ]).then(([ rows, count ]) => {
411 return { total: count, data: rows }
412 })
413 }
414
415 static async listForFeed (parameters: {
416 start: number
417 count: number
418 videoId?: number
419 accountId?: number
420 videoChannelId?: number
421 }) {
422 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
423
424 const queryOptions: ListVideoCommentsOptions = {
425 ...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]),
426
427 selectType: 'feed',
428
429 sort: '-createdAt',
430 onPublicVideo: true,
431 notDeleted: true,
432
433 blockerAccountIds
434 }
435
436 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>()
437 }
438
439 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
440 const queryOptions: ListVideoCommentsOptions = {
441 selectType: 'comment-only',
442
443 accountId: ofAccount.id,
444 videoAccountOwnerId: filter.onVideosOfAccount?.id,
445
446 notDeleted: true,
447 count: 5000
448 }
449
450 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>()
451 }
452
453 static async getStats () {
454 const totalLocalVideoComments = await VideoCommentModel.count({
455 include: [
456 {
457 model: AccountModel.unscoped(),
458 required: true,
459 include: [
460 {
461 model: ActorModel.unscoped(),
462 required: true,
463 where: {
464 serverId: null
465 }
466 }
467 ]
468 }
469 ]
470 })
471 const totalVideoComments = await VideoCommentModel.count()
472
473 return {
474 totalLocalVideoComments,
475 totalVideoComments
476 }
477 }
478
479 static listRemoteCommentUrlsOfLocalVideos () {
480 const query = `SELECT "videoComment".url FROM "videoComment" ` +
481 `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
482 `INNER JOIN actor ON actor.id = "account"."actorId" AND actor."serverId" IS NOT NULL ` +
483 `INNER JOIN video ON video.id = "videoComment"."videoId" AND video.remote IS FALSE`
484
485 return VideoCommentModel.sequelize.query<{ url: string }>(query, {
486 type: QueryTypes.SELECT,
487 raw: true
488 }).then(rows => rows.map(r => r.url))
489 }
490
491 static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
492 const query = {
493 where: {
494 updatedAt: {
495 [Op.lt]: beforeUpdatedAt
496 },
497 videoId,
498 accountId: {
499 [Op.notIn]: buildLocalAccountIdsIn()
500 },
501 // Do not delete Tombstones
502 deletedAt: null
503 }
504 }
505
506 return VideoCommentModel.destroy(query)
507 }
508
509 getCommentStaticPath () {
510 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
511 }
512
513 getThreadId (): number {
514 return this.originCommentId || this.id
515 }
516
517 isOwned () {
518 if (!this.Account) return false
519
520 return this.Account.isOwned()
521 }
522
523 markAsDeleted () {
524 this.text = ''
525 this.deletedAt = new Date()
526 this.accountId = null
527 }
528
529 isDeleted () {
530 return this.deletedAt !== null
531 }
532
533 extractMentions () {
534 let result: string[] = []
535
536 const localMention = `@(${actorNameAlphabet}+)`
537 const remoteMention = `${localMention}@${WEBSERVER.HOST}`
538
539 const mentionRegex = this.isOwned()
540 ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions?
541 : '(?:' + remoteMention + ')'
542
543 const firstMentionRegex = new RegExp(`^${mentionRegex} `, 'g')
544 const endMentionRegex = new RegExp(` ${mentionRegex}$`, 'g')
545 const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
546
547 result = result.concat(
548 regexpCapture(this.text, firstMentionRegex)
549 .map(([ , username1, username2 ]) => username1 || username2),
550
551 regexpCapture(this.text, endMentionRegex)
552 .map(([ , username1, username2 ]) => username1 || username2),
553
554 regexpCapture(this.text, remoteMentionsRegex)
555 .map(([ , username ]) => username)
556 )
557
558 // Include local mentions
559 if (this.isOwned()) {
560 const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
561
562 result = result.concat(
563 regexpCapture(this.text, localMentionsRegex)
564 .map(([ , username ]) => username)
565 )
566 }
567
568 return uniqify(result)
569 }
570
571 toFormattedJSON (this: MCommentFormattable) {
572 return {
573 id: this.id,
574 url: this.url,
575 text: this.text,
576
577 threadId: this.getThreadId(),
578 inReplyToCommentId: this.inReplyToCommentId || null,
579 videoId: this.videoId,
580
581 createdAt: this.createdAt,
582 updatedAt: this.updatedAt,
583 deletedAt: this.deletedAt,
584
585 isDeleted: this.isDeleted(),
586
587 totalRepliesFromVideoAuthor: this.get('totalRepliesFromVideoAuthor') || 0,
588 totalReplies: this.get('totalReplies') || 0,
589
590 account: this.Account
591 ? this.Account.toFormattedJSON()
592 : null
593 } as VideoComment
594 }
595
596 toFormattedAdminJSON (this: MCommentAdminFormattable) {
597 return {
598 id: this.id,
599 url: this.url,
600 text: this.text,
601
602 threadId: this.getThreadId(),
603 inReplyToCommentId: this.inReplyToCommentId || null,
604 videoId: this.videoId,
605
606 createdAt: this.createdAt,
607 updatedAt: this.updatedAt,
608
609 video: {
610 id: this.Video.id,
611 uuid: this.Video.uuid,
612 name: this.Video.name
613 },
614
615 account: this.Account
616 ? this.Account.toFormattedJSON()
617 : null
618 } as VideoCommentAdmin
619 }
620
621 toActivityPubObject (this: MCommentAP, threadParentComments: MCommentOwner[]): VideoCommentObject | ActivityTombstoneObject {
622 let inReplyTo: string
623 // New thread, so in AS we reply to the video
624 if (this.inReplyToCommentId === null) {
625 inReplyTo = this.Video.url
626 } else {
627 inReplyTo = this.InReplyToVideoComment.url
628 }
629
630 if (this.isDeleted()) {
631 return {
632 id: this.url,
633 type: 'Tombstone',
634 formerType: 'Note',
635 inReplyTo,
636 published: this.createdAt.toISOString(),
637 updated: this.updatedAt.toISOString(),
638 deleted: this.deletedAt.toISOString()
639 }
640 }
641
642 const tag: ActivityTagObject[] = []
643 for (const parentComment of threadParentComments) {
644 if (!parentComment.Account) continue
645
646 const actor = parentComment.Account.Actor
647
648 tag.push({
649 type: 'Mention',
650 href: actor.url,
651 name: `@${actor.preferredUsername}@${actor.getHost()}`
652 })
653 }
654
655 return {
656 type: 'Note' as 'Note',
657 id: this.url,
658
659 content: this.text,
660 mediaType: 'text/markdown',
661
662 inReplyTo,
663 updated: this.updatedAt.toISOString(),
664 published: this.createdAt.toISOString(),
665 url: this.url,
666 attributedTo: this.Account.Actor.url,
667 tag
668 }
669 }
670
671 private static async buildBlockerAccountIds (options: {
672 user: MUserAccountId
673 }): Promise<number[]> {
674 const { user } = options
675
676 const serverActor = await getServerActor()
677 const blockerAccountIds = [ serverActor.Account.id ]
678
679 if (user) blockerAccountIds.push(user.Account.id)
680
681 return blockerAccountIds
682 }
683}
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
deleted file mode 100644
index ee34ad2ff..000000000
--- a/server/models/video/video-file.ts
+++ /dev/null
@@ -1,635 +0,0 @@
1import { remove } from 'fs-extra'
2import memoizee from 'memoizee'
3import { join } from 'path'
4import { FindOptions, Op, Transaction, WhereOptions } from 'sequelize'
5import {
6 AllowNull,
7 BelongsTo,
8 Column,
9 CreatedAt,
10 DataType,
11 Default,
12 DefaultScope,
13 ForeignKey,
14 HasMany,
15 Is,
16 Model,
17 Scopes,
18 Table,
19 UpdatedAt
20} from 'sequelize-typescript'
21import validator from 'validator'
22import { logger } from '@server/helpers/logger'
23import { extractVideo } from '@server/helpers/video'
24import { CONFIG } from '@server/initializers/config'
25import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url'
26import {
27 getHLSPrivateFileUrl,
28 getHLSPublicFileUrl,
29 getWebVideoPrivateFileUrl,
30 getWebVideoPublicFileUrl
31} from '@server/lib/object-storage'
32import { getFSTorrentFilePath } from '@server/lib/paths'
33import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
34import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoWithHost } from '@server/types/models'
35import { VideoResolution, VideoStorage } from '@shared/models'
36import { AttributesOnly } from '@shared/typescript-utils'
37import {
38 isVideoFileExtnameValid,
39 isVideoFileInfoHashValid,
40 isVideoFileResolutionValid,
41 isVideoFileSizeValid,
42 isVideoFPSResolutionValid
43} from '../../helpers/custom-validators/videos'
44import {
45 LAZY_STATIC_PATHS,
46 MEMOIZE_LENGTH,
47 MEMOIZE_TTL,
48 STATIC_DOWNLOAD_PATHS,
49 STATIC_PATHS,
50 WEBSERVER
51} from '../../initializers/constants'
52import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
53import { VideoRedundancyModel } from '../redundancy/video-redundancy'
54import { doesExist, parseAggregateResult, throwIfNotValid } from '../shared'
55import { VideoModel } from './video'
56import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
57
58export enum ScopeNames {
59 WITH_VIDEO = 'WITH_VIDEO',
60 WITH_METADATA = 'WITH_METADATA',
61 WITH_VIDEO_OR_PLAYLIST = 'WITH_VIDEO_OR_PLAYLIST'
62}
63
64@DefaultScope(() => ({
65 attributes: {
66 exclude: [ 'metadata' ]
67 }
68}))
69@Scopes(() => ({
70 [ScopeNames.WITH_VIDEO]: {
71 include: [
72 {
73 model: VideoModel.unscoped(),
74 required: true
75 }
76 ]
77 },
78 [ScopeNames.WITH_VIDEO_OR_PLAYLIST]: (options: { whereVideo?: WhereOptions } = {}) => {
79 return {
80 include: [
81 {
82 model: VideoModel.unscoped(),
83 required: false,
84 where: options.whereVideo
85 },
86 {
87 model: VideoStreamingPlaylistModel.unscoped(),
88 required: false,
89 include: [
90 {
91 model: VideoModel.unscoped(),
92 required: true,
93 where: options.whereVideo
94 }
95 ]
96 }
97 ]
98 }
99 },
100 [ScopeNames.WITH_METADATA]: {
101 attributes: {
102 include: [ 'metadata' ]
103 }
104 }
105}))
106@Table({
107 tableName: 'videoFile',
108 indexes: [
109 {
110 fields: [ 'videoId' ],
111 where: {
112 videoId: {
113 [Op.ne]: null
114 }
115 }
116 },
117 {
118 fields: [ 'videoStreamingPlaylistId' ],
119 where: {
120 videoStreamingPlaylistId: {
121 [Op.ne]: null
122 }
123 }
124 },
125
126 {
127 fields: [ 'infoHash' ]
128 },
129
130 {
131 fields: [ 'torrentFilename' ],
132 unique: true
133 },
134
135 {
136 fields: [ 'filename' ],
137 unique: true
138 },
139
140 {
141 fields: [ 'videoId', 'resolution', 'fps' ],
142 unique: true,
143 where: {
144 videoId: {
145 [Op.ne]: null
146 }
147 }
148 },
149 {
150 fields: [ 'videoStreamingPlaylistId', 'resolution', 'fps' ],
151 unique: true,
152 where: {
153 videoStreamingPlaylistId: {
154 [Op.ne]: null
155 }
156 }
157 }
158 ]
159})
160export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>>> {
161 @CreatedAt
162 createdAt: Date
163
164 @UpdatedAt
165 updatedAt: Date
166
167 @AllowNull(false)
168 @Is('VideoFileResolution', value => throwIfNotValid(value, isVideoFileResolutionValid, 'resolution'))
169 @Column
170 resolution: number
171
172 @AllowNull(false)
173 @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileSizeValid, 'size'))
174 @Column(DataType.BIGINT)
175 size: number
176
177 @AllowNull(false)
178 @Is('VideoFileExtname', value => throwIfNotValid(value, isVideoFileExtnameValid, 'extname'))
179 @Column
180 extname: string
181
182 @AllowNull(true)
183 @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash', true))
184 @Column
185 infoHash: string
186
187 @AllowNull(false)
188 @Default(-1)
189 @Is('VideoFileFPS', value => throwIfNotValid(value, isVideoFPSResolutionValid, 'fps'))
190 @Column
191 fps: number
192
193 @AllowNull(true)
194 @Column(DataType.JSONB)
195 metadata: any
196
197 @AllowNull(true)
198 @Column
199 metadataUrl: string
200
201 // Could be null for remote files
202 @AllowNull(true)
203 @Column
204 fileUrl: string
205
206 // Could be null for live files
207 @AllowNull(true)
208 @Column
209 filename: string
210
211 // Could be null for remote files
212 @AllowNull(true)
213 @Column
214 torrentUrl: string
215
216 // Could be null for live files
217 @AllowNull(true)
218 @Column
219 torrentFilename: string
220
221 @ForeignKey(() => VideoModel)
222 @Column
223 videoId: number
224
225 @AllowNull(false)
226 @Default(VideoStorage.FILE_SYSTEM)
227 @Column
228 storage: VideoStorage
229
230 @BelongsTo(() => VideoModel, {
231 foreignKey: {
232 allowNull: true
233 },
234 onDelete: 'CASCADE'
235 })
236 Video: VideoModel
237
238 @ForeignKey(() => VideoStreamingPlaylistModel)
239 @Column
240 videoStreamingPlaylistId: number
241
242 @BelongsTo(() => VideoStreamingPlaylistModel, {
243 foreignKey: {
244 allowNull: true
245 },
246 onDelete: 'CASCADE'
247 })
248 VideoStreamingPlaylist: VideoStreamingPlaylistModel
249
250 @HasMany(() => VideoRedundancyModel, {
251 foreignKey: {
252 allowNull: true
253 },
254 onDelete: 'CASCADE',
255 hooks: true
256 })
257 RedundancyVideos: VideoRedundancyModel[]
258
259 static doesInfohashExistCached = memoizee(VideoFileModel.doesInfohashExist, {
260 promise: true,
261 max: MEMOIZE_LENGTH.INFO_HASH_EXISTS,
262 maxAge: MEMOIZE_TTL.INFO_HASH_EXISTS
263 })
264
265 static doesInfohashExist (infoHash: string) {
266 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
267
268 return doesExist(this.sequelize, query, { infoHash })
269 }
270
271 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
272 const videoFile = await VideoFileModel.loadWithVideoOrPlaylist(id, videoIdOrUUID)
273
274 return !!videoFile
275 }
276
277 static async doesOwnedTorrentFileExist (filename: string) {
278 const query = 'SELECT 1 FROM "videoFile" ' +
279 'LEFT JOIN "video" "webvideo" ON "webvideo"."id" = "videoFile"."videoId" AND "webvideo"."remote" IS FALSE ' +
280 'LEFT JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoFile"."videoStreamingPlaylistId" ' +
281 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
282 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webvideo"."id" IS NOT NULL) LIMIT 1'
283
284 return doesExist(this.sequelize, query, { filename })
285 }
286
287 static async doesOwnedWebVideoFileExist (filename: string) {
288 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
289 `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
290
291 return doesExist(this.sequelize, query, { filename })
292 }
293
294 static loadByFilename (filename: string) {
295 const query = {
296 where: {
297 filename
298 }
299 }
300
301 return VideoFileModel.findOne(query)
302 }
303
304 static loadWithVideoByFilename (filename: string): Promise<MVideoFileVideo | MVideoFileStreamingPlaylistVideo> {
305 const query = {
306 where: {
307 filename
308 }
309 }
310
311 return VideoFileModel.scope(ScopeNames.WITH_VIDEO_OR_PLAYLIST).findOne(query)
312 }
313
314 static loadWithVideoOrPlaylistByTorrentFilename (filename: string) {
315 const query = {
316 where: {
317 torrentFilename: filename
318 }
319 }
320
321 return VideoFileModel.scope(ScopeNames.WITH_VIDEO_OR_PLAYLIST).findOne(query)
322 }
323
324 static load (id: number): Promise<MVideoFile> {
325 return VideoFileModel.findByPk(id)
326 }
327
328 static loadWithMetadata (id: number) {
329 return VideoFileModel.scope(ScopeNames.WITH_METADATA).findByPk(id)
330 }
331
332 static loadWithVideo (id: number) {
333 return VideoFileModel.scope(ScopeNames.WITH_VIDEO).findByPk(id)
334 }
335
336 static loadWithVideoOrPlaylist (id: number, videoIdOrUUID: number | string) {
337 const whereVideo = validator.isUUID(videoIdOrUUID + '')
338 ? { uuid: videoIdOrUUID }
339 : { id: videoIdOrUUID }
340
341 const options = {
342 where: {
343 id
344 }
345 }
346
347 return VideoFileModel.scope({ method: [ ScopeNames.WITH_VIDEO_OR_PLAYLIST, whereVideo ] })
348 .findOne(options)
349 .then(file => {
350 // We used `required: false` so check we have at least a video or a streaming playlist
351 if (!file.Video && !file.VideoStreamingPlaylist) return null
352
353 return file
354 })
355 }
356
357 static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) {
358 const query = {
359 include: [
360 {
361 model: VideoModel.unscoped(),
362 required: true,
363 include: [
364 {
365 model: VideoStreamingPlaylistModel.unscoped(),
366 required: true,
367 where: {
368 id: streamingPlaylistId
369 }
370 }
371 ]
372 }
373 ],
374 transaction
375 }
376
377 return VideoFileModel.findAll(query)
378 }
379
380 static getStats () {
381 const webVideoFilesQuery: FindOptions = {
382 include: [
383 {
384 attributes: [],
385 required: true,
386 model: VideoModel.unscoped(),
387 where: {
388 remote: false
389 }
390 }
391 ]
392 }
393
394 const hlsFilesQuery: FindOptions = {
395 include: [
396 {
397 attributes: [],
398 required: true,
399 model: VideoStreamingPlaylistModel.unscoped(),
400 include: [
401 {
402 attributes: [],
403 model: VideoModel.unscoped(),
404 required: true,
405 where: {
406 remote: false
407 }
408 }
409 ]
410 }
411 ]
412 }
413
414 return Promise.all([
415 VideoFileModel.aggregate('size', 'SUM', webVideoFilesQuery),
416 VideoFileModel.aggregate('size', 'SUM', hlsFilesQuery)
417 ]).then(([ webVideoResult, hlsResult ]) => ({
418 totalLocalVideoFilesSize: parseAggregateResult(webVideoResult) + parseAggregateResult(hlsResult)
419 }))
420 }
421
422 // Redefine upsert because sequelize does not use an appropriate where clause in the update query with 2 unique indexes
423 static async customUpsert (
424 videoFile: MVideoFile,
425 mode: 'streaming-playlist' | 'video',
426 transaction: Transaction
427 ) {
428 const baseFind = {
429 fps: videoFile.fps,
430 resolution: videoFile.resolution,
431 transaction
432 }
433
434 const element = mode === 'streaming-playlist'
435 ? await VideoFileModel.loadHLSFile({ ...baseFind, playlistId: videoFile.videoStreamingPlaylistId })
436 : await VideoFileModel.loadWebVideoFile({ ...baseFind, videoId: videoFile.videoId })
437
438 if (!element) return videoFile.save({ transaction })
439
440 for (const k of Object.keys(videoFile.toJSON())) {
441 element.set(k, videoFile[k])
442 }
443
444 return element.save({ transaction })
445 }
446
447 static async loadWebVideoFile (options: {
448 videoId: number
449 fps: number
450 resolution: number
451 transaction?: Transaction
452 }) {
453 const where = {
454 fps: options.fps,
455 resolution: options.resolution,
456 videoId: options.videoId
457 }
458
459 return VideoFileModel.findOne({ where, transaction: options.transaction })
460 }
461
462 static async loadHLSFile (options: {
463 playlistId: number
464 fps: number
465 resolution: number
466 transaction?: Transaction
467 }) {
468 const where = {
469 fps: options.fps,
470 resolution: options.resolution,
471 videoStreamingPlaylistId: options.playlistId
472 }
473
474 return VideoFileModel.findOne({ where, transaction: options.transaction })
475 }
476
477 static removeHLSFilesOfVideoId (videoStreamingPlaylistId: number) {
478 const options = {
479 where: { videoStreamingPlaylistId }
480 }
481
482 return VideoFileModel.destroy(options)
483 }
484
485 hasTorrent () {
486 return this.infoHash && this.torrentFilename
487 }
488
489 getVideoOrStreamingPlaylist (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo | MStreamingPlaylistVideo {
490 if (this.videoId || (this as MVideoFileVideo).Video) return (this as MVideoFileVideo).Video
491
492 return (this as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist
493 }
494
495 getVideo (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo {
496 return extractVideo(this.getVideoOrStreamingPlaylist())
497 }
498
499 isAudio () {
500 return this.resolution === VideoResolution.H_NOVIDEO
501 }
502
503 isLive () {
504 return this.size === -1
505 }
506
507 isHLS () {
508 return !!this.videoStreamingPlaylistId
509 }
510
511 // ---------------------------------------------------------------------------
512
513 getObjectStorageUrl (video: MVideo) {
514 if (video.hasPrivateStaticPath() && CONFIG.OBJECT_STORAGE.PROXY.PROXIFY_PRIVATE_FILES === true) {
515 return this.getPrivateObjectStorageUrl(video)
516 }
517
518 return this.getPublicObjectStorageUrl()
519 }
520
521 private getPrivateObjectStorageUrl (video: MVideo) {
522 if (this.isHLS()) {
523 return getHLSPrivateFileUrl(video, this.filename)
524 }
525
526 return getWebVideoPrivateFileUrl(this.filename)
527 }
528
529 private getPublicObjectStorageUrl () {
530 if (this.isHLS()) {
531 return getHLSPublicFileUrl(this.fileUrl)
532 }
533
534 return getWebVideoPublicFileUrl(this.fileUrl)
535 }
536
537 // ---------------------------------------------------------------------------
538
539 getFileUrl (video: MVideo) {
540 if (video.isOwned()) {
541 if (this.storage === VideoStorage.OBJECT_STORAGE) {
542 return this.getObjectStorageUrl(video)
543 }
544
545 return WEBSERVER.URL + this.getFileStaticPath(video)
546 }
547
548 return this.fileUrl
549 }
550
551 // ---------------------------------------------------------------------------
552
553 getFileStaticPath (video: MVideo) {
554 if (this.isHLS()) return this.getHLSFileStaticPath(video)
555
556 return this.getWebVideoFileStaticPath(video)
557 }
558
559 private getWebVideoFileStaticPath (video: MVideo) {
560 if (isVideoInPrivateDirectory(video.privacy)) {
561 return join(STATIC_PATHS.PRIVATE_WEB_VIDEOS, this.filename)
562 }
563
564 return join(STATIC_PATHS.WEB_VIDEOS, this.filename)
565 }
566
567 private getHLSFileStaticPath (video: MVideo) {
568 if (isVideoInPrivateDirectory(video.privacy)) {
569 return join(STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, video.uuid, this.filename)
570 }
571
572 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.filename)
573 }
574
575 // ---------------------------------------------------------------------------
576
577 getFileDownloadUrl (video: MVideoWithHost) {
578 const path = this.isHLS()
579 ? join(STATIC_DOWNLOAD_PATHS.HLS_VIDEOS, `${video.uuid}-${this.resolution}-fragmented${this.extname}`)
580 : join(STATIC_DOWNLOAD_PATHS.VIDEOS, `${video.uuid}-${this.resolution}${this.extname}`)
581
582 if (video.isOwned()) return WEBSERVER.URL + path
583
584 // FIXME: don't guess remote URL
585 return buildRemoteVideoBaseUrl(video, path)
586 }
587
588 getRemoteTorrentUrl (video: MVideo) {
589 if (video.isOwned()) throw new Error(`Video ${video.url} is not a remote video`)
590
591 return this.torrentUrl
592 }
593
594 // We proxify torrent requests so use a local URL
595 getTorrentUrl () {
596 if (!this.torrentFilename) return null
597
598 return WEBSERVER.URL + this.getTorrentStaticPath()
599 }
600
601 getTorrentStaticPath () {
602 if (!this.torrentFilename) return null
603
604 return join(LAZY_STATIC_PATHS.TORRENTS, this.torrentFilename)
605 }
606
607 getTorrentDownloadUrl () {
608 if (!this.torrentFilename) return null
609
610 return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.TORRENTS, this.torrentFilename)
611 }
612
613 removeTorrent () {
614 if (!this.torrentFilename) return null
615
616 const torrentPath = getFSTorrentFilePath(this)
617 return remove(torrentPath)
618 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
619 }
620
621 hasSameUniqueKeysThan (other: MVideoFile) {
622 return this.fps === other.fps &&
623 this.resolution === other.resolution &&
624 (
625 (this.videoId !== null && this.videoId === other.videoId) ||
626 (this.videoStreamingPlaylistId !== null && this.videoStreamingPlaylistId === other.videoStreamingPlaylistId)
627 )
628 }
629
630 withVideoOrPlaylist (videoOrPlaylist: MVideo | MStreamingPlaylistVideo) {
631 if (isStreamingPlaylist(videoOrPlaylist)) return Object.assign(this, { VideoStreamingPlaylist: videoOrPlaylist })
632
633 return Object.assign(this, { Video: videoOrPlaylist })
634 }
635}
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
deleted file mode 100644
index c040e0fda..000000000
--- a/server/models/video/video-import.ts
+++ /dev/null
@@ -1,267 +0,0 @@
1import { IncludeOptions, Op, WhereOptions } from 'sequelize'
2import {
3 AfterUpdate,
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 Default,
10 DefaultScope,
11 ForeignKey,
12 Is,
13 Model,
14 Table,
15 UpdatedAt
16} from 'sequelize-typescript'
17import { afterCommitIfTransaction } from '@server/helpers/database-utils'
18import { MVideoImportDefault, MVideoImportFormattable } from '@server/types/models/video/video-import'
19import { VideoImport, VideoImportState } from '@shared/models'
20import { AttributesOnly } from '@shared/typescript-utils'
21import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports'
22import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
23import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
24import { UserModel } from '../user/user'
25import { getSort, searchAttribute, throwIfNotValid } from '../shared'
26import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
27import { VideoChannelSyncModel } from './video-channel-sync'
28
29const defaultVideoScope = () => {
30 return VideoModel.scope([
31 VideoModelScopeNames.WITH_ACCOUNT_DETAILS,
32 VideoModelScopeNames.WITH_TAGS,
33 VideoModelScopeNames.WITH_THUMBNAILS
34 ])
35}
36
37@DefaultScope(() => ({
38 include: [
39 {
40 model: UserModel.unscoped(),
41 required: true
42 },
43 {
44 model: defaultVideoScope(),
45 required: false
46 },
47 {
48 model: VideoChannelSyncModel.unscoped(),
49 required: false
50 }
51 ]
52}))
53
54@Table({
55 tableName: 'videoImport',
56 indexes: [
57 {
58 fields: [ 'videoId' ],
59 unique: true
60 },
61 {
62 fields: [ 'userId' ]
63 }
64 ]
65})
66export class VideoImportModel extends Model<Partial<AttributesOnly<VideoImportModel>>> {
67 @CreatedAt
68 createdAt: Date
69
70 @UpdatedAt
71 updatedAt: Date
72
73 @AllowNull(true)
74 @Default(null)
75 @Is('VideoImportTargetUrl', value => throwIfNotValid(value, isVideoImportTargetUrlValid, 'targetUrl', true))
76 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max))
77 targetUrl: string
78
79 @AllowNull(true)
80 @Default(null)
81 @Is('VideoImportMagnetUri', value => throwIfNotValid(value, isVideoMagnetUriValid, 'magnetUri', true))
82 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max)) // Use the same constraints than URLs
83 magnetUri: string
84
85 @AllowNull(true)
86 @Default(null)
87 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_NAME.max))
88 torrentName: string
89
90 @AllowNull(false)
91 @Default(null)
92 @Is('VideoImportState', value => throwIfNotValid(value, isVideoImportStateValid, 'state'))
93 @Column
94 state: VideoImportState
95
96 @AllowNull(true)
97 @Default(null)
98 @Column(DataType.TEXT)
99 error: string
100
101 @ForeignKey(() => UserModel)
102 @Column
103 userId: number
104
105 @BelongsTo(() => UserModel, {
106 foreignKey: {
107 allowNull: false
108 },
109 onDelete: 'cascade'
110 })
111 User: UserModel
112
113 @ForeignKey(() => VideoModel)
114 @Column
115 videoId: number
116
117 @BelongsTo(() => VideoModel, {
118 foreignKey: {
119 allowNull: true
120 },
121 onDelete: 'set null'
122 })
123 Video: VideoModel
124
125 @ForeignKey(() => VideoChannelSyncModel)
126 @Column
127 videoChannelSyncId: number
128
129 @BelongsTo(() => VideoChannelSyncModel, {
130 foreignKey: {
131 allowNull: true
132 },
133 onDelete: 'set null'
134 })
135 VideoChannelSync: VideoChannelSyncModel
136
137 @AfterUpdate
138 static deleteVideoIfFailed (instance: VideoImportModel, options) {
139 if (instance.state === VideoImportState.FAILED) {
140 return afterCommitIfTransaction(options.transaction, () => instance.Video.destroy())
141 }
142
143 return undefined
144 }
145
146 static loadAndPopulateVideo (id: number): Promise<MVideoImportDefault> {
147 return VideoImportModel.findByPk(id)
148 }
149
150 static listUserVideoImportsForApi (options: {
151 userId: number
152 start: number
153 count: number
154 sort: string
155
156 search?: string
157 targetUrl?: string
158 videoChannelSyncId?: number
159 }) {
160 const { userId, start, count, sort, targetUrl, videoChannelSyncId, search } = options
161
162 const where: WhereOptions = { userId }
163 const include: IncludeOptions[] = [
164 {
165 attributes: [ 'id' ],
166 model: UserModel.unscoped(), // FIXME: Without this, sequelize try to COUNT(DISTINCT(*)) which is an invalid SQL query
167 required: true
168 },
169 {
170 model: VideoChannelSyncModel.unscoped(),
171 required: false
172 }
173 ]
174
175 if (targetUrl) where['targetUrl'] = targetUrl
176 if (videoChannelSyncId) where['videoChannelSyncId'] = videoChannelSyncId
177
178 if (search) {
179 include.push({
180 model: defaultVideoScope(),
181 required: true,
182 where: searchAttribute(search, 'name')
183 })
184 } else {
185 include.push({
186 model: defaultVideoScope(),
187 required: false
188 })
189 }
190
191 const query = {
192 distinct: true,
193 include,
194 offset: start,
195 limit: count,
196 order: getSort(sort),
197 where
198 }
199
200 return Promise.all([
201 VideoImportModel.unscoped().count(query),
202 VideoImportModel.findAll<MVideoImportDefault>(query)
203 ]).then(([ total, data ]) => ({ total, data }))
204 }
205
206 static async urlAlreadyImported (channelId: number, targetUrl: string): Promise<boolean> {
207 const element = await VideoImportModel.unscoped().findOne({
208 where: {
209 targetUrl,
210 state: {
211 [Op.in]: [ VideoImportState.PENDING, VideoImportState.PROCESSING, VideoImportState.SUCCESS ]
212 }
213 },
214 include: [
215 {
216 model: VideoModel,
217 required: true,
218 where: {
219 channelId
220 }
221 }
222 ]
223 })
224
225 return !!element
226 }
227
228 getTargetIdentifier () {
229 return this.targetUrl || this.magnetUri || this.torrentName
230 }
231
232 toFormattedJSON (this: MVideoImportFormattable): VideoImport {
233 const videoFormatOptions = {
234 completeDescription: true,
235 additionalAttributes: { state: true, waitTranscoding: true, scheduledUpdate: true }
236 }
237 const video = this.Video
238 ? Object.assign(this.Video.toFormattedJSON(videoFormatOptions), { tags: this.Video.Tags.map(t => t.name) })
239 : undefined
240
241 const videoChannelSync = this.VideoChannelSync
242 ? { id: this.VideoChannelSync.id, externalChannelUrl: this.VideoChannelSync.externalChannelUrl }
243 : undefined
244
245 return {
246 id: this.id,
247
248 targetUrl: this.targetUrl,
249 magnetUri: this.magnetUri,
250 torrentName: this.torrentName,
251
252 state: {
253 id: this.state,
254 label: VideoImportModel.getStateLabel(this.state)
255 },
256 error: this.error,
257 updatedAt: this.updatedAt.toISOString(),
258 createdAt: this.createdAt.toISOString(),
259 video,
260 videoChannelSync
261 }
262 }
263
264 private static getStateLabel (id: number) {
265 return VIDEO_IMPORT_STATES[id] || 'Unknown'
266 }
267}
diff --git a/server/models/video/video-job-info.ts b/server/models/video/video-job-info.ts
deleted file mode 100644
index 5845b8c74..000000000
--- a/server/models/video/video-job-info.ts
+++ /dev/null
@@ -1,121 +0,0 @@
1import { Op, QueryTypes, Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, IsInt, Model, Table, Unique, UpdatedAt } from 'sequelize-typescript'
3import { forceNumber } from '@shared/core-utils'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoModel } from './video'
6
7export type VideoJobInfoColumnType = 'pendingMove' | 'pendingTranscode'
8
9@Table({
10 tableName: 'videoJobInfo',
11 indexes: [
12 {
13 fields: [ 'videoId' ],
14 where: {
15 videoId: {
16 [Op.ne]: null
17 }
18 }
19 }
20 ]
21})
22
23export class VideoJobInfoModel extends Model<Partial<AttributesOnly<VideoJobInfoModel>>> {
24 @CreatedAt
25 createdAt: Date
26
27 @UpdatedAt
28 updatedAt: Date
29
30 @AllowNull(false)
31 @Default(0)
32 @IsInt
33 @Column
34 pendingMove: number
35
36 @AllowNull(false)
37 @Default(0)
38 @IsInt
39 @Column
40 pendingTranscode: number
41
42 @ForeignKey(() => VideoModel)
43 @Unique
44 @Column
45 videoId: number
46
47 @BelongsTo(() => VideoModel, {
48 foreignKey: {
49 allowNull: false
50 },
51 onDelete: 'cascade'
52 })
53 Video: VideoModel
54
55 static load (videoId: number, transaction?: Transaction) {
56 const where = {
57 videoId
58 }
59
60 return VideoJobInfoModel.findOne({ where, transaction })
61 }
62
63 static async increaseOrCreate (videoUUID: string, column: VideoJobInfoColumnType, amountArg = 1): Promise<number> {
64 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT, bind: { videoUUID } }
65 const amount = forceNumber(amountArg)
66
67 const [ result ] = await VideoJobInfoModel.sequelize.query<{ pendingMove: number }>(`
68 INSERT INTO "videoJobInfo" ("videoId", "${column}", "createdAt", "updatedAt")
69 SELECT
70 "video"."id" AS "videoId", ${amount}, NOW(), NOW()
71 FROM
72 "video"
73 WHERE
74 "video"."uuid" = $videoUUID
75 ON CONFLICT ("videoId") DO UPDATE
76 SET
77 "${column}" = "videoJobInfo"."${column}" + ${amount},
78 "updatedAt" = NOW()
79 RETURNING
80 "${column}"
81 `, options)
82
83 return result[column]
84 }
85
86 static async decrease (videoUUID: string, column: VideoJobInfoColumnType): Promise<number> {
87 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT, bind: { videoUUID } }
88
89 const result = await VideoJobInfoModel.sequelize.query(`
90 UPDATE
91 "videoJobInfo"
92 SET
93 "${column}" = "videoJobInfo"."${column}" - 1,
94 "updatedAt" = NOW()
95 FROM "video"
96 WHERE
97 "video"."id" = "videoJobInfo"."videoId" AND "video"."uuid" = $videoUUID
98 RETURNING
99 "${column}";
100 `, options)
101
102 if (result.length === 0) return undefined
103
104 return result[0][column]
105 }
106
107 static async abortAllTasks (videoUUID: string, column: VideoJobInfoColumnType): Promise<void> {
108 const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE, bind: { videoUUID } }
109
110 await VideoJobInfoModel.sequelize.query(`
111 UPDATE
112 "videoJobInfo"
113 SET
114 "${column}" = 0,
115 "updatedAt" = NOW()
116 FROM "video"
117 WHERE
118 "video"."id" = "videoJobInfo"."videoId" AND "video"."uuid" = $videoUUID
119 `, options)
120 }
121}
diff --git a/server/models/video/video-live-replay-setting.ts b/server/models/video/video-live-replay-setting.ts
deleted file mode 100644
index 1c824dfa2..000000000
--- a/server/models/video/video-live-replay-setting.ts
+++ /dev/null
@@ -1,42 +0,0 @@
1import { isVideoPrivacyValid } from '@server/helpers/custom-validators/videos'
2import { MLiveReplaySetting } from '@server/types/models/video/video-live-replay-setting'
3import { VideoPrivacy } from '@shared/models/videos/video-privacy.enum'
4import { Transaction } from 'sequelize'
5import { AllowNull, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
6import { throwIfNotValid } from '../shared/sequelize-helpers'
7
8@Table({
9 tableName: 'videoLiveReplaySetting'
10})
11export class VideoLiveReplaySettingModel extends Model<VideoLiveReplaySettingModel> {
12
13 @CreatedAt
14 createdAt: Date
15
16 @UpdatedAt
17 updatedAt: Date
18
19 @AllowNull(false)
20 @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
21 @Column
22 privacy: VideoPrivacy
23
24 static load (id: number, transaction?: Transaction): Promise<MLiveReplaySetting> {
25 return VideoLiveReplaySettingModel.findOne({
26 where: { id },
27 transaction
28 })
29 }
30
31 static removeSettings (id: number) {
32 return VideoLiveReplaySettingModel.destroy({
33 where: { id }
34 })
35 }
36
37 toFormattedJSON () {
38 return {
39 privacy: this.privacy
40 }
41 }
42}
diff --git a/server/models/video/video-live-session.ts b/server/models/video/video-live-session.ts
deleted file mode 100644
index 9426f5d11..000000000
--- a/server/models/video/video-live-session.ts
+++ /dev/null
@@ -1,217 +0,0 @@
1import { FindOptions } from 'sequelize'
2import {
3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 Model,
11 Scopes,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { MVideoLiveSession, MVideoLiveSessionReplay } from '@server/types/models'
16import { uuidToShort } from '@shared/extra-utils'
17import { LiveVideoError, LiveVideoSession } from '@shared/models'
18import { AttributesOnly } from '@shared/typescript-utils'
19import { VideoModel } from './video'
20import { VideoLiveReplaySettingModel } from './video-live-replay-setting'
21
22export enum ScopeNames {
23 WITH_REPLAY = 'WITH_REPLAY'
24}
25
26@Scopes(() => ({
27 [ScopeNames.WITH_REPLAY]: {
28 include: [
29 {
30 model: VideoModel.unscoped(),
31 as: 'ReplayVideo',
32 required: false
33 },
34 {
35 model: VideoLiveReplaySettingModel,
36 required: false
37 }
38 ]
39 }
40}))
41@Table({
42 tableName: 'videoLiveSession',
43 indexes: [
44 {
45 fields: [ 'replayVideoId' ],
46 unique: true
47 },
48 {
49 fields: [ 'liveVideoId' ]
50 },
51 {
52 fields: [ 'replaySettingId' ],
53 unique: true
54 }
55 ]
56})
57export class VideoLiveSessionModel extends Model<Partial<AttributesOnly<VideoLiveSessionModel>>> {
58
59 @CreatedAt
60 createdAt: Date
61
62 @UpdatedAt
63 updatedAt: Date
64
65 @AllowNull(false)
66 @Column(DataType.DATE)
67 startDate: Date
68
69 @AllowNull(true)
70 @Column(DataType.DATE)
71 endDate: Date
72
73 @AllowNull(true)
74 @Column
75 error: LiveVideoError
76
77 @AllowNull(false)
78 @Column
79 saveReplay: boolean
80
81 @AllowNull(false)
82 @Column
83 endingProcessed: boolean
84
85 @ForeignKey(() => VideoModel)
86 @Column
87 replayVideoId: number
88
89 @BelongsTo(() => VideoModel, {
90 foreignKey: {
91 allowNull: true,
92 name: 'replayVideoId'
93 },
94 as: 'ReplayVideo',
95 onDelete: 'set null'
96 })
97 ReplayVideo: VideoModel
98
99 @ForeignKey(() => VideoModel)
100 @Column
101 liveVideoId: number
102
103 @BelongsTo(() => VideoModel, {
104 foreignKey: {
105 allowNull: true,
106 name: 'liveVideoId'
107 },
108 as: 'LiveVideo',
109 onDelete: 'set null'
110 })
111 LiveVideo: VideoModel
112
113 @ForeignKey(() => VideoLiveReplaySettingModel)
114 @Column
115 replaySettingId: number
116
117 @BelongsTo(() => VideoLiveReplaySettingModel, {
118 foreignKey: {
119 allowNull: true
120 },
121 onDelete: 'set null'
122 })
123 ReplaySetting: VideoLiveReplaySettingModel
124
125 @BeforeDestroy
126 static deleteReplaySetting (instance: VideoLiveSessionModel) {
127 return VideoLiveReplaySettingModel.destroy({
128 where: {
129 id: instance.replaySettingId
130 }
131 })
132 }
133
134 static load (id: number): Promise<MVideoLiveSession> {
135 return VideoLiveSessionModel.findOne({
136 where: { id }
137 })
138 }
139
140 static findSessionOfReplay (replayVideoId: number) {
141 const query = {
142 where: {
143 replayVideoId
144 }
145 }
146
147 return VideoLiveSessionModel.scope(ScopeNames.WITH_REPLAY).findOne(query)
148 }
149
150 static findCurrentSessionOf (videoUUID: string) {
151 return VideoLiveSessionModel.findOne({
152 where: {
153 endDate: null
154 },
155 include: [
156 {
157 model: VideoModel.unscoped(),
158 as: 'LiveVideo',
159 required: true,
160 where: {
161 uuid: videoUUID
162 }
163 }
164 ],
165 order: [ [ 'startDate', 'DESC' ] ]
166 })
167 }
168
169 static findLatestSessionOf (videoId: number) {
170 return VideoLiveSessionModel.findOne({
171 where: {
172 liveVideoId: videoId
173 },
174 order: [ [ 'startDate', 'DESC' ] ]
175 })
176 }
177
178 static listSessionsOfLiveForAPI (options: { videoId: number }) {
179 const { videoId } = options
180
181 const query: FindOptions<AttributesOnly<VideoLiveSessionModel>> = {
182 where: {
183 liveVideoId: videoId
184 },
185 order: [ [ 'startDate', 'ASC' ] ]
186 }
187
188 return VideoLiveSessionModel.scope(ScopeNames.WITH_REPLAY).findAll(query)
189 }
190
191 toFormattedJSON (this: MVideoLiveSessionReplay): LiveVideoSession {
192 const replayVideo = this.ReplayVideo
193 ? {
194 id: this.ReplayVideo.id,
195 uuid: this.ReplayVideo.uuid,
196 shortUUID: uuidToShort(this.ReplayVideo.uuid)
197 }
198 : undefined
199
200 const replaySettings = this.replaySettingId
201 ? this.ReplaySetting.toFormattedJSON()
202 : undefined
203
204 return {
205 id: this.id,
206 startDate: this.startDate.toISOString(),
207 endDate: this.endDate
208 ? this.endDate.toISOString()
209 : null,
210 endingProcessed: this.endingProcessed,
211 saveReplay: this.saveReplay,
212 replaySettings,
213 replayVideo,
214 error: this.error
215 }
216 }
217}
diff --git a/server/models/video/video-live.ts b/server/models/video/video-live.ts
deleted file mode 100644
index ca1118641..000000000
--- a/server/models/video/video-live.ts
+++ /dev/null
@@ -1,184 +0,0 @@
1import { Transaction } from 'sequelize'
2import {
3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 DefaultScope,
10 ForeignKey,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { CONFIG } from '@server/initializers/config'
16import { WEBSERVER } from '@server/initializers/constants'
17import { MVideoLive, MVideoLiveVideoWithSetting } from '@server/types/models'
18import { LiveVideo, LiveVideoLatencyMode, VideoState } from '@shared/models'
19import { AttributesOnly } from '@shared/typescript-utils'
20import { VideoModel } from './video'
21import { VideoBlacklistModel } from './video-blacklist'
22import { VideoLiveReplaySettingModel } from './video-live-replay-setting'
23
24@DefaultScope(() => ({
25 include: [
26 {
27 model: VideoModel,
28 required: true,
29 include: [
30 {
31 model: VideoBlacklistModel,
32 required: false
33 }
34 ]
35 },
36 {
37 model: VideoLiveReplaySettingModel,
38 required: false
39 }
40 ]
41}))
42@Table({
43 tableName: 'videoLive',
44 indexes: [
45 {
46 fields: [ 'videoId' ],
47 unique: true
48 },
49 {
50 fields: [ 'replaySettingId' ],
51 unique: true
52 }
53 ]
54})
55export class VideoLiveModel extends Model<Partial<AttributesOnly<VideoLiveModel>>> {
56
57 @AllowNull(true)
58 @Column(DataType.STRING)
59 streamKey: string
60
61 @AllowNull(false)
62 @Column
63 saveReplay: boolean
64
65 @AllowNull(false)
66 @Column
67 permanentLive: boolean
68
69 @AllowNull(false)
70 @Column
71 latencyMode: LiveVideoLatencyMode
72
73 @CreatedAt
74 createdAt: Date
75
76 @UpdatedAt
77 updatedAt: Date
78
79 @ForeignKey(() => VideoModel)
80 @Column
81 videoId: number
82
83 @BelongsTo(() => VideoModel, {
84 foreignKey: {
85 allowNull: false
86 },
87 onDelete: 'cascade'
88 })
89 Video: VideoModel
90
91 @ForeignKey(() => VideoLiveReplaySettingModel)
92 @Column
93 replaySettingId: number
94
95 @BelongsTo(() => VideoLiveReplaySettingModel, {
96 foreignKey: {
97 allowNull: true
98 },
99 onDelete: 'set null'
100 })
101 ReplaySetting: VideoLiveReplaySettingModel
102
103 @BeforeDestroy
104 static deleteReplaySetting (instance: VideoLiveModel, options: { transaction: Transaction }) {
105 return VideoLiveReplaySettingModel.destroy({
106 where: {
107 id: instance.replaySettingId
108 },
109 transaction: options.transaction
110 })
111 }
112
113 static loadByStreamKey (streamKey: string) {
114 const query = {
115 where: {
116 streamKey
117 },
118 include: [
119 {
120 model: VideoModel.unscoped(),
121 required: true,
122 where: {
123 state: VideoState.WAITING_FOR_LIVE
124 },
125 include: [
126 {
127 model: VideoBlacklistModel.unscoped(),
128 required: false
129 }
130 ]
131 },
132 {
133 model: VideoLiveReplaySettingModel.unscoped(),
134 required: false
135 }
136 ]
137 }
138
139 return VideoLiveModel.findOne<MVideoLiveVideoWithSetting>(query)
140 }
141
142 static loadByVideoId (videoId: number) {
143 const query = {
144 where: {
145 videoId
146 }
147 }
148
149 return VideoLiveModel.findOne<MVideoLive>(query)
150 }
151
152 toFormattedJSON (canSeePrivateInformation: boolean): LiveVideo {
153 let privateInformation: Pick<LiveVideo, 'rtmpUrl' | 'rtmpsUrl' | 'streamKey'> | {} = {}
154
155 // If we don't have a stream key, it means this is a remote live so we don't specify the rtmp URL
156 // We also display these private information only to the live owne/moderators
157 if (this.streamKey && canSeePrivateInformation === true) {
158 privateInformation = {
159 streamKey: this.streamKey,
160
161 rtmpUrl: CONFIG.LIVE.RTMP.ENABLED
162 ? WEBSERVER.RTMP_BASE_LIVE_URL
163 : null,
164
165 rtmpsUrl: CONFIG.LIVE.RTMPS.ENABLED
166 ? WEBSERVER.RTMPS_BASE_LIVE_URL
167 : null
168 }
169 }
170
171 const replaySettings = this.replaySettingId
172 ? this.ReplaySetting.toFormattedJSON()
173 : undefined
174
175 return {
176 ...privateInformation,
177
178 permanentLive: this.permanentLive,
179 saveReplay: this.saveReplay,
180 replaySettings,
181 latencyMode: this.latencyMode
182 }
183 }
184}
diff --git a/server/models/video/video-password.ts b/server/models/video/video-password.ts
deleted file mode 100644
index 648366c3b..000000000
--- a/server/models/video/video-password.ts
+++ /dev/null
@@ -1,137 +0,0 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DefaultScope, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { VideoModel } from './video'
3import { AttributesOnly } from '@shared/typescript-utils'
4import { ResultList, VideoPassword } from '@shared/models'
5import { getSort, throwIfNotValid } from '../shared'
6import { FindOptions, Transaction } from 'sequelize'
7import { MVideoPassword } from '@server/types/models'
8import { isPasswordValid } from '@server/helpers/custom-validators/videos'
9import { pick } from '@shared/core-utils'
10
11@DefaultScope(() => ({
12 include: [
13 {
14 model: VideoModel.unscoped(),
15 required: true
16 }
17 ]
18}))
19@Table({
20 tableName: 'videoPassword',
21 indexes: [
22 {
23 fields: [ 'videoId', 'password' ],
24 unique: true
25 }
26 ]
27})
28export class VideoPasswordModel extends Model<Partial<AttributesOnly<VideoPasswordModel>>> {
29
30 @AllowNull(false)
31 @Is('VideoPassword', value => throwIfNotValid(value, isPasswordValid, 'videoPassword'))
32 @Column
33 password: string
34
35 @CreatedAt
36 createdAt: Date
37
38 @UpdatedAt
39 updatedAt: Date
40
41 @ForeignKey(() => VideoModel)
42 @Column
43 videoId: number
44
45 @BelongsTo(() => VideoModel, {
46 foreignKey: {
47 allowNull: false
48 },
49 onDelete: 'cascade'
50 })
51 Video: VideoModel
52
53 static async countByVideoId (videoId: number, t?: Transaction) {
54 const query: FindOptions = {
55 where: {
56 videoId
57 },
58 transaction: t
59 }
60
61 return VideoPasswordModel.count(query)
62 }
63
64 static async loadByIdAndVideo (options: { id: number, videoId: number, t?: Transaction }): Promise<MVideoPassword> {
65 const { id, videoId, t } = options
66 const query: FindOptions = {
67 where: {
68 id,
69 videoId
70 },
71 transaction: t
72 }
73
74 return VideoPasswordModel.findOne(query)
75 }
76
77 static async listPasswords (options: {
78 start: number
79 count: number
80 sort: string
81 videoId: number
82 }): Promise<ResultList<MVideoPassword>> {
83 const { start, count, sort, videoId } = options
84
85 const { count: total, rows: data } = await VideoPasswordModel.findAndCountAll({
86 where: { videoId },
87 order: getSort(sort),
88 offset: start,
89 limit: count
90 })
91
92 return { total, data }
93 }
94
95 static async addPasswords (passwords: string[], videoId: number, transaction?: Transaction): Promise<void> {
96 for (const password of passwords) {
97 await VideoPasswordModel.create({
98 password,
99 videoId
100 }, { transaction })
101 }
102 }
103
104 static async deleteAllPasswords (videoId: number, transaction?: Transaction) {
105 await VideoPasswordModel.destroy({
106 where: { videoId },
107 transaction
108 })
109 }
110
111 static async deletePassword (passwordId: number, transaction?: Transaction) {
112 await VideoPasswordModel.destroy({
113 where: { id: passwordId },
114 transaction
115 })
116 }
117
118 static async isACorrectPassword (options: {
119 videoId: number
120 password: string
121 }) {
122 const query = {
123 where: pick(options, [ 'videoId', 'password' ])
124 }
125 return VideoPasswordModel.findOne(query)
126 }
127
128 toFormattedJSON (): VideoPassword {
129 return {
130 id: this.id,
131 password: this.password,
132 videoId: this.videoId,
133 createdAt: this.createdAt,
134 updatedAt: this.updatedAt
135 }
136 }
137}
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
deleted file mode 100644
index 61ae6b9fe..000000000
--- a/server/models/video/video-playlist-element.ts
+++ /dev/null
@@ -1,370 +0,0 @@
1import { AggregateOptions, Op, ScopeOptions, Sequelize, Transaction } from 'sequelize'
2import {
3 AllowNull,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 Default,
9 ForeignKey,
10 Is,
11 IsInt,
12 Min,
13 Model,
14 Table,
15 UpdatedAt
16} from 'sequelize-typescript'
17import validator from 'validator'
18import { MUserAccountId } from '@server/types/models'
19import {
20 MVideoPlaylistElement,
21 MVideoPlaylistElementAP,
22 MVideoPlaylistElementFormattable,
23 MVideoPlaylistElementVideoUrlPlaylistPrivacy,
24 MVideoPlaylistVideoThumbnail
25} from '@server/types/models/video/video-playlist-element'
26import { forceNumber } from '@shared/core-utils'
27import { AttributesOnly } from '@shared/typescript-utils'
28import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
29import { VideoPrivacy } from '../../../shared/models/videos'
30import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/models/videos/playlist/video-playlist-element.model'
31import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
32import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
33import { AccountModel } from '../account/account'
34import { getSort, throwIfNotValid } from '../shared'
35import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
36import { VideoPlaylistModel } from './video-playlist'
37
38@Table({
39 tableName: 'videoPlaylistElement',
40 indexes: [
41 {
42 fields: [ 'videoPlaylistId' ]
43 },
44 {
45 fields: [ 'videoId' ]
46 },
47 {
48 fields: [ 'url' ],
49 unique: true
50 }
51 ]
52})
53export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<VideoPlaylistElementModel>>> {
54 @CreatedAt
55 createdAt: Date
56
57 @UpdatedAt
58 updatedAt: Date
59
60 @AllowNull(true)
61 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url', true))
62 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
63 url: string
64
65 @AllowNull(false)
66 @Default(1)
67 @IsInt
68 @Min(1)
69 @Column
70 position: number
71
72 @AllowNull(true)
73 @IsInt
74 @Min(0)
75 @Column
76 startTimestamp: number
77
78 @AllowNull(true)
79 @IsInt
80 @Min(0)
81 @Column
82 stopTimestamp: number
83
84 @ForeignKey(() => VideoPlaylistModel)
85 @Column
86 videoPlaylistId: number
87
88 @BelongsTo(() => VideoPlaylistModel, {
89 foreignKey: {
90 allowNull: false
91 },
92 onDelete: 'CASCADE'
93 })
94 VideoPlaylist: VideoPlaylistModel
95
96 @ForeignKey(() => VideoModel)
97 @Column
98 videoId: number
99
100 @BelongsTo(() => VideoModel, {
101 foreignKey: {
102 allowNull: true
103 },
104 onDelete: 'set null'
105 })
106 Video: VideoModel
107
108 static deleteAllOf (videoPlaylistId: number, transaction?: Transaction) {
109 const query = {
110 where: {
111 videoPlaylistId
112 },
113 transaction
114 }
115
116 return VideoPlaylistElementModel.destroy(query)
117 }
118
119 static listForApi (options: {
120 start: number
121 count: number
122 videoPlaylistId: number
123 serverAccount: AccountModel
124 user?: MUserAccountId
125 }) {
126 const accountIds = [ options.serverAccount.id ]
127 const videoScope: (ScopeOptions | string)[] = [
128 VideoScopeNames.WITH_BLACKLISTED
129 ]
130
131 if (options.user) {
132 accountIds.push(options.user.Account.id)
133 videoScope.push({ method: [ VideoScopeNames.WITH_USER_HISTORY, options.user.id ] })
134 }
135
136 const forApiOptions: ForAPIOptions = { withAccountBlockerIds: accountIds }
137 videoScope.push({
138 method: [
139 VideoScopeNames.FOR_API, forApiOptions
140 ]
141 })
142
143 const findQuery = {
144 offset: options.start,
145 limit: options.count,
146 order: getSort('position'),
147 where: {
148 videoPlaylistId: options.videoPlaylistId
149 },
150 include: [
151 {
152 model: VideoModel.scope(videoScope),
153 required: false
154 }
155 ]
156 }
157
158 const countQuery = {
159 where: {
160 videoPlaylistId: options.videoPlaylistId
161 }
162 }
163
164 return Promise.all([
165 VideoPlaylistElementModel.count(countQuery),
166 VideoPlaylistElementModel.findAll(findQuery)
167 ]).then(([ total, data ]) => ({ total, data }))
168 }
169
170 static loadByPlaylistAndVideo (videoPlaylistId: number, videoId: number): Promise<MVideoPlaylistElement> {
171 const query = {
172 where: {
173 videoPlaylistId,
174 videoId
175 }
176 }
177
178 return VideoPlaylistElementModel.findOne(query)
179 }
180
181 static loadById (playlistElementId: number | string): Promise<MVideoPlaylistElement> {
182 return VideoPlaylistElementModel.findByPk(playlistElementId)
183 }
184
185 static loadByPlaylistAndElementIdForAP (
186 playlistId: number | string,
187 playlistElementId: number
188 ): Promise<MVideoPlaylistElementVideoUrlPlaylistPrivacy> {
189 const playlistWhere = validator.isUUID('' + playlistId)
190 ? { uuid: playlistId }
191 : { id: playlistId }
192
193 const query = {
194 include: [
195 {
196 attributes: [ 'privacy' ],
197 model: VideoPlaylistModel.unscoped(),
198 where: playlistWhere
199 },
200 {
201 attributes: [ 'url' ],
202 model: VideoModel.unscoped()
203 }
204 ],
205 where: {
206 id: playlistElementId
207 }
208 }
209
210 return VideoPlaylistElementModel.findOne(query)
211 }
212
213 static listUrlsOfForAP (videoPlaylistId: number, start: number, count: number, t?: Transaction) {
214 const getQuery = (forCount: boolean) => {
215 return {
216 attributes: forCount
217 ? []
218 : [ 'url' ],
219 offset: start,
220 limit: count,
221 order: getSort('position'),
222 where: {
223 videoPlaylistId
224 },
225 transaction: t
226 }
227 }
228
229 return Promise.all([
230 VideoPlaylistElementModel.count(getQuery(true)),
231 VideoPlaylistElementModel.findAll(getQuery(false))
232 ]).then(([ total, rows ]) => ({
233 total,
234 data: rows.map(e => e.url)
235 }))
236 }
237
238 static loadFirstElementWithVideoThumbnail (videoPlaylistId: number): Promise<MVideoPlaylistVideoThumbnail> {
239 const query = {
240 order: getSort('position'),
241 where: {
242 videoPlaylistId
243 },
244 include: [
245 {
246 model: VideoModel.scope(VideoScopeNames.WITH_THUMBNAILS),
247 required: true
248 }
249 ]
250 }
251
252 return VideoPlaylistElementModel
253 .findOne(query)
254 }
255
256 static getNextPositionOf (videoPlaylistId: number, transaction?: Transaction) {
257 const query: AggregateOptions<number> = {
258 where: {
259 videoPlaylistId
260 },
261 transaction
262 }
263
264 return VideoPlaylistElementModel.max('position', query)
265 .then(position => position ? position + 1 : 1)
266 }
267
268 static reassignPositionOf (options: {
269 videoPlaylistId: number
270 firstPosition: number
271 endPosition: number
272 newPosition: number
273 transaction?: Transaction
274 }) {
275 const { videoPlaylistId, firstPosition, endPosition, newPosition, transaction } = options
276
277 const query = {
278 where: {
279 videoPlaylistId,
280 position: {
281 [Op.gte]: firstPosition,
282 [Op.lte]: endPosition
283 }
284 },
285 transaction,
286 validate: false // We use a literal to update the position
287 }
288
289 const positionQuery = Sequelize.literal(`${forceNumber(newPosition)} + "position" - ${forceNumber(firstPosition)}`)
290 return VideoPlaylistElementModel.update({ position: positionQuery }, query)
291 }
292
293 static increasePositionOf (
294 videoPlaylistId: number,
295 fromPosition: number,
296 by = 1,
297 transaction?: Transaction
298 ) {
299 const query = {
300 where: {
301 videoPlaylistId,
302 position: {
303 [Op.gte]: fromPosition
304 }
305 },
306 transaction
307 }
308
309 return VideoPlaylistElementModel.increment({ position: by }, query)
310 }
311
312 toFormattedJSON (
313 this: MVideoPlaylistElementFormattable,
314 options: { accountId?: number } = {}
315 ): VideoPlaylistElement {
316 return {
317 id: this.id,
318 position: this.position,
319 startTimestamp: this.startTimestamp,
320 stopTimestamp: this.stopTimestamp,
321
322 type: this.getType(options.accountId),
323
324 video: this.getVideoElement(options.accountId)
325 }
326 }
327
328 getType (this: MVideoPlaylistElementFormattable, accountId?: number) {
329 const video = this.Video
330
331 if (!video) return VideoPlaylistElementType.DELETED
332
333 // Owned video, don't filter it
334 if (accountId && video.VideoChannel.Account.id === accountId) return VideoPlaylistElementType.REGULAR
335
336 // Internal video?
337 if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR
338
339 // Private, internal and password protected videos cannot be read without appropriate access (ownership, internal)
340 if (new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy)) {
341 return VideoPlaylistElementType.PRIVATE
342 }
343
344 if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE
345
346 return VideoPlaylistElementType.REGULAR
347 }
348
349 getVideoElement (this: MVideoPlaylistElementFormattable, accountId?: number) {
350 if (!this.Video) return null
351 if (this.getType(accountId) !== VideoPlaylistElementType.REGULAR) return null
352
353 return this.Video.toFormattedJSON()
354 }
355
356 toActivityPubObject (this: MVideoPlaylistElementAP): PlaylistElementObject {
357 const base: PlaylistElementObject = {
358 id: this.url,
359 type: 'PlaylistElement',
360
361 url: this.Video?.url || null,
362 position: this.position
363 }
364
365 if (this.startTimestamp) base.startTimestamp = this.startTimestamp
366 if (this.stopTimestamp) base.stopTimestamp = this.stopTimestamp
367
368 return base
369 }
370}
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
deleted file mode 100644
index 15999d409..000000000
--- a/server/models/video/video-playlist.ts
+++ /dev/null
@@ -1,725 +0,0 @@
1import { join } from 'path'
2import { FindOptions, Includeable, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
3import {
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 Default,
10 ForeignKey,
11 HasMany,
12 HasOne,
13 Is,
14 IsUUID,
15 Model,
16 Scopes,
17 Table,
18 UpdatedAt
19} from 'sequelize-typescript'
20import { activityPubCollectionPagination } from '@server/lib/activitypub/collection'
21import { MAccountId, MChannelId } from '@server/types/models'
22import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils'
23import { buildUUID, uuidToShort } from '@shared/extra-utils'
24import { ActivityIconObject, PlaylistObject, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType } from '@shared/models'
25import { AttributesOnly } from '@shared/typescript-utils'
26import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
27import {
28 isVideoPlaylistDescriptionValid,
29 isVideoPlaylistNameValid,
30 isVideoPlaylistPrivacyValid
31} from '../../helpers/custom-validators/video-playlists'
32import {
33 ACTIVITY_PUB,
34 CONSTRAINTS_FIELDS,
35 LAZY_STATIC_PATHS,
36 THUMBNAILS_SIZE,
37 VIDEO_PLAYLIST_PRIVACIES,
38 VIDEO_PLAYLIST_TYPES,
39 WEBSERVER
40} from '../../initializers/constants'
41import { MThumbnail } from '../../types/models/video/thumbnail'
42import {
43 MVideoPlaylistAccountThumbnail,
44 MVideoPlaylistAP,
45 MVideoPlaylistFormattable,
46 MVideoPlaylistFull,
47 MVideoPlaylistFullSummary,
48 MVideoPlaylistSummaryWithElements
49} from '../../types/models/video/video-playlist'
50import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
51import { ActorModel } from '../actor/actor'
52import {
53 buildServerIdsFollowedBy,
54 buildTrigramSearchIndex,
55 buildWhereIdOrUUID,
56 createSimilarityAttribute,
57 getPlaylistSort,
58 isOutdated,
59 setAsUpdated,
60 throwIfNotValid
61} from '../shared'
62import { ThumbnailModel } from './thumbnail'
63import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
64import { VideoPlaylistElementModel } from './video-playlist-element'
65
66enum ScopeNames {
67 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
68 WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
69 WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY',
70 WITH_ACCOUNT = 'WITH_ACCOUNT',
71 WITH_THUMBNAIL = 'WITH_THUMBNAIL',
72 WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
73}
74
75type AvailableForListOptions = {
76 followerActorId?: number
77 type?: VideoPlaylistType
78 accountId?: number
79 videoChannelId?: number
80 listMyPlaylists?: boolean
81 search?: string
82 host?: string
83 uuids?: string[]
84 withVideos?: boolean
85 forCount?: boolean
86}
87
88function getVideoLengthSelect () {
89 return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"'
90}
91
92@Scopes(() => ({
93 [ScopeNames.WITH_THUMBNAIL]: {
94 include: [
95 {
96 model: ThumbnailModel,
97 required: false
98 }
99 ]
100 },
101 [ScopeNames.WITH_VIDEOS_LENGTH]: {
102 attributes: {
103 include: [
104 [
105 literal(`(${getVideoLengthSelect()})`),
106 'videosLength'
107 ]
108 ]
109 }
110 } as FindOptions,
111 [ScopeNames.WITH_ACCOUNT]: {
112 include: [
113 {
114 model: AccountModel,
115 required: true
116 }
117 ]
118 },
119 [ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY]: {
120 include: [
121 {
122 model: AccountModel.scope(AccountScopeNames.SUMMARY),
123 required: true
124 },
125 {
126 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
127 required: false
128 }
129 ]
130 },
131 [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: {
132 include: [
133 {
134 model: AccountModel,
135 required: true
136 },
137 {
138 model: VideoChannelModel,
139 required: false
140 }
141 ]
142 },
143 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
144 const whereAnd: WhereOptions[] = []
145
146 const whereServer = options.host && options.host !== WEBSERVER.HOST
147 ? { host: options.host }
148 : undefined
149
150 let whereActor: WhereOptions = {}
151
152 if (options.host === WEBSERVER.HOST) {
153 whereActor = {
154 [Op.and]: [ { serverId: null } ]
155 }
156 }
157
158 if (options.listMyPlaylists !== true) {
159 whereAnd.push({
160 privacy: VideoPlaylistPrivacy.PUBLIC
161 })
162
163 // Only list local playlists
164 const whereActorOr: WhereOptions[] = [
165 {
166 serverId: null
167 }
168 ]
169
170 // … OR playlists that are on an instance followed by actorId
171 if (options.followerActorId) {
172 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
173
174 whereActorOr.push({
175 serverId: {
176 [Op.in]: literal(inQueryInstanceFollow)
177 }
178 })
179 }
180
181 Object.assign(whereActor, { [Op.or]: whereActorOr })
182 }
183
184 if (options.accountId) {
185 whereAnd.push({
186 ownerAccountId: options.accountId
187 })
188 }
189
190 if (options.videoChannelId) {
191 whereAnd.push({
192 videoChannelId: options.videoChannelId
193 })
194 }
195
196 if (options.type) {
197 whereAnd.push({
198 type: options.type
199 })
200 }
201
202 if (options.uuids) {
203 whereAnd.push({
204 uuid: {
205 [Op.in]: options.uuids
206 }
207 })
208 }
209
210 if (options.withVideos === true) {
211 whereAnd.push(
212 literal(`(${getVideoLengthSelect()}) != 0`)
213 )
214 }
215
216 let attributesInclude: any[] = [ literal('0 as similarity') ]
217
218 if (options.search) {
219 const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search)
220 const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%')
221 attributesInclude = [ createSimilarityAttribute('VideoPlaylistModel.name', options.search) ]
222
223 whereAnd.push({
224 [Op.or]: [
225 Sequelize.literal(
226 'lower(immutable_unaccent("VideoPlaylistModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
227 ),
228 Sequelize.literal(
229 'lower(immutable_unaccent("VideoPlaylistModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
230 )
231 ]
232 })
233 }
234
235 const where = {
236 [Op.and]: whereAnd
237 }
238
239 const include: Includeable[] = [
240 {
241 model: AccountModel.scope({
242 method: [ AccountScopeNames.SUMMARY, { whereActor, whereServer, forCount: options.forCount } as SummaryOptions ]
243 }),
244 required: true
245 }
246 ]
247
248 if (options.forCount !== true) {
249 include.push({
250 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
251 required: false
252 })
253 }
254
255 return {
256 attributes: {
257 include: attributesInclude
258 },
259 where,
260 include
261 } as FindOptions
262 }
263}))
264
265@Table({
266 tableName: 'videoPlaylist',
267 indexes: [
268 buildTrigramSearchIndex('video_playlist_name_trigram', 'name'),
269
270 {
271 fields: [ 'ownerAccountId' ]
272 },
273 {
274 fields: [ 'videoChannelId' ]
275 },
276 {
277 fields: [ 'url' ],
278 unique: true
279 }
280 ]
281})
282export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlaylistModel>>> {
283 @CreatedAt
284 createdAt: Date
285
286 @UpdatedAt
287 updatedAt: Date
288
289 @AllowNull(false)
290 @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name'))
291 @Column
292 name: string
293
294 @AllowNull(true)
295 @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true))
296 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.DESCRIPTION.max))
297 description: string
298
299 @AllowNull(false)
300 @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy'))
301 @Column
302 privacy: VideoPlaylistPrivacy
303
304 @AllowNull(false)
305 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
306 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
307 url: string
308
309 @AllowNull(false)
310 @Default(DataType.UUIDV4)
311 @IsUUID(4)
312 @Column(DataType.UUID)
313 uuid: string
314
315 @AllowNull(false)
316 @Default(VideoPlaylistType.REGULAR)
317 @Column
318 type: VideoPlaylistType
319
320 @ForeignKey(() => AccountModel)
321 @Column
322 ownerAccountId: number
323
324 @BelongsTo(() => AccountModel, {
325 foreignKey: {
326 allowNull: false
327 },
328 onDelete: 'CASCADE'
329 })
330 OwnerAccount: AccountModel
331
332 @ForeignKey(() => VideoChannelModel)
333 @Column
334 videoChannelId: number
335
336 @BelongsTo(() => VideoChannelModel, {
337 foreignKey: {
338 allowNull: true
339 },
340 onDelete: 'CASCADE'
341 })
342 VideoChannel: VideoChannelModel
343
344 @HasMany(() => VideoPlaylistElementModel, {
345 foreignKey: {
346 name: 'videoPlaylistId',
347 allowNull: false
348 },
349 onDelete: 'CASCADE'
350 })
351 VideoPlaylistElements: VideoPlaylistElementModel[]
352
353 @HasOne(() => ThumbnailModel, {
354 foreignKey: {
355 name: 'videoPlaylistId',
356 allowNull: true
357 },
358 onDelete: 'CASCADE',
359 hooks: true
360 })
361 Thumbnail: ThumbnailModel
362
363 static listForApi (options: AvailableForListOptions & {
364 start: number
365 count: number
366 sort: string
367 }) {
368 const query = {
369 offset: options.start,
370 limit: options.count,
371 order: getPlaylistSort(options.sort)
372 }
373
374 const commonAvailableForListOptions = pick(options, [
375 'type',
376 'followerActorId',
377 'accountId',
378 'videoChannelId',
379 'listMyPlaylists',
380 'search',
381 'host',
382 'uuids'
383 ])
384
385 const scopesFind: (string | ScopeOptions)[] = [
386 {
387 method: [
388 ScopeNames.AVAILABLE_FOR_LIST,
389 {
390 ...commonAvailableForListOptions,
391
392 withVideos: options.withVideos || false
393 } as AvailableForListOptions
394 ]
395 },
396 ScopeNames.WITH_VIDEOS_LENGTH,
397 ScopeNames.WITH_THUMBNAIL
398 ]
399
400 const scopesCount: (string | ScopeOptions)[] = [
401 {
402 method: [
403 ScopeNames.AVAILABLE_FOR_LIST,
404
405 {
406 ...commonAvailableForListOptions,
407
408 withVideos: options.withVideos || false,
409 forCount: true
410 } as AvailableForListOptions
411 ]
412 },
413 ScopeNames.WITH_VIDEOS_LENGTH
414 ]
415
416 return Promise.all([
417 VideoPlaylistModel.scope(scopesCount).count(),
418 VideoPlaylistModel.scope(scopesFind).findAll(query)
419 ]).then(([ count, rows ]) => ({ total: count, data: rows }))
420 }
421
422 static searchForApi (options: Pick<AvailableForListOptions, 'followerActorId' | 'search' | 'host' | 'uuids'> & {
423 start: number
424 count: number
425 sort: string
426 }) {
427 return VideoPlaylistModel.listForApi({
428 ...options,
429
430 type: VideoPlaylistType.REGULAR,
431 listMyPlaylists: false,
432 withVideos: true
433 })
434 }
435
436 static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) {
437 const where = {
438 privacy: VideoPlaylistPrivacy.PUBLIC
439 }
440
441 if (options.account) {
442 Object.assign(where, { ownerAccountId: options.account.id })
443 }
444
445 if (options.channel) {
446 Object.assign(where, { videoChannelId: options.channel.id })
447 }
448
449 const getQuery = (forCount: boolean) => {
450 return {
451 attributes: forCount === true
452 ? []
453 : [ 'url' ],
454 offset: start,
455 limit: count,
456 where
457 }
458 }
459
460 return Promise.all([
461 VideoPlaylistModel.count(getQuery(true)),
462 VideoPlaylistModel.findAll(getQuery(false))
463 ]).then(([ total, rows ]) => ({
464 total,
465 data: rows.map(p => p.url)
466 }))
467 }
468
469 static listPlaylistSummariesOf (accountId: number, videoIds: number[]): Promise<MVideoPlaylistSummaryWithElements[]> {
470 const query = {
471 attributes: [ 'id', 'name', 'uuid' ],
472 where: {
473 ownerAccountId: accountId
474 },
475 include: [
476 {
477 attributes: [ 'id', 'videoId', 'startTimestamp', 'stopTimestamp' ],
478 model: VideoPlaylistElementModel.unscoped(),
479 where: {
480 videoId: {
481 [Op.in]: videoIds
482 }
483 },
484 required: true
485 }
486 ]
487 }
488
489 return VideoPlaylistModel.findAll(query)
490 }
491
492 static doesPlaylistExist (url: string) {
493 const query = {
494 attributes: [ 'id' ],
495 where: {
496 url
497 }
498 }
499
500 return VideoPlaylistModel
501 .findOne(query)
502 .then(e => !!e)
503 }
504
505 static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction): Promise<MVideoPlaylistFullSummary> {
506 const where = buildWhereIdOrUUID(id)
507
508 const query = {
509 where,
510 transaction
511 }
512
513 return VideoPlaylistModel
514 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
515 .findOne(query)
516 }
517
518 static loadWithAccountAndChannel (id: number | string, transaction: Transaction): Promise<MVideoPlaylistFull> {
519 const where = buildWhereIdOrUUID(id)
520
521 const query = {
522 where,
523 transaction
524 }
525
526 return VideoPlaylistModel
527 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
528 .findOne(query)
529 }
530
531 static loadByUrlAndPopulateAccount (url: string): Promise<MVideoPlaylistAccountThumbnail> {
532 const query = {
533 where: {
534 url
535 }
536 }
537
538 return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
539 }
540
541 static loadByUrlWithAccountAndChannelSummary (url: string): Promise<MVideoPlaylistFullSummary> {
542 const query = {
543 where: {
544 url
545 }
546 }
547
548 return VideoPlaylistModel
549 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
550 .findOne(query)
551 }
552
553 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
554 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
555 }
556
557 static getTypeLabel (type: VideoPlaylistType) {
558 return VIDEO_PLAYLIST_TYPES[type] || 'Unknown'
559 }
560
561 static resetPlaylistsOfChannel (videoChannelId: number, transaction: Transaction) {
562 const query = {
563 where: {
564 videoChannelId
565 },
566 transaction
567 }
568
569 return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
570 }
571
572 async setAndSaveThumbnail (thumbnail: MThumbnail, t: Transaction) {
573 thumbnail.videoPlaylistId = this.id
574
575 this.Thumbnail = await thumbnail.save({ transaction: t })
576 }
577
578 hasThumbnail () {
579 return !!this.Thumbnail
580 }
581
582 hasGeneratedThumbnail () {
583 return this.hasThumbnail() && this.Thumbnail.automaticallyGenerated === true
584 }
585
586 generateThumbnailName () {
587 const extension = '.jpg'
588
589 return 'playlist-' + buildUUID() + extension
590 }
591
592 getThumbnailUrl () {
593 if (!this.hasThumbnail()) return null
594
595 return WEBSERVER.URL + LAZY_STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename
596 }
597
598 getThumbnailStaticPath () {
599 if (!this.hasThumbnail()) return null
600
601 return join(LAZY_STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
602 }
603
604 getWatchStaticPath () {
605 return buildPlaylistWatchPath({ shortUUID: uuidToShort(this.uuid) })
606 }
607
608 getEmbedStaticPath () {
609 return buildPlaylistEmbedPath(this)
610 }
611
612 static async getStats () {
613 const totalLocalPlaylists = await VideoPlaylistModel.count({
614 include: [
615 {
616 model: AccountModel.unscoped(),
617 required: true,
618 include: [
619 {
620 model: ActorModel.unscoped(),
621 required: true,
622 where: {
623 serverId: null
624 }
625 }
626 ]
627 }
628 ],
629 where: {
630 privacy: VideoPlaylistPrivacy.PUBLIC
631 }
632 })
633
634 return {
635 totalLocalPlaylists
636 }
637 }
638
639 setAsRefreshed () {
640 return setAsUpdated({ sequelize: this.sequelize, table: 'videoPlaylist', id: this.id })
641 }
642
643 setVideosLength (videosLength: number) {
644 this.set('videosLength' as any, videosLength, { raw: true })
645 }
646
647 isOwned () {
648 return this.OwnerAccount.isOwned()
649 }
650
651 isOutdated () {
652 if (this.isOwned()) return false
653
654 return isOutdated(this, ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL)
655 }
656
657 toFormattedJSON (this: MVideoPlaylistFormattable): VideoPlaylist {
658 return {
659 id: this.id,
660 uuid: this.uuid,
661 shortUUID: uuidToShort(this.uuid),
662
663 isLocal: this.isOwned(),
664
665 url: this.url,
666
667 displayName: this.name,
668 description: this.description,
669 privacy: {
670 id: this.privacy,
671 label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
672 },
673
674 thumbnailPath: this.getThumbnailStaticPath(),
675 embedPath: this.getEmbedStaticPath(),
676
677 type: {
678 id: this.type,
679 label: VideoPlaylistModel.getTypeLabel(this.type)
680 },
681
682 videosLength: this.get('videosLength') as number,
683
684 createdAt: this.createdAt,
685 updatedAt: this.updatedAt,
686
687 ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
688 videoChannel: this.VideoChannel
689 ? this.VideoChannel.toFormattedSummaryJSON()
690 : null
691 }
692 }
693
694 toActivityPubObject (this: MVideoPlaylistAP, page: number, t: Transaction): Promise<PlaylistObject> {
695 const handler = (start: number, count: number) => {
696 return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
697 }
698
699 let icon: ActivityIconObject
700 if (this.hasThumbnail()) {
701 icon = {
702 type: 'Image' as 'Image',
703 url: this.getThumbnailUrl(),
704 mediaType: 'image/jpeg' as 'image/jpeg',
705 width: THUMBNAILS_SIZE.width,
706 height: THUMBNAILS_SIZE.height
707 }
708 }
709
710 return activityPubCollectionPagination(this.url, handler, page)
711 .then(o => {
712 return Object.assign(o, {
713 type: 'Playlist' as 'Playlist',
714 name: this.name,
715 content: this.description,
716 mediaType: 'text/markdown' as 'text/markdown',
717 uuid: this.uuid,
718 published: this.createdAt.toISOString(),
719 updated: this.updatedAt.toISOString(),
720 attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
721 icon
722 })
723 })
724 }
725}
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
deleted file mode 100644
index b4de2b20f..000000000
--- a/server/models/video/video-share.ts
+++ /dev/null
@@ -1,216 +0,0 @@
1import { literal, Op, QueryTypes, Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { forceNumber } from '@shared/core-utils'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
6import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
7import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models'
8import { MVideoShareActor, MVideoShareFull } from '../../types/models/video'
9import { ActorModel } from '../actor/actor'
10import { buildLocalActorIdsIn, throwIfNotValid } from '../shared'
11import { VideoModel } from './video'
12
13enum ScopeNames {
14 FULL = 'FULL',
15 WITH_ACTOR = 'WITH_ACTOR'
16}
17
18@Scopes(() => ({
19 [ScopeNames.FULL]: {
20 include: [
21 {
22 model: ActorModel,
23 required: true
24 },
25 {
26 model: VideoModel,
27 required: true
28 }
29 ]
30 },
31 [ScopeNames.WITH_ACTOR]: {
32 include: [
33 {
34 model: ActorModel,
35 required: true
36 }
37 ]
38 }
39}))
40@Table({
41 tableName: 'videoShare',
42 indexes: [
43 {
44 fields: [ 'actorId' ]
45 },
46 {
47 fields: [ 'videoId' ]
48 },
49 {
50 fields: [ 'url' ],
51 unique: true
52 }
53 ]
54})
55export class VideoShareModel extends Model<Partial<AttributesOnly<VideoShareModel>>> {
56
57 @AllowNull(false)
58 @Is('VideoShareUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
59 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_SHARE.URL.max))
60 url: string
61
62 @CreatedAt
63 createdAt: Date
64
65 @UpdatedAt
66 updatedAt: Date
67
68 @ForeignKey(() => ActorModel)
69 @Column
70 actorId: number
71
72 @BelongsTo(() => ActorModel, {
73 foreignKey: {
74 allowNull: false
75 },
76 onDelete: 'cascade'
77 })
78 Actor: ActorModel
79
80 @ForeignKey(() => VideoModel)
81 @Column
82 videoId: number
83
84 @BelongsTo(() => VideoModel, {
85 foreignKey: {
86 allowNull: false
87 },
88 onDelete: 'cascade'
89 })
90 Video: VideoModel
91
92 static load (actorId: number | string, videoId: number | string, t?: Transaction): Promise<MVideoShareActor> {
93 return VideoShareModel.scope(ScopeNames.WITH_ACTOR).findOne({
94 where: {
95 actorId,
96 videoId
97 },
98 transaction: t
99 })
100 }
101
102 static loadByUrl (url: string, t: Transaction): Promise<MVideoShareFull> {
103 return VideoShareModel.scope(ScopeNames.FULL).findOne({
104 where: {
105 url
106 },
107 transaction: t
108 })
109 }
110
111 static listActorIdsAndFollowerUrlsByShare (videoId: number, t: Transaction) {
112 const query = `SELECT "actor"."id" AS "id", "actor"."followersUrl" AS "followersUrl" ` +
113 `FROM "videoShare" ` +
114 `INNER JOIN "actor" ON "actor"."id" = "videoShare"."actorId" ` +
115 `WHERE "videoShare"."videoId" = :videoId`
116
117 const options = {
118 type: QueryTypes.SELECT as QueryTypes.SELECT,
119 replacements: { videoId },
120 transaction: t
121 }
122
123 return VideoShareModel.sequelize.query<MActorId & MActorFollowersUrl>(query, options)
124 }
125
126 static loadActorsWhoSharedVideosOf (actorOwnerId: number, t: Transaction): Promise<MActorDefault[]> {
127 const safeOwnerId = forceNumber(actorOwnerId)
128
129 // /!\ On actor model
130 const query = {
131 where: {
132 [Op.and]: [
133 literal(
134 `EXISTS (` +
135 ` SELECT 1 FROM "videoShare" ` +
136 ` INNER JOIN "video" ON "videoShare"."videoId" = "video"."id" ` +
137 ` INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
138 ` INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ` +
139 ` WHERE "videoShare"."actorId" = "ActorModel"."id" AND "account"."actorId" = ${safeOwnerId} ` +
140 ` LIMIT 1` +
141 `)`
142 )
143 ]
144 },
145 transaction: t
146 }
147
148 return ActorModel.findAll(query)
149 }
150
151 static loadActorsByVideoChannel (videoChannelId: number, t: Transaction): Promise<MActorDefault[]> {
152 const safeChannelId = forceNumber(videoChannelId)
153
154 // /!\ On actor model
155 const query = {
156 where: {
157 [Op.and]: [
158 literal(
159 `EXISTS (` +
160 ` SELECT 1 FROM "videoShare" ` +
161 ` INNER JOIN "video" ON "videoShare"."videoId" = "video"."id" ` +
162 ` WHERE "videoShare"."actorId" = "ActorModel"."id" AND "video"."channelId" = ${safeChannelId} ` +
163 ` LIMIT 1` +
164 `)`
165 )
166 ]
167 },
168 transaction: t
169 }
170
171 return ActorModel.findAll(query)
172 }
173
174 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Transaction) {
175 const query = {
176 offset: start,
177 limit: count,
178 where: {
179 videoId
180 },
181 transaction: t
182 }
183
184 return Promise.all([
185 VideoShareModel.count(query),
186 VideoShareModel.findAll(query)
187 ]).then(([ total, data ]) => ({ total, data }))
188 }
189
190 static listRemoteShareUrlsOfLocalVideos () {
191 const query = `SELECT "videoShare".url FROM "videoShare" ` +
192 `INNER JOIN actor ON actor.id = "videoShare"."actorId" AND actor."serverId" IS NOT NULL ` +
193 `INNER JOIN video ON video.id = "videoShare"."videoId" AND video.remote IS FALSE`
194
195 return VideoShareModel.sequelize.query<{ url: string }>(query, {
196 type: QueryTypes.SELECT,
197 raw: true
198 }).then(rows => rows.map(r => r.url))
199 }
200
201 static cleanOldSharesOf (videoId: number, beforeUpdatedAt: Date) {
202 const query = {
203 where: {
204 updatedAt: {
205 [Op.lt]: beforeUpdatedAt
206 },
207 videoId,
208 actorId: {
209 [Op.notIn]: buildLocalActorIdsIn()
210 }
211 }
212 }
213
214 return VideoShareModel.destroy(query)
215 }
216}
diff --git a/server/models/video/video-source.ts b/server/models/video/video-source.ts
deleted file mode 100644
index 1b6868b85..000000000
--- a/server/models/video/video-source.ts
+++ /dev/null
@@ -1,56 +0,0 @@
1import { Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { VideoSource } from '@shared/models/videos/video-source'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { getSort } from '../shared'
6import { VideoModel } from './video'
7
8@Table({
9 tableName: 'videoSource',
10 indexes: [
11 {
12 fields: [ 'videoId' ]
13 },
14 {
15 fields: [ { name: 'createdAt', order: 'DESC' } ]
16 }
17 ]
18})
19export class VideoSourceModel extends Model<Partial<AttributesOnly<VideoSourceModel>>> {
20 @CreatedAt
21 createdAt: Date
22
23 @UpdatedAt
24 updatedAt: Date
25
26 @AllowNull(false)
27 @Column
28 filename: string
29
30 @ForeignKey(() => VideoModel)
31 @Column
32 videoId: number
33
34 @BelongsTo(() => VideoModel, {
35 foreignKey: {
36 allowNull: false
37 },
38 onDelete: 'cascade'
39 })
40 Video: VideoModel
41
42 static loadLatest (videoId: number, transaction?: Transaction) {
43 return VideoSourceModel.findOne({
44 where: { videoId },
45 order: getSort('-createdAt'),
46 transaction
47 })
48 }
49
50 toFormattedJSON (): VideoSource {
51 return {
52 filename: this.filename,
53 createdAt: this.createdAt.toISOString()
54 }
55 }
56}
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
deleted file mode 100644
index a85c79c9f..000000000
--- a/server/models/video/video-streaming-playlist.ts
+++ /dev/null
@@ -1,328 +0,0 @@
1import memoizee from 'memoizee'
2import { join } from 'path'
3import { Op, Transaction } from 'sequelize'
4import {
5 AllowNull,
6 BelongsTo,
7 Column,
8 CreatedAt,
9 DataType,
10 Default,
11 ForeignKey,
12 HasMany,
13 Is,
14 Model,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
18import { CONFIG } from '@server/initializers/config'
19import { getHLSPrivateFileUrl, getHLSPublicFileUrl } from '@server/lib/object-storage'
20import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths'
21import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
22import { VideoFileModel } from '@server/models/video/video-file'
23import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models'
24import { sha1 } from '@shared/extra-utils'
25import { VideoStorage } from '@shared/models'
26import { AttributesOnly } from '@shared/typescript-utils'
27import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
28import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
29import { isArrayOf } from '../../helpers/custom-validators/misc'
30import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
31import {
32 CONSTRAINTS_FIELDS,
33 MEMOIZE_LENGTH,
34 MEMOIZE_TTL,
35 P2P_MEDIA_LOADER_PEER_VERSION,
36 STATIC_PATHS,
37 WEBSERVER
38} from '../../initializers/constants'
39import { VideoRedundancyModel } from '../redundancy/video-redundancy'
40import { doesExist, throwIfNotValid } from '../shared'
41import { VideoModel } from './video'
42
43@Table({
44 tableName: 'videoStreamingPlaylist',
45 indexes: [
46 {
47 fields: [ 'videoId' ]
48 },
49 {
50 fields: [ 'videoId', 'type' ],
51 unique: true
52 },
53 {
54 fields: [ 'p2pMediaLoaderInfohashes' ],
55 using: 'gin'
56 }
57 ]
58})
59export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<VideoStreamingPlaylistModel>>> {
60 @CreatedAt
61 createdAt: Date
62
63 @UpdatedAt
64 updatedAt: Date
65
66 @AllowNull(false)
67 @Column
68 type: VideoStreamingPlaylistType
69
70 @AllowNull(false)
71 @Column
72 playlistFilename: string
73
74 @AllowNull(true)
75 @Is('PlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'playlist url', true))
76 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
77 playlistUrl: string
78
79 @AllowNull(false)
80 @Is('VideoStreamingPlaylistInfoHashes', value => throwIfNotValid(value, v => isArrayOf(v, isVideoFileInfoHashValid), 'info hashes'))
81 @Column(DataType.ARRAY(DataType.STRING))
82 p2pMediaLoaderInfohashes: string[]
83
84 @AllowNull(false)
85 @Column
86 p2pMediaLoaderPeerVersion: number
87
88 @AllowNull(false)
89 @Column
90 segmentsSha256Filename: string
91
92 @AllowNull(true)
93 @Is('VideoStreamingSegmentsSha256Url', value => throwIfNotValid(value, isActivityPubUrlValid, 'segments sha256 url', true))
94 @Column
95 segmentsSha256Url: string
96
97 @ForeignKey(() => VideoModel)
98 @Column
99 videoId: number
100
101 @AllowNull(false)
102 @Default(VideoStorage.FILE_SYSTEM)
103 @Column
104 storage: VideoStorage
105
106 @BelongsTo(() => VideoModel, {
107 foreignKey: {
108 allowNull: false
109 },
110 onDelete: 'CASCADE'
111 })
112 Video: VideoModel
113
114 @HasMany(() => VideoFileModel, {
115 foreignKey: {
116 allowNull: true
117 },
118 onDelete: 'CASCADE'
119 })
120 VideoFiles: VideoFileModel[]
121
122 @HasMany(() => VideoRedundancyModel, {
123 foreignKey: {
124 allowNull: false
125 },
126 onDelete: 'CASCADE',
127 hooks: true
128 })
129 RedundancyVideos: VideoRedundancyModel[]
130
131 static doesInfohashExistCached = memoizee(VideoStreamingPlaylistModel.doesInfohashExist, {
132 promise: true,
133 max: MEMOIZE_LENGTH.INFO_HASH_EXISTS,
134 maxAge: MEMOIZE_TTL.INFO_HASH_EXISTS
135 })
136
137 static doesInfohashExist (infoHash: string) {
138 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
139
140 return doesExist(this.sequelize, query, { infoHash })
141 }
142
143 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) {
144 const hashes: string[] = []
145
146 // https://github.com/Novage/p2p-media-loader/blob/master/p2p-media-loader-core/lib/p2p-media-manager.ts#L115
147 for (let i = 0; i < files.length; i++) {
148 hashes.push(sha1(`${P2P_MEDIA_LOADER_PEER_VERSION}${playlistUrl}+V${i}`))
149 }
150
151 return hashes
152 }
153
154 static listByIncorrectPeerVersion () {
155 const query = {
156 where: {
157 p2pMediaLoaderPeerVersion: {
158 [Op.ne]: P2P_MEDIA_LOADER_PEER_VERSION
159 }
160 },
161 include: [
162 {
163 model: VideoModel.unscoped(),
164 required: true
165 }
166 ]
167 }
168
169 return VideoStreamingPlaylistModel.findAll(query)
170 }
171
172 static loadWithVideoAndFiles (id: number) {
173 const options = {
174 include: [
175 {
176 model: VideoModel.unscoped(),
177 required: true
178 },
179 {
180 model: VideoFileModel.unscoped()
181 }
182 ]
183 }
184
185 return VideoStreamingPlaylistModel.findByPk<MStreamingPlaylistFilesVideo>(id, options)
186 }
187
188 static loadWithVideo (id: number) {
189 const options = {
190 include: [
191 {
192 model: VideoModel.unscoped(),
193 required: true
194 }
195 ]
196 }
197
198 return VideoStreamingPlaylistModel.findByPk(id, options)
199 }
200
201 static loadHLSPlaylistByVideo (videoId: number, transaction?: Transaction): Promise<MStreamingPlaylist> {
202 const options = {
203 where: {
204 type: VideoStreamingPlaylistType.HLS,
205 videoId
206 },
207 transaction
208 }
209
210 return VideoStreamingPlaylistModel.findOne(options)
211 }
212
213 static async loadOrGenerate (video: MVideo, transaction?: Transaction) {
214 let playlist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id, transaction)
215
216 if (!playlist) {
217 playlist = new VideoStreamingPlaylistModel({
218 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
219 type: VideoStreamingPlaylistType.HLS,
220 storage: VideoStorage.FILE_SYSTEM,
221 p2pMediaLoaderInfohashes: [],
222 playlistFilename: generateHLSMasterPlaylistFilename(video.isLive),
223 segmentsSha256Filename: generateHlsSha256SegmentsFilename(video.isLive),
224 videoId: video.id
225 })
226
227 await playlist.save({ transaction })
228 }
229
230 return Object.assign(playlist, { Video: video })
231 }
232
233 static doesOwnedHLSPlaylistExist (videoUUID: string) {
234 const query = `SELECT 1 FROM "videoStreamingPlaylist" ` +
235 `INNER JOIN "video" ON "video"."id" = "videoStreamingPlaylist"."videoId" ` +
236 `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` +
237 `AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
238
239 return doesExist(this.sequelize, query, { videoUUID })
240 }
241
242 assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) {
243 const masterPlaylistUrl = this.getMasterPlaylistUrl(video)
244
245 this.p2pMediaLoaderInfohashes = VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(masterPlaylistUrl, files)
246 }
247
248 // ---------------------------------------------------------------------------
249
250 getMasterPlaylistUrl (video: MVideo) {
251 if (video.isOwned()) {
252 if (this.storage === VideoStorage.OBJECT_STORAGE) {
253 return this.getMasterPlaylistObjectStorageUrl(video)
254 }
255
256 return WEBSERVER.URL + this.getMasterPlaylistStaticPath(video)
257 }
258
259 return this.playlistUrl
260 }
261
262 private getMasterPlaylistObjectStorageUrl (video: MVideo) {
263 if (video.hasPrivateStaticPath() && CONFIG.OBJECT_STORAGE.PROXY.PROXIFY_PRIVATE_FILES === true) {
264 return getHLSPrivateFileUrl(video, this.playlistFilename)
265 }
266
267 return getHLSPublicFileUrl(this.playlistUrl)
268 }
269
270 // ---------------------------------------------------------------------------
271
272 getSha256SegmentsUrl (video: MVideo) {
273 if (video.isOwned()) {
274 if (this.storage === VideoStorage.OBJECT_STORAGE) {
275 return this.getSha256SegmentsObjectStorageUrl(video)
276 }
277
278 return WEBSERVER.URL + this.getSha256SegmentsStaticPath(video)
279 }
280
281 return this.segmentsSha256Url
282 }
283
284 private getSha256SegmentsObjectStorageUrl (video: MVideo) {
285 if (video.hasPrivateStaticPath() && CONFIG.OBJECT_STORAGE.PROXY.PROXIFY_PRIVATE_FILES === true) {
286 return getHLSPrivateFileUrl(video, this.segmentsSha256Filename)
287 }
288
289 return getHLSPublicFileUrl(this.segmentsSha256Url)
290 }
291
292 // ---------------------------------------------------------------------------
293
294 getStringType () {
295 if (this.type === VideoStreamingPlaylistType.HLS) return 'hls'
296
297 return 'unknown'
298 }
299
300 getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
301 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
302 }
303
304 hasSameUniqueKeysThan (other: MStreamingPlaylist) {
305 return this.type === other.type &&
306 this.videoId === other.videoId
307 }
308
309 withVideo (video: MVideo) {
310 return Object.assign(this, { Video: video })
311 }
312
313 private getMasterPlaylistStaticPath (video: MVideo) {
314 if (isVideoInPrivateDirectory(video.privacy)) {
315 return join(STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, video.uuid, this.playlistFilename)
316 }
317
318 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.playlistFilename)
319 }
320
321 private getSha256SegmentsStaticPath (video: MVideo) {
322 if (isVideoInPrivateDirectory(video.privacy)) {
323 return join(STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, video.uuid, this.segmentsSha256Filename)
324 }
325
326 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.segmentsSha256Filename)
327 }
328}
diff --git a/server/models/video/video-tag.ts b/server/models/video/video-tag.ts
deleted file mode 100644
index 7e880c968..000000000
--- a/server/models/video/video-tag.ts
+++ /dev/null
@@ -1,31 +0,0 @@
1import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/typescript-utils'
3import { TagModel } from './tag'
4import { VideoModel } from './video'
5
6@Table({
7 tableName: 'videoTag',
8 indexes: [
9 {
10 fields: [ 'videoId' ]
11 },
12 {
13 fields: [ 'tagId' ]
14 }
15 ]
16})
17export class VideoTagModel extends Model<Partial<AttributesOnly<VideoTagModel>>> {
18 @CreatedAt
19 createdAt: Date
20
21 @UpdatedAt
22 updatedAt: Date
23
24 @ForeignKey(() => VideoModel)
25 @Column
26 videoId: number
27
28 @ForeignKey(() => TagModel)
29 @Column
30 tagId: number
31}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
deleted file mode 100644
index 73308182d..000000000
--- a/server/models/video/video.ts
+++ /dev/null
@@ -1,2047 +0,0 @@
1import Bluebird from 'bluebird'
2import { remove } from 'fs-extra'
3import { maxBy, minBy } from 'lodash'
4import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
5import {
6 AfterCreate,
7 AfterDestroy,
8 AfterUpdate,
9 AllowNull,
10 BeforeDestroy,
11 BelongsTo,
12 BelongsToMany,
13 Column,
14 CreatedAt,
15 DataType,
16 Default,
17 ForeignKey,
18 HasMany,
19 HasOne,
20 Is,
21 IsInt,
22 IsUUID,
23 Min,
24 Model,
25 Scopes,
26 Table,
27 UpdatedAt
28} from 'sequelize-typescript'
29import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
30import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
31import { LiveManager } from '@server/lib/live/live-manager'
32import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebVideoObjectStorage } from '@server/lib/object-storage'
33import { tracer } from '@server/lib/opentelemetry/tracing'
34import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths'
35import { Hooks } from '@server/lib/plugins/hooks'
36import { VideoPathManager } from '@server/lib/video-path-manager'
37import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
38import { getServerActor } from '@server/models/application/application'
39import { ModelCache } from '@server/models/shared/model-cache'
40import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
41import { uuidToShort } from '@shared/extra-utils'
42import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@shared/ffmpeg'
43import {
44 ResultList,
45 ThumbnailType,
46 UserRight,
47 Video,
48 VideoDetails,
49 VideoFile,
50 VideoInclude,
51 VideoObject,
52 VideoPrivacy,
53 VideoRateType,
54 VideoState,
55 VideoStorage,
56 VideoStreamingPlaylistType
57} from '@shared/models'
58import { AttributesOnly } from '@shared/typescript-utils'
59import { peertubeTruncate } from '../../helpers/core-utils'
60import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
61import { exists, isArray, isBooleanValid, isUUIDValid } from '../../helpers/custom-validators/misc'
62import {
63 isVideoDescriptionValid,
64 isVideoDurationValid,
65 isVideoNameValid,
66 isVideoPrivacyValid,
67 isVideoStateValid,
68 isVideoSupportValid
69} from '../../helpers/custom-validators/videos'
70import { logger } from '../../helpers/logger'
71import { CONFIG } from '../../initializers/config'
72import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
73import { sendDeleteVideo } from '../../lib/activitypub/send'
74import {
75 MChannel,
76 MChannelAccountDefault,
77 MChannelId,
78 MStoryboard,
79 MStreamingPlaylist,
80 MStreamingPlaylistFilesVideo,
81 MUserAccountId,
82 MUserId,
83 MVideo,
84 MVideoAccountLight,
85 MVideoAccountLightBlacklistAllFiles,
86 MVideoAP,
87 MVideoAPLight,
88 MVideoCaptionLanguageUrl,
89 MVideoDetails,
90 MVideoFileVideo,
91 MVideoFormattable,
92 MVideoFormattableDetails,
93 MVideoForUser,
94 MVideoFullLight,
95 MVideoId,
96 MVideoImmutable,
97 MVideoThumbnail,
98 MVideoThumbnailBlacklist,
99 MVideoWithAllFiles,
100 MVideoWithFile
101} from '../../types/models'
102import { MThumbnail } from '../../types/models/video/thumbnail'
103import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
104import { VideoAbuseModel } from '../abuse/video-abuse'
105import { AccountModel } from '../account/account'
106import { AccountVideoRateModel } from '../account/account-video-rate'
107import { ActorModel } from '../actor/actor'
108import { ActorImageModel } from '../actor/actor-image'
109import { VideoRedundancyModel } from '../redundancy/video-redundancy'
110import { ServerModel } from '../server/server'
111import { TrackerModel } from '../server/tracker'
112import { VideoTrackerModel } from '../server/video-tracker'
113import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared'
114import { UserModel } from '../user/user'
115import { UserVideoHistoryModel } from '../user/user-video-history'
116import { VideoViewModel } from '../view/video-view'
117import { videoModelToActivityPubObject } from './formatter/video-activity-pub-format'
118import {
119 videoFilesModelToFormattedJSON,
120 VideoFormattingJSONOptions,
121 videoModelToFormattedDetailsJSON,
122 videoModelToFormattedJSON
123} from './formatter/video-api-format'
124import { ScheduleVideoUpdateModel } from './schedule-video-update'
125import {
126 BuildVideosListQueryOptions,
127 DisplayOnlyForFollowerOptions,
128 VideoModelGetQueryBuilder,
129 VideosIdListQueryBuilder,
130 VideosModelListQueryBuilder
131} from './sql/video'
132import { StoryboardModel } from './storyboard'
133import { TagModel } from './tag'
134import { ThumbnailModel } from './thumbnail'
135import { VideoBlacklistModel } from './video-blacklist'
136import { VideoCaptionModel } from './video-caption'
137import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
138import { VideoCommentModel } from './video-comment'
139import { VideoFileModel } from './video-file'
140import { VideoImportModel } from './video-import'
141import { VideoJobInfoModel } from './video-job-info'
142import { VideoLiveModel } from './video-live'
143import { VideoPasswordModel } from './video-password'
144import { VideoPlaylistElementModel } from './video-playlist-element'
145import { VideoShareModel } from './video-share'
146import { VideoSourceModel } from './video-source'
147import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
148import { VideoTagModel } from './video-tag'
149
150export enum ScopeNames {
151 FOR_API = 'FOR_API',
152 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
153 WITH_TAGS = 'WITH_TAGS',
154 WITH_WEB_VIDEO_FILES = 'WITH_WEB_VIDEO_FILES',
155 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
156 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
157 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
158 WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES',
159 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
160 WITH_THUMBNAILS = 'WITH_THUMBNAILS'
161}
162
163export type ForAPIOptions = {
164 ids?: number[]
165
166 videoPlaylistId?: number
167
168 withAccountBlockerIds?: number[]
169}
170
171@Scopes(() => ({
172 [ScopeNames.WITH_IMMUTABLE_ATTRIBUTES]: {
173 attributes: [ 'id', 'url', 'uuid', 'remote' ]
174 },
175 [ScopeNames.FOR_API]: (options: ForAPIOptions) => {
176 const include: Includeable[] = [
177 {
178 model: VideoChannelModel.scope({
179 method: [
180 VideoChannelScopeNames.SUMMARY, {
181 withAccount: true,
182 withAccountBlockerIds: options.withAccountBlockerIds
183 } as SummaryOptions
184 ]
185 }),
186 required: true
187 },
188 {
189 attributes: [ 'type', 'filename' ],
190 model: ThumbnailModel,
191 required: false
192 }
193 ]
194
195 const query: FindOptions = {}
196
197 if (options.ids) {
198 query.where = {
199 id: {
200 [Op.in]: options.ids
201 }
202 }
203 }
204
205 if (options.videoPlaylistId) {
206 include.push({
207 model: VideoPlaylistElementModel.unscoped(),
208 required: true,
209 where: {
210 videoPlaylistId: options.videoPlaylistId
211 }
212 })
213 }
214
215 query.include = include
216
217 return query
218 },
219 [ScopeNames.WITH_THUMBNAILS]: {
220 include: [
221 {
222 model: ThumbnailModel,
223 required: false
224 }
225 ]
226 },
227 [ScopeNames.WITH_ACCOUNT_DETAILS]: {
228 include: [
229 {
230 model: VideoChannelModel.unscoped(),
231 required: true,
232 include: [
233 {
234 attributes: {
235 exclude: [ 'privateKey', 'publicKey' ]
236 },
237 model: ActorModel.unscoped(),
238 required: true,
239 include: [
240 {
241 attributes: [ 'host' ],
242 model: ServerModel.unscoped(),
243 required: false
244 },
245 {
246 model: ActorImageModel,
247 as: 'Avatars',
248 required: false
249 }
250 ]
251 },
252 {
253 model: AccountModel.unscoped(),
254 required: true,
255 include: [
256 {
257 model: ActorModel.unscoped(),
258 attributes: {
259 exclude: [ 'privateKey', 'publicKey' ]
260 },
261 required: true,
262 include: [
263 {
264 attributes: [ 'host' ],
265 model: ServerModel.unscoped(),
266 required: false
267 },
268 {
269 model: ActorImageModel,
270 as: 'Avatars',
271 required: false
272 }
273 ]
274 }
275 ]
276 }
277 ]
278 }
279 ]
280 },
281 [ScopeNames.WITH_TAGS]: {
282 include: [ TagModel ]
283 },
284 [ScopeNames.WITH_BLACKLISTED]: {
285 include: [
286 {
287 attributes: [ 'id', 'reason', 'unfederated' ],
288 model: VideoBlacklistModel,
289 required: false
290 }
291 ]
292 },
293 [ScopeNames.WITH_WEB_VIDEO_FILES]: (withRedundancies = false) => {
294 let subInclude: any[] = []
295
296 if (withRedundancies === true) {
297 subInclude = [
298 {
299 attributes: [ 'fileUrl' ],
300 model: VideoRedundancyModel.unscoped(),
301 required: false
302 }
303 ]
304 }
305
306 return {
307 include: [
308 {
309 model: VideoFileModel,
310 separate: true,
311 required: false,
312 include: subInclude
313 }
314 ]
315 }
316 },
317 [ScopeNames.WITH_STREAMING_PLAYLISTS]: (withRedundancies = false) => {
318 const subInclude: IncludeOptions[] = [
319 {
320 model: VideoFileModel,
321 required: false
322 }
323 ]
324
325 if (withRedundancies === true) {
326 subInclude.push({
327 attributes: [ 'fileUrl' ],
328 model: VideoRedundancyModel.unscoped(),
329 required: false
330 })
331 }
332
333 return {
334 include: [
335 {
336 model: VideoStreamingPlaylistModel.unscoped(),
337 required: false,
338 separate: true,
339 include: subInclude
340 }
341 ]
342 }
343 },
344 [ScopeNames.WITH_SCHEDULED_UPDATE]: {
345 include: [
346 {
347 model: ScheduleVideoUpdateModel.unscoped(),
348 required: false
349 }
350 ]
351 },
352 [ScopeNames.WITH_USER_HISTORY]: (userId: number) => {
353 return {
354 include: [
355 {
356 attributes: [ 'currentTime' ],
357 model: UserVideoHistoryModel.unscoped(),
358 required: false,
359 where: {
360 userId
361 }
362 }
363 ]
364 }
365 }
366}))
367@Table({
368 tableName: 'video',
369 indexes: [
370 buildTrigramSearchIndex('video_name_trigram', 'name'),
371
372 { fields: [ 'createdAt' ] },
373 {
374 fields: [
375 { name: 'publishedAt', order: 'DESC' },
376 { name: 'id', order: 'ASC' }
377 ]
378 },
379 { fields: [ 'duration' ] },
380 {
381 fields: [
382 { name: 'views', order: 'DESC' },
383 { name: 'id', order: 'ASC' }
384 ]
385 },
386 { fields: [ 'channelId' ] },
387 {
388 fields: [ 'originallyPublishedAt' ],
389 where: {
390 originallyPublishedAt: {
391 [Op.ne]: null
392 }
393 }
394 },
395 {
396 fields: [ 'category' ], // We don't care videos with an unknown category
397 where: {
398 category: {
399 [Op.ne]: null
400 }
401 }
402 },
403 {
404 fields: [ 'licence' ], // We don't care videos with an unknown licence
405 where: {
406 licence: {
407 [Op.ne]: null
408 }
409 }
410 },
411 {
412 fields: [ 'language' ], // We don't care videos with an unknown language
413 where: {
414 language: {
415 [Op.ne]: null
416 }
417 }
418 },
419 {
420 fields: [ 'nsfw' ], // Most of the videos are not NSFW
421 where: {
422 nsfw: true
423 }
424 },
425 {
426 fields: [ 'remote' ], // Only index local videos
427 where: {
428 remote: false
429 }
430 },
431 {
432 fields: [ 'uuid' ],
433 unique: true
434 },
435 {
436 fields: [ 'url' ],
437 unique: true
438 }
439 ]
440})
441export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
442
443 @AllowNull(false)
444 @Default(DataType.UUIDV4)
445 @IsUUID(4)
446 @Column(DataType.UUID)
447 uuid: string
448
449 @AllowNull(false)
450 @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
451 @Column
452 name: string
453
454 @AllowNull(true)
455 @Default(null)
456 @Column
457 category: number
458
459 @AllowNull(true)
460 @Default(null)
461 @Column
462 licence: number
463
464 @AllowNull(true)
465 @Default(null)
466 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
467 language: string
468
469 @AllowNull(false)
470 @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
471 @Column
472 privacy: VideoPrivacy
473
474 @AllowNull(false)
475 @Is('VideoNSFW', value => throwIfNotValid(value, isBooleanValid, 'NSFW boolean'))
476 @Column
477 nsfw: boolean
478
479 @AllowNull(true)
480 @Default(null)
481 @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description', true))
482 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
483 description: string
484
485 @AllowNull(true)
486 @Default(null)
487 @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support', true))
488 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
489 support: string
490
491 @AllowNull(false)
492 @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
493 @Column
494 duration: number
495
496 @AllowNull(false)
497 @Default(0)
498 @IsInt
499 @Min(0)
500 @Column
501 views: number
502
503 @AllowNull(false)
504 @Default(0)
505 @IsInt
506 @Min(0)
507 @Column
508 likes: number
509
510 @AllowNull(false)
511 @Default(0)
512 @IsInt
513 @Min(0)
514 @Column
515 dislikes: number
516
517 @AllowNull(false)
518 @Column
519 remote: boolean
520
521 @AllowNull(false)
522 @Default(false)
523 @Column
524 isLive: boolean
525
526 @AllowNull(false)
527 @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
528 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
529 url: string
530
531 @AllowNull(false)
532 @Column
533 commentsEnabled: boolean
534
535 @AllowNull(false)
536 @Column
537 downloadEnabled: boolean
538
539 @AllowNull(false)
540 @Column
541 waitTranscoding: boolean
542
543 @AllowNull(false)
544 @Default(null)
545 @Is('VideoState', value => throwIfNotValid(value, isVideoStateValid, 'state'))
546 @Column
547 state: VideoState
548
549 // We already have the information in videoSource table for local videos, but we prefer to normalize it for performance
550 // And also to store the info from remote instances
551 @AllowNull(true)
552 @Column
553 inputFileUpdatedAt: Date
554
555 @CreatedAt
556 createdAt: Date
557
558 @UpdatedAt
559 updatedAt: Date
560
561 @AllowNull(false)
562 @Default(DataType.NOW)
563 @Column
564 publishedAt: Date
565
566 @AllowNull(true)
567 @Default(null)
568 @Column
569 originallyPublishedAt: Date
570
571 @ForeignKey(() => VideoChannelModel)
572 @Column
573 channelId: number
574
575 @BelongsTo(() => VideoChannelModel, {
576 foreignKey: {
577 allowNull: true
578 },
579 onDelete: 'cascade'
580 })
581 VideoChannel: VideoChannelModel
582
583 @BelongsToMany(() => TagModel, {
584 foreignKey: 'videoId',
585 through: () => VideoTagModel,
586 onDelete: 'CASCADE'
587 })
588 Tags: TagModel[]
589
590 @BelongsToMany(() => TrackerModel, {
591 foreignKey: 'videoId',
592 through: () => VideoTrackerModel,
593 onDelete: 'CASCADE'
594 })
595 Trackers: TrackerModel[]
596
597 @HasMany(() => ThumbnailModel, {
598 foreignKey: {
599 name: 'videoId',
600 allowNull: true
601 },
602 hooks: true,
603 onDelete: 'cascade'
604 })
605 Thumbnails: ThumbnailModel[]
606
607 @HasMany(() => VideoPlaylistElementModel, {
608 foreignKey: {
609 name: 'videoId',
610 allowNull: true
611 },
612 onDelete: 'set null'
613 })
614 VideoPlaylistElements: VideoPlaylistElementModel[]
615
616 @HasOne(() => VideoSourceModel, {
617 foreignKey: {
618 name: 'videoId',
619 allowNull: false
620 },
621 onDelete: 'CASCADE'
622 })
623 VideoSource: VideoSourceModel
624
625 @HasMany(() => VideoAbuseModel, {
626 foreignKey: {
627 name: 'videoId',
628 allowNull: true
629 },
630 onDelete: 'set null'
631 })
632 VideoAbuses: VideoAbuseModel[]
633
634 @HasMany(() => VideoFileModel, {
635 foreignKey: {
636 name: 'videoId',
637 allowNull: true
638 },
639 hooks: true,
640 onDelete: 'cascade'
641 })
642 VideoFiles: VideoFileModel[]
643
644 @HasMany(() => VideoStreamingPlaylistModel, {
645 foreignKey: {
646 name: 'videoId',
647 allowNull: false
648 },
649 hooks: true,
650 onDelete: 'cascade'
651 })
652 VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
653
654 @HasMany(() => VideoShareModel, {
655 foreignKey: {
656 name: 'videoId',
657 allowNull: false
658 },
659 onDelete: 'cascade'
660 })
661 VideoShares: VideoShareModel[]
662
663 @HasMany(() => AccountVideoRateModel, {
664 foreignKey: {
665 name: 'videoId',
666 allowNull: false
667 },
668 onDelete: 'cascade'
669 })
670 AccountVideoRates: AccountVideoRateModel[]
671
672 @HasMany(() => VideoCommentModel, {
673 foreignKey: {
674 name: 'videoId',
675 allowNull: false
676 },
677 onDelete: 'cascade',
678 hooks: true
679 })
680 VideoComments: VideoCommentModel[]
681
682 @HasMany(() => VideoViewModel, {
683 foreignKey: {
684 name: 'videoId',
685 allowNull: false
686 },
687 onDelete: 'cascade'
688 })
689 VideoViews: VideoViewModel[]
690
691 @HasMany(() => UserVideoHistoryModel, {
692 foreignKey: {
693 name: 'videoId',
694 allowNull: false
695 },
696 onDelete: 'cascade'
697 })
698 UserVideoHistories: UserVideoHistoryModel[]
699
700 @HasOne(() => ScheduleVideoUpdateModel, {
701 foreignKey: {
702 name: 'videoId',
703 allowNull: false
704 },
705 onDelete: 'cascade'
706 })
707 ScheduleVideoUpdate: ScheduleVideoUpdateModel
708
709 @HasOne(() => VideoBlacklistModel, {
710 foreignKey: {
711 name: 'videoId',
712 allowNull: false
713 },
714 onDelete: 'cascade'
715 })
716 VideoBlacklist: VideoBlacklistModel
717
718 @HasOne(() => VideoLiveModel, {
719 foreignKey: {
720 name: 'videoId',
721 allowNull: false
722 },
723 hooks: true,
724 onDelete: 'cascade'
725 })
726 VideoLive: VideoLiveModel
727
728 @HasOne(() => VideoImportModel, {
729 foreignKey: {
730 name: 'videoId',
731 allowNull: true
732 },
733 onDelete: 'set null'
734 })
735 VideoImport: VideoImportModel
736
737 @HasMany(() => VideoCaptionModel, {
738 foreignKey: {
739 name: 'videoId',
740 allowNull: false
741 },
742 onDelete: 'cascade',
743 hooks: true,
744 ['separate' as any]: true
745 })
746 VideoCaptions: VideoCaptionModel[]
747
748 @HasMany(() => VideoPasswordModel, {
749 foreignKey: {
750 name: 'videoId',
751 allowNull: false
752 },
753 onDelete: 'cascade'
754 })
755 VideoPasswords: VideoPasswordModel[]
756
757 @HasOne(() => VideoJobInfoModel, {
758 foreignKey: {
759 name: 'videoId',
760 allowNull: false
761 },
762 onDelete: 'cascade'
763 })
764 VideoJobInfo: VideoJobInfoModel
765
766 @HasOne(() => StoryboardModel, {
767 foreignKey: {
768 name: 'videoId',
769 allowNull: false
770 },
771 onDelete: 'cascade',
772 hooks: true
773 })
774 Storyboard: StoryboardModel
775
776 @AfterCreate
777 static notifyCreate (video: MVideo) {
778 InternalEventEmitter.Instance.emit('video-created', { video })
779 }
780
781 @AfterUpdate
782 static notifyUpdate (video: MVideo) {
783 InternalEventEmitter.Instance.emit('video-updated', { video })
784 }
785
786 @AfterDestroy
787 static notifyDestroy (video: MVideo) {
788 InternalEventEmitter.Instance.emit('video-deleted', { video })
789 }
790
791 @BeforeDestroy
792 static async sendDelete (instance: MVideoAccountLight, options: { transaction: Transaction }) {
793 if (!instance.isOwned()) return undefined
794
795 // Lazy load channels
796 if (!instance.VideoChannel) {
797 instance.VideoChannel = await instance.$get('VideoChannel', {
798 include: [
799 ActorModel,
800 AccountModel
801 ],
802 transaction: options.transaction
803 }) as MChannelAccountDefault
804 }
805
806 return sendDeleteVideo(instance, options.transaction)
807 }
808
809 @BeforeDestroy
810 static async removeFiles (instance: VideoModel, options) {
811 const tasks: Promise<any>[] = []
812
813 logger.info('Removing files of video %s.', instance.url)
814
815 if (instance.isOwned()) {
816 if (!Array.isArray(instance.VideoFiles)) {
817 instance.VideoFiles = await instance.$get('VideoFiles', { transaction: options.transaction })
818 }
819
820 // Remove physical files and torrents
821 instance.VideoFiles.forEach(file => {
822 tasks.push(instance.removeWebVideoFile(file))
823 })
824
825 // Remove playlists file
826 if (!Array.isArray(instance.VideoStreamingPlaylists)) {
827 instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists', { transaction: options.transaction })
828 }
829
830 for (const p of instance.VideoStreamingPlaylists) {
831 tasks.push(instance.removeStreamingPlaylistFiles(p))
832 }
833 }
834
835 // Do not wait video deletion because we could be in a transaction
836 Promise.all(tasks)
837 .then(() => logger.info('Removed files of video %s.', instance.url))
838 .catch(err => logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err }))
839
840 return undefined
841 }
842
843 @BeforeDestroy
844 static stopLiveIfNeeded (instance: VideoModel) {
845 if (!instance.isLive) return
846
847 logger.info('Stopping live of video %s after video deletion.', instance.uuid)
848
849 LiveManager.Instance.stopSessionOf(instance.uuid, null)
850 }
851
852 @BeforeDestroy
853 static invalidateCache (instance: VideoModel) {
854 ModelCache.Instance.invalidateCache('video', instance.id)
855 }
856
857 @BeforeDestroy
858 static async saveEssentialDataToAbuses (instance: VideoModel, options) {
859 const tasks: Promise<any>[] = []
860
861 if (!Array.isArray(instance.VideoAbuses)) {
862 instance.VideoAbuses = await instance.$get('VideoAbuses', { transaction: options.transaction })
863
864 if (instance.VideoAbuses.length === 0) return undefined
865 }
866
867 logger.info('Saving video abuses details of video %s.', instance.url)
868
869 if (!instance.Trackers) instance.Trackers = await instance.$get('Trackers', { transaction: options.transaction })
870 const details = instance.toFormattedDetailsJSON()
871
872 for (const abuse of instance.VideoAbuses) {
873 abuse.deletedVideo = details
874 tasks.push(abuse.save({ transaction: options.transaction }))
875 }
876
877 await Promise.all(tasks)
878 }
879
880 static listLocalIds (): Promise<number[]> {
881 const query = {
882 attributes: [ 'id' ],
883 raw: true,
884 where: {
885 remote: false
886 }
887 }
888
889 return VideoModel.findAll(query)
890 .then(rows => rows.map(r => r.id))
891 }
892
893 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
894 function getRawQuery (select: string) {
895 const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
896 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
897 'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
898 'WHERE "Account"."actorId" = ' + actorId
899 const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
900 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
901 'WHERE "VideoShare"."actorId" = ' + actorId
902
903 return `(${queryVideo}) UNION (${queryVideoShare})`
904 }
905
906 const rawQuery = getRawQuery('"Video"."id"')
907 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
908
909 const query = {
910 distinct: true,
911 offset: start,
912 limit: count,
913 order: getVideoSort('-createdAt', [ 'Tags', 'name', 'ASC' ]),
914 where: {
915 id: {
916 [Op.in]: Sequelize.literal('(' + rawQuery + ')')
917 },
918 [Op.or]: getPrivaciesForFederation()
919 },
920 include: [
921 {
922 attributes: [ 'filename', 'language', 'fileUrl' ],
923 model: VideoCaptionModel.unscoped(),
924 required: false
925 },
926 {
927 model: StoryboardModel.unscoped(),
928 required: false
929 },
930 {
931 attributes: [ 'id', 'url' ],
932 model: VideoShareModel.unscoped(),
933 required: false,
934 // We only want videos shared by this actor
935 where: {
936 [Op.and]: [
937 {
938 id: {
939 [Op.not]: null
940 }
941 },
942 {
943 actorId
944 }
945 ]
946 },
947 include: [
948 {
949 attributes: [ 'id', 'url' ],
950 model: ActorModel.unscoped()
951 }
952 ]
953 },
954 {
955 model: VideoChannelModel.unscoped(),
956 required: true,
957 include: [
958 {
959 attributes: [ 'name' ],
960 model: AccountModel.unscoped(),
961 required: true,
962 include: [
963 {
964 attributes: [ 'id', 'url', 'followersUrl' ],
965 model: ActorModel.unscoped(),
966 required: true
967 }
968 ]
969 },
970 {
971 attributes: [ 'id', 'url', 'followersUrl' ],
972 model: ActorModel.unscoped(),
973 required: true
974 }
975 ]
976 },
977 {
978 model: VideoStreamingPlaylistModel.unscoped(),
979 required: false,
980 include: [
981 {
982 model: VideoFileModel,
983 required: false
984 }
985 ]
986 },
987 VideoLiveModel.unscoped(),
988 VideoFileModel,
989 TagModel
990 ]
991 }
992
993 return Bluebird.all([
994 VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
995 VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
996 ]).then(([ rows, totals ]) => {
997 // totals: totalVideos + totalVideoShares
998 let totalVideos = 0
999 let totalVideoShares = 0
1000 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
1001 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
1002
1003 const total = totalVideos + totalVideoShares
1004 return {
1005 data: rows,
1006 total
1007 }
1008 })
1009 }
1010
1011 static async listPublishedLiveUUIDs () {
1012 const options = {
1013 attributes: [ 'uuid' ],
1014 where: {
1015 isLive: true,
1016 remote: false,
1017 state: VideoState.PUBLISHED
1018 }
1019 }
1020
1021 const result = await VideoModel.findAll(options)
1022
1023 return result.map(v => v.uuid)
1024 }
1025
1026 static listUserVideosForApi (options: {
1027 accountId: number
1028 start: number
1029 count: number
1030 sort: string
1031
1032 channelId?: number
1033 isLive?: boolean
1034 search?: string
1035 }) {
1036 const { accountId, channelId, start, count, sort, search, isLive } = options
1037
1038 function buildBaseQuery (forCount: boolean): FindOptions {
1039 const where: WhereOptions = {}
1040
1041 if (search) {
1042 where.name = {
1043 [Op.iLike]: '%' + search + '%'
1044 }
1045 }
1046
1047 if (exists(isLive)) {
1048 where.isLive = isLive
1049 }
1050
1051 const channelWhere = channelId
1052 ? { id: channelId }
1053 : {}
1054
1055 const baseQuery = {
1056 offset: start,
1057 limit: count,
1058 where,
1059 order: getVideoSort(sort),
1060 include: [
1061 {
1062 model: forCount
1063 ? VideoChannelModel.unscoped()
1064 : VideoChannelModel,
1065 required: true,
1066 where: channelWhere,
1067 include: [
1068 {
1069 model: forCount
1070 ? AccountModel.unscoped()
1071 : AccountModel,
1072 where: {
1073 id: accountId
1074 },
1075 required: true
1076 }
1077 ]
1078 }
1079 ]
1080 }
1081
1082 return baseQuery
1083 }
1084
1085 const countQuery = buildBaseQuery(true)
1086 const findQuery = buildBaseQuery(false)
1087
1088 const findScopes: (string | ScopeOptions)[] = [
1089 ScopeNames.WITH_SCHEDULED_UPDATE,
1090 ScopeNames.WITH_BLACKLISTED,
1091 ScopeNames.WITH_THUMBNAILS
1092 ]
1093
1094 return Promise.all([
1095 VideoModel.count(countQuery),
1096 VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1097 ]).then(([ count, rows ]) => {
1098 return {
1099 data: rows,
1100 total: count
1101 }
1102 })
1103 }
1104
1105 static async listForApi (options: {
1106 start: number
1107 count: number
1108 sort: string
1109
1110 nsfw: boolean
1111 isLive?: boolean
1112 isLocal?: boolean
1113 include?: VideoInclude
1114
1115 hasFiles?: boolean // default false
1116
1117 hasWebtorrentFiles?: boolean // TODO: remove in v7
1118 hasWebVideoFiles?: boolean
1119
1120 hasHLSFiles?: boolean
1121
1122 categoryOneOf?: number[]
1123 licenceOneOf?: number[]
1124 languageOneOf?: string[]
1125 tagsOneOf?: string[]
1126 tagsAllOf?: string[]
1127 privacyOneOf?: VideoPrivacy[]
1128
1129 accountId?: number
1130 videoChannelId?: number
1131
1132 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1133
1134 videoPlaylistId?: number
1135
1136 trendingDays?: number
1137
1138 user?: MUserAccountId
1139 historyOfUser?: MUserId
1140
1141 countVideos?: boolean
1142
1143 search?: string
1144
1145 excludeAlreadyWatched?: boolean
1146 }) {
1147 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1148 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1149
1150 const trendingDays = options.sort.endsWith('trending')
1151 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1152 : undefined
1153
1154 let trendingAlgorithm: string
1155 if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
1156 if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
1157
1158 const serverActor = await getServerActor()
1159
1160 const queryOptions = {
1161 ...pick(options, [
1162 'start',
1163 'count',
1164 'sort',
1165 'nsfw',
1166 'isLive',
1167 'categoryOneOf',
1168 'licenceOneOf',
1169 'languageOneOf',
1170 'tagsOneOf',
1171 'tagsAllOf',
1172 'privacyOneOf',
1173 'isLocal',
1174 'include',
1175 'displayOnlyForFollower',
1176 'hasFiles',
1177 'accountId',
1178 'videoChannelId',
1179 'videoPlaylistId',
1180 'user',
1181 'historyOfUser',
1182 'hasHLSFiles',
1183 'hasWebtorrentFiles',
1184 'hasWebVideoFiles',
1185 'search',
1186 'excludeAlreadyWatched'
1187 ]),
1188
1189 serverAccountIdForBlock: serverActor.Account.id,
1190 trendingDays,
1191 trendingAlgorithm
1192 }
1193
1194 return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1195 }
1196
1197 static async searchAndPopulateAccountAndServer (options: {
1198 start: number
1199 count: number
1200 sort: string
1201
1202 nsfw?: boolean
1203 isLive?: boolean
1204 isLocal?: boolean
1205 include?: VideoInclude
1206
1207 categoryOneOf?: number[]
1208 licenceOneOf?: number[]
1209 languageOneOf?: string[]
1210 tagsOneOf?: string[]
1211 tagsAllOf?: string[]
1212 privacyOneOf?: VideoPrivacy[]
1213
1214 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1215
1216 user?: MUserAccountId
1217
1218 hasWebtorrentFiles?: boolean // TODO: remove in v7
1219 hasWebVideoFiles?: boolean
1220
1221 hasHLSFiles?: boolean
1222
1223 search?: string
1224
1225 host?: string
1226 startDate?: string // ISO 8601
1227 endDate?: string // ISO 8601
1228 originallyPublishedStartDate?: string
1229 originallyPublishedEndDate?: string
1230
1231 durationMin?: number // seconds
1232 durationMax?: number // seconds
1233 uuids?: string[]
1234
1235 excludeAlreadyWatched?: boolean
1236 }) {
1237 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1238 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1239
1240 const serverActor = await getServerActor()
1241
1242 const queryOptions = {
1243 ...pick(options, [
1244 'include',
1245 'nsfw',
1246 'isLive',
1247 'categoryOneOf',
1248 'licenceOneOf',
1249 'languageOneOf',
1250 'tagsOneOf',
1251 'tagsAllOf',
1252 'privacyOneOf',
1253 'user',
1254 'isLocal',
1255 'host',
1256 'start',
1257 'count',
1258 'sort',
1259 'startDate',
1260 'endDate',
1261 'originallyPublishedStartDate',
1262 'originallyPublishedEndDate',
1263 'durationMin',
1264 'durationMax',
1265 'hasHLSFiles',
1266 'hasWebtorrentFiles',
1267 'hasWebVideoFiles',
1268 'uuids',
1269 'search',
1270 'displayOnlyForFollower',
1271 'excludeAlreadyWatched'
1272 ]),
1273 serverAccountIdForBlock: serverActor.Account.id
1274 }
1275
1276 return VideoModel.getAvailableForApi(queryOptions)
1277 }
1278
1279 static countLives (options: {
1280 remote: boolean
1281 mode: 'published' | 'not-ended'
1282 }) {
1283 const query = {
1284 where: {
1285 remote: options.remote,
1286 isLive: true,
1287 state: options.mode === 'not-ended'
1288 ? { [Op.ne]: VideoState.LIVE_ENDED }
1289 : { [Op.eq]: VideoState.PUBLISHED }
1290 }
1291 }
1292
1293 return VideoModel.count(query)
1294 }
1295
1296 static countVideosUploadedByUserSince (userId: number, since: Date) {
1297 const options = {
1298 include: [
1299 {
1300 model: VideoChannelModel.unscoped(),
1301 required: true,
1302 include: [
1303 {
1304 model: AccountModel.unscoped(),
1305 required: true,
1306 include: [
1307 {
1308 model: UserModel.unscoped(),
1309 required: true,
1310 where: {
1311 id: userId
1312 }
1313 }
1314 ]
1315 }
1316 ]
1317 }
1318 ],
1319 where: {
1320 createdAt: {
1321 [Op.gte]: since
1322 }
1323 }
1324 }
1325
1326 return VideoModel.unscoped().count(options)
1327 }
1328
1329 static countLivesOfAccount (accountId: number) {
1330 const options = {
1331 where: {
1332 remote: false,
1333 isLive: true,
1334 state: {
1335 [Op.ne]: VideoState.LIVE_ENDED
1336 }
1337 },
1338 include: [
1339 {
1340 required: true,
1341 model: VideoChannelModel.unscoped(),
1342 where: {
1343 accountId
1344 }
1345 }
1346 ]
1347 }
1348
1349 return VideoModel.count(options)
1350 }
1351
1352 static load (id: number | string, transaction?: Transaction): Promise<MVideoThumbnail> {
1353 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1354
1355 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' })
1356 }
1357
1358 static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
1359 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1360
1361 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' })
1362 }
1363
1364 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
1365 const fun = () => {
1366 const query = {
1367 where: buildWhereIdOrUUID(id),
1368 transaction: t
1369 }
1370
1371 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1372 }
1373
1374 return ModelCache.Instance.doCache({
1375 cacheType: 'load-video-immutable-id',
1376 key: '' + id,
1377 deleteKey: 'video',
1378 fun
1379 })
1380 }
1381
1382 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
1383 const fun = () => {
1384 const query: FindOptions = {
1385 where: {
1386 url
1387 },
1388 transaction
1389 }
1390
1391 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1392 }
1393
1394 return ModelCache.Instance.doCache({
1395 cacheType: 'load-video-immutable-url',
1396 key: url,
1397 deleteKey: 'video',
1398 fun
1399 })
1400 }
1401
1402 static loadOnlyId (id: number | string, transaction?: Transaction): Promise<MVideoId> {
1403 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1404
1405 return queryBuilder.queryVideo({ id, transaction, type: 'id' })
1406 }
1407
1408 static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1409 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1410
1411 return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging })
1412 }
1413
1414 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1415 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1416
1417 return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
1418 }
1419
1420 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
1421 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1422
1423 return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
1424 }
1425
1426 static loadFull (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
1427 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1428
1429 return queryBuilder.queryVideo({ id, transaction: t, type: 'full', userId })
1430 }
1431
1432 static loadForGetAPI (parameters: {
1433 id: number | string
1434 transaction?: Transaction
1435 userId?: number
1436 }): Promise<MVideoDetails> {
1437 const { id, transaction, userId } = parameters
1438 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1439
1440 return queryBuilder.queryVideo({ id, transaction, type: 'api', userId })
1441 }
1442
1443 static async getStats () {
1444 const serverActor = await getServerActor()
1445
1446 let totalLocalVideoViews = await VideoModel.sum('views', {
1447 where: {
1448 remote: false
1449 }
1450 })
1451
1452 // Sequelize could return null...
1453 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1454
1455 const baseOptions = {
1456 start: 0,
1457 count: 0,
1458 sort: '-publishedAt',
1459 nsfw: null,
1460 displayOnlyForFollower: {
1461 actorId: serverActor.id,
1462 orLocalVideos: true
1463 }
1464 }
1465
1466 const { total: totalLocalVideos } = await VideoModel.listForApi({
1467 ...baseOptions,
1468
1469 isLocal: true
1470 })
1471
1472 const { total: totalVideos } = await VideoModel.listForApi(baseOptions)
1473
1474 return {
1475 totalLocalVideos,
1476 totalLocalVideoViews,
1477 totalVideos
1478 }
1479 }
1480
1481 static incrementViews (id: number, views: number) {
1482 return VideoModel.increment('views', {
1483 by: views,
1484 where: {
1485 id
1486 }
1487 })
1488 }
1489
1490 static updateRatesOf (videoId: number, type: VideoRateType, count: number, t: Transaction) {
1491 const field = type === 'like'
1492 ? 'likes'
1493 : 'dislikes'
1494
1495 const rawQuery = `UPDATE "video" SET "${field}" = :count WHERE "video"."id" = :videoId`
1496
1497 return AccountVideoRateModel.sequelize.query(rawQuery, {
1498 transaction: t,
1499 replacements: { videoId, rateType: type, count },
1500 type: QueryTypes.UPDATE
1501 })
1502 }
1503
1504 static syncLocalRates (videoId: number, type: VideoRateType, t: Transaction) {
1505 const field = type === 'like'
1506 ? 'likes'
1507 : 'dislikes'
1508
1509 const rawQuery = `UPDATE "video" SET "${field}" = ` +
1510 '(' +
1511 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' +
1512 ') ' +
1513 'WHERE "video"."id" = :videoId'
1514
1515 return AccountVideoRateModel.sequelize.query(rawQuery, {
1516 transaction: t,
1517 replacements: { videoId, rateType: type },
1518 type: QueryTypes.UPDATE
1519 })
1520 }
1521
1522 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1523 // Instances only share videos
1524 const query = 'SELECT 1 FROM "videoShare" ' +
1525 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1526 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
1527 'UNION ' +
1528 'SELECT 1 FROM "video" ' +
1529 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
1530 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
1531 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "account"."actorId" ' +
1532 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "video"."id" = $videoId ' +
1533 'LIMIT 1'
1534
1535 const options = {
1536 type: QueryTypes.SELECT as QueryTypes.SELECT,
1537 bind: { followerActorId, videoId },
1538 raw: true
1539 }
1540
1541 return VideoModel.sequelize.query(query, options)
1542 .then(results => results.length === 1)
1543 }
1544
1545 static bulkUpdateSupportField (ofChannel: MChannel, t: Transaction) {
1546 const options = {
1547 where: {
1548 channelId: ofChannel.id
1549 },
1550 transaction: t
1551 }
1552
1553 return VideoModel.update({ support: ofChannel.support }, options)
1554 }
1555
1556 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
1557 const query = {
1558 attributes: [ 'id' ],
1559 where: {
1560 channelId: videoChannel.id
1561 }
1562 }
1563
1564 return VideoModel.findAll(query)
1565 .then(videos => videos.map(v => v.id))
1566 }
1567
1568 // threshold corresponds to how many video the field should have to be returned
1569 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1570 const serverActor = await getServerActor()
1571
1572 const queryOptions: BuildVideosListQueryOptions = {
1573 attributes: [ `"${field}"` ],
1574 group: `GROUP BY "${field}"`,
1575 having: `HAVING COUNT("${field}") >= ${threshold}`,
1576 start: 0,
1577 sort: 'random',
1578 count,
1579 serverAccountIdForBlock: serverActor.Account.id,
1580 displayOnlyForFollower: {
1581 actorId: serverActor.id,
1582 orLocalVideos: true
1583 }
1584 }
1585
1586 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1587
1588 return queryBuilder.queryVideoIds(queryOptions)
1589 .then(rows => rows.map(r => r[field]))
1590 }
1591
1592 static buildTrendingQuery (trendingDays: number) {
1593 return {
1594 attributes: [],
1595 subQuery: false,
1596 model: VideoViewModel,
1597 required: false,
1598 where: {
1599 startDate: {
1600 // FIXME: ts error
1601 [Op.gte as any]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1602 }
1603 }
1604 }
1605 }
1606
1607 private static async getAvailableForApi (
1608 options: BuildVideosListQueryOptions,
1609 countVideos = true
1610 ): Promise<ResultList<VideoModel>> {
1611 const span = tracer.startSpan('peertube.VideoModel.getAvailableForApi')
1612
1613 function getCount () {
1614 if (countVideos !== true) return Promise.resolve(undefined)
1615
1616 const countOptions = Object.assign({}, options, { isCount: true })
1617 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1618
1619 return queryBuilder.countVideoIds(countOptions)
1620 }
1621
1622 function getModels () {
1623 if (options.count === 0) return Promise.resolve([])
1624
1625 const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize)
1626
1627 return queryBuilder.queryVideos(options)
1628 }
1629
1630 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1631
1632 span.end()
1633
1634 return {
1635 data: rows,
1636 total: count
1637 }
1638 }
1639
1640 private static throwIfPrivateIncludeWithoutUser (include: VideoInclude, user: MUserAccountId) {
1641 if (VideoModel.isPrivateInclude(include) && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1642 throw new Error('Try to include protected videos but user cannot see all videos')
1643 }
1644 }
1645
1646 private static throwIfPrivacyOneOfWithoutUser (privacyOneOf: VideoPrivacy[], user: MUserAccountId) {
1647 if (privacyOneOf && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1648 throw new Error('Try to choose video privacies but user cannot see all videos')
1649 }
1650 }
1651
1652 private static isPrivateInclude (include: VideoInclude) {
1653 return include & VideoInclude.BLACKLISTED ||
1654 include & VideoInclude.BLOCKED_OWNER ||
1655 include & VideoInclude.NOT_PUBLISHED_STATE
1656 }
1657
1658 isBlacklisted () {
1659 return !!this.VideoBlacklist
1660 }
1661
1662 isBlocked () {
1663 return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
1664 }
1665
1666 getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1667 const files = this.getAllFiles()
1668 const file = fun(files, file => file.resolution)
1669 if (!file) return undefined
1670
1671 if (file.videoId) {
1672 return Object.assign(file, { Video: this })
1673 }
1674
1675 if (file.videoStreamingPlaylistId) {
1676 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1677
1678 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1679 }
1680
1681 throw new Error('File is not associated to a video of a playlist')
1682 }
1683
1684 getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1685 return this.getQualityFileBy(maxBy)
1686 }
1687
1688 getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1689 return this.getQualityFileBy(minBy)
1690 }
1691
1692 getWebVideoFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1693 if (Array.isArray(this.VideoFiles) === false) return undefined
1694
1695 const file = this.VideoFiles.find(f => f.resolution === resolution)
1696 if (!file) return undefined
1697
1698 return Object.assign(file, { Video: this })
1699 }
1700
1701 hasWebVideoFiles () {
1702 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
1703 }
1704
1705 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) {
1706 thumbnail.videoId = this.id
1707
1708 const savedThumbnail = await thumbnail.save({ transaction })
1709
1710 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1711
1712 this.Thumbnails = this.Thumbnails.filter(t => t.id !== savedThumbnail.id)
1713 this.Thumbnails.push(savedThumbnail)
1714 }
1715
1716 getMiniature () {
1717 if (Array.isArray(this.Thumbnails) === false) return undefined
1718
1719 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1720 }
1721
1722 hasPreview () {
1723 return !!this.getPreview()
1724 }
1725
1726 getPreview () {
1727 if (Array.isArray(this.Thumbnails) === false) return undefined
1728
1729 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1730 }
1731
1732 isOwned () {
1733 return this.remote === false
1734 }
1735
1736 getWatchStaticPath () {
1737 return buildVideoWatchPath({ shortUUID: uuidToShort(this.uuid) })
1738 }
1739
1740 getEmbedStaticPath () {
1741 return buildVideoEmbedPath(this)
1742 }
1743
1744 getMiniatureStaticPath () {
1745 const thumbnail = this.getMiniature()
1746 if (!thumbnail) return null
1747
1748 return thumbnail.getLocalStaticPath()
1749 }
1750
1751 getPreviewStaticPath () {
1752 const preview = this.getPreview()
1753 if (!preview) return null
1754
1755 return preview.getLocalStaticPath()
1756 }
1757
1758 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1759 return videoModelToFormattedJSON(this, options)
1760 }
1761
1762 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1763 return videoModelToFormattedDetailsJSON(this)
1764 }
1765
1766 getFormattedWebVideoFilesJSON (includeMagnet = true): VideoFile[] {
1767 return videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet })
1768 }
1769
1770 getFormattedHLSVideoFilesJSON (includeMagnet = true): VideoFile[] {
1771 let acc: VideoFile[] = []
1772
1773 for (const p of this.VideoStreamingPlaylists) {
1774 acc = acc.concat(videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet }))
1775 }
1776
1777 return acc
1778 }
1779
1780 getFormattedAllVideoFilesJSON (includeMagnet = true): VideoFile[] {
1781 let files: VideoFile[] = []
1782
1783 if (Array.isArray(this.VideoFiles)) {
1784 files = files.concat(this.getFormattedWebVideoFilesJSON(includeMagnet))
1785 }
1786
1787 if (Array.isArray(this.VideoStreamingPlaylists)) {
1788 files = files.concat(this.getFormattedHLSVideoFilesJSON(includeMagnet))
1789 }
1790
1791 return files
1792 }
1793
1794 toActivityPubObject (this: MVideoAP): Promise<VideoObject> {
1795 return Hooks.wrapObject(
1796 videoModelToActivityPubObject(this),
1797 'filter:activity-pub.video.json-ld.build.result',
1798 { video: this }
1799 )
1800 }
1801
1802 async lightAPToFullAP (this: MVideoAPLight, transaction: Transaction): Promise<MVideoAP> {
1803 const videoAP = this as MVideoAP
1804
1805 const getCaptions = () => {
1806 if (isArray(videoAP.VideoCaptions)) return videoAP.VideoCaptions
1807
1808 return this.$get('VideoCaptions', {
1809 attributes: [ 'filename', 'language', 'fileUrl' ],
1810 transaction
1811 }) as Promise<MVideoCaptionLanguageUrl[]>
1812 }
1813
1814 const getStoryboard = () => {
1815 if (videoAP.Storyboard) return videoAP.Storyboard
1816
1817 return this.$get('Storyboard', { transaction }) as Promise<MStoryboard>
1818 }
1819
1820 const [ captions, storyboard ] = await Promise.all([ getCaptions(), getStoryboard() ])
1821
1822 return Object.assign(this, {
1823 VideoCaptions: captions,
1824 Storyboard: storyboard
1825 })
1826 }
1827
1828 getTruncatedDescription () {
1829 if (!this.description) return null
1830
1831 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1832 return peertubeTruncate(this.description, { length: maxLength })
1833 }
1834
1835 getAllFiles () {
1836 let files: MVideoFile[] = []
1837
1838 if (Array.isArray(this.VideoFiles)) {
1839 files = files.concat(this.VideoFiles)
1840 }
1841
1842 if (Array.isArray(this.VideoStreamingPlaylists)) {
1843 for (const p of this.VideoStreamingPlaylists) {
1844 if (Array.isArray(p.VideoFiles)) {
1845 files = files.concat(p.VideoFiles)
1846 }
1847 }
1848 }
1849
1850 return files
1851 }
1852
1853 probeMaxQualityFile () {
1854 const file = this.getMaxQualityFile()
1855 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1856
1857 return VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(videoOrPlaylist), async originalFilePath => {
1858 const probe = await ffprobePromise(originalFilePath)
1859
1860 const { audioStream } = await getAudioStream(originalFilePath, probe)
1861 const hasAudio = await hasAudioStream(originalFilePath, probe)
1862 const fps = await getVideoStreamFPS(originalFilePath, probe)
1863
1864 return {
1865 audioStream,
1866 hasAudio,
1867 fps,
1868
1869 ...await getVideoStreamDimensionsInfo(originalFilePath, probe)
1870 }
1871 })
1872 }
1873
1874 getDescriptionAPIPath () {
1875 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1876 }
1877
1878 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1879 if (!this.VideoStreamingPlaylists) return undefined
1880
1881 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1882 if (!playlist) return undefined
1883
1884 return playlist.withVideo(this)
1885 }
1886
1887 setHLSPlaylist (playlist: MStreamingPlaylist) {
1888 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1889
1890 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1891 this.VideoStreamingPlaylists = toAdd
1892 return
1893 }
1894
1895 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1896 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1897 .concat(toAdd)
1898 }
1899
1900 removeWebVideoFile (videoFile: MVideoFile, isRedundancy = false) {
1901 const filePath = isRedundancy
1902 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
1903 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
1904
1905 const promises: Promise<any>[] = [ remove(filePath) ]
1906 if (!isRedundancy) promises.push(videoFile.removeTorrent())
1907
1908 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1909 promises.push(removeWebVideoObjectStorage(videoFile))
1910 }
1911
1912 return Promise.all(promises)
1913 }
1914
1915 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
1916 const directoryPath = isRedundancy
1917 ? getHLSRedundancyDirectory(this)
1918 : getHLSDirectory(this)
1919
1920 await remove(directoryPath)
1921
1922 if (isRedundancy !== true) {
1923 const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
1924 streamingPlaylistWithFiles.Video = this
1925
1926 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
1927 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
1928 }
1929
1930 // Remove physical files and torrents
1931 await Promise.all(
1932 streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
1933 )
1934
1935 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1936 await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
1937 }
1938 }
1939 }
1940
1941 async removeStreamingPlaylistVideoFile (streamingPlaylist: MStreamingPlaylist, videoFile: MVideoFile) {
1942 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, videoFile.filename)
1943 await videoFile.removeTorrent()
1944 await remove(filePath)
1945
1946 const resolutionFilename = getHlsResolutionPlaylistFilename(videoFile.filename)
1947 await remove(VideoPathManager.Instance.getFSHLSOutputPath(this, resolutionFilename))
1948
1949 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1950 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), videoFile.filename)
1951 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), resolutionFilename)
1952 }
1953 }
1954
1955 async removeStreamingPlaylistFile (streamingPlaylist: MStreamingPlaylist, filename: string) {
1956 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, filename)
1957 await remove(filePath)
1958
1959 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1960 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), filename)
1961 }
1962 }
1963
1964 isOutdated () {
1965 if (this.isOwned()) return false
1966
1967 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1968 }
1969
1970 hasPrivacyForFederation () {
1971 return isPrivacyForFederation(this.privacy)
1972 }
1973
1974 hasStateForFederation () {
1975 return isStateForFederation(this.state)
1976 }
1977
1978 isNewVideo (newPrivacy: VideoPrivacy) {
1979 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
1980 }
1981
1982 setAsRefreshed (transaction?: Transaction) {
1983 return setAsUpdated({ sequelize: this.sequelize, table: 'video', id: this.id, transaction })
1984 }
1985
1986 // ---------------------------------------------------------------------------
1987
1988 requiresUserAuth (options: {
1989 urlParamId: string
1990 checkBlacklist: boolean
1991 }) {
1992 const { urlParamId, checkBlacklist } = options
1993
1994 if (this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL) {
1995 return true
1996 }
1997
1998 if (this.privacy === VideoPrivacy.UNLISTED) {
1999 if (urlParamId && !isUUIDValid(urlParamId)) return true
2000
2001 return false
2002 }
2003
2004 if (checkBlacklist && this.VideoBlacklist) return true
2005
2006 if (this.privacy === VideoPrivacy.PUBLIC || this.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
2007 return false
2008 }
2009
2010 throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
2011 }
2012
2013 hasPrivateStaticPath () {
2014 return isVideoInPrivateDirectory(this.privacy)
2015 }
2016
2017 // ---------------------------------------------------------------------------
2018
2019 async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) {
2020 if (this.state === newState) throw new Error('Cannot use same state ' + newState)
2021
2022 this.state = newState
2023
2024 if (this.state === VideoState.PUBLISHED && isNewVideo) {
2025 this.publishedAt = new Date()
2026 }
2027
2028 await this.save({ transaction })
2029 }
2030
2031 getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
2032 if (!this.duration) return videoFile.size
2033
2034 return Math.ceil((videoFile.size * 8) / this.duration)
2035 }
2036
2037 getTrackerUrls () {
2038 if (this.isOwned()) {
2039 return [
2040 WEBSERVER.URL + '/tracker/announce',
2041 WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket'
2042 ]
2043 }
2044
2045 return this.Trackers.map(t => t.url)
2046 }
2047}
diff --git a/server/models/view/local-video-viewer-watch-section.ts b/server/models/view/local-video-viewer-watch-section.ts
deleted file mode 100644
index e29bb7847..000000000
--- a/server/models/view/local-video-viewer-watch-section.ts
+++ /dev/null
@@ -1,63 +0,0 @@
1import { Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table } from 'sequelize-typescript'
3import { MLocalVideoViewerWatchSection } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { LocalVideoViewerModel } from './local-video-viewer'
6
7@Table({
8 tableName: 'localVideoViewerWatchSection',
9 updatedAt: false,
10 indexes: [
11 {
12 fields: [ 'localVideoViewerId' ]
13 }
14 ]
15})
16export class LocalVideoViewerWatchSectionModel extends Model<Partial<AttributesOnly<LocalVideoViewerWatchSectionModel>>> {
17 @CreatedAt
18 createdAt: Date
19
20 @AllowNull(false)
21 @Column
22 watchStart: number
23
24 @AllowNull(false)
25 @Column
26 watchEnd: number
27
28 @ForeignKey(() => LocalVideoViewerModel)
29 @Column
30 localVideoViewerId: number
31
32 @BelongsTo(() => LocalVideoViewerModel, {
33 foreignKey: {
34 allowNull: false
35 },
36 onDelete: 'CASCADE'
37 })
38 LocalVideoViewer: LocalVideoViewerModel
39
40 static async bulkCreateSections (options: {
41 localVideoViewerId: number
42 watchSections: {
43 start: number
44 end: number
45 }[]
46 transaction?: Transaction
47 }) {
48 const { localVideoViewerId, watchSections, transaction } = options
49 const models: MLocalVideoViewerWatchSection[] = []
50
51 for (const section of watchSections) {
52 const model = await this.create({
53 watchStart: section.start,
54 watchEnd: section.end,
55 localVideoViewerId
56 }, { transaction })
57
58 models.push(model)
59 }
60
61 return models
62 }
63}
diff --git a/server/models/view/local-video-viewer.ts b/server/models/view/local-video-viewer.ts
deleted file mode 100644
index c7ac51a03..000000000
--- a/server/models/view/local-video-viewer.ts
+++ /dev/null
@@ -1,368 +0,0 @@
1import { QueryTypes } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, IsUUID, Model, Table } from 'sequelize-typescript'
3import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
4import { buildGroupByAndBoundaries } from '@server/lib/timeserie'
5import { MLocalVideoViewer, MLocalVideoViewerWithWatchSections, MVideo } from '@server/types/models'
6import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric, WatchActionObject } from '@shared/models'
7import { AttributesOnly } from '@shared/typescript-utils'
8import { VideoModel } from '../video/video'
9import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-section'
10
11/**
12 *
13 * Aggregate viewers of local videos only to display statistics to video owners
14 * A viewer is a user that watched one or multiple sections of a specific video inside a time window
15 *
16 */
17
18@Table({
19 tableName: 'localVideoViewer',
20 updatedAt: false,
21 indexes: [
22 {
23 fields: [ 'videoId' ]
24 },
25 {
26 fields: [ 'url' ],
27 unique: true
28 }
29 ]
30})
31export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVideoViewerModel>>> {
32 @CreatedAt
33 createdAt: Date
34
35 @AllowNull(false)
36 @Column(DataType.DATE)
37 startDate: Date
38
39 @AllowNull(false)
40 @Column(DataType.DATE)
41 endDate: Date
42
43 @AllowNull(false)
44 @Column
45 watchTime: number
46
47 @AllowNull(true)
48 @Column
49 country: string
50
51 @AllowNull(false)
52 @Default(DataType.UUIDV4)
53 @IsUUID(4)
54 @Column(DataType.UUID)
55 uuid: string
56
57 @AllowNull(false)
58 @Column
59 url: string
60
61 @ForeignKey(() => VideoModel)
62 @Column
63 videoId: number
64
65 @BelongsTo(() => VideoModel, {
66 foreignKey: {
67 allowNull: false
68 },
69 onDelete: 'CASCADE'
70 })
71 Video: VideoModel
72
73 @HasMany(() => LocalVideoViewerWatchSectionModel, {
74 foreignKey: {
75 allowNull: false
76 },
77 onDelete: 'cascade'
78 })
79 WatchSections: LocalVideoViewerWatchSectionModel[]
80
81 static loadByUrl (url: string): Promise<MLocalVideoViewer> {
82 return this.findOne({
83 where: {
84 url
85 }
86 })
87 }
88
89 static loadFullById (id: number): Promise<MLocalVideoViewerWithWatchSections> {
90 return this.findOne({
91 include: [
92 {
93 model: VideoModel.unscoped(),
94 required: true
95 },
96 {
97 model: LocalVideoViewerWatchSectionModel.unscoped(),
98 required: true
99 }
100 ],
101 where: {
102 id
103 }
104 })
105 }
106
107 static async getOverallStats (options: {
108 video: MVideo
109 startDate?: string
110 endDate?: string
111 }): Promise<VideoStatsOverall> {
112 const { video, startDate, endDate } = options
113
114 const queryOptions = {
115 type: QueryTypes.SELECT as QueryTypes.SELECT,
116 replacements: { videoId: video.id } as any
117 }
118
119 if (startDate) queryOptions.replacements.startDate = startDate
120 if (endDate) queryOptions.replacements.endDate = endDate
121
122 const buildTotalViewersPromise = () => {
123 let totalViewersDateWhere = ''
124
125 if (startDate) totalViewersDateWhere += ' AND "localVideoViewer"."endDate" >= :startDate'
126 if (endDate) totalViewersDateWhere += ' AND "localVideoViewer"."startDate" <= :endDate'
127
128 const totalViewersQuery = `SELECT ` +
129 `COUNT("localVideoViewer"."id") AS "totalViewers" ` +
130 `FROM "localVideoViewer" ` +
131 `WHERE "videoId" = :videoId ${totalViewersDateWhere}`
132
133 return LocalVideoViewerModel.sequelize.query<any>(totalViewersQuery, queryOptions)
134 }
135
136 const buildWatchTimePromise = () => {
137 let watchTimeDateWhere = ''
138
139 // We know this where is not exact
140 // But we prefer to take into account only watch section that started and ended **in** the interval
141 if (startDate) watchTimeDateWhere += ' AND "localVideoViewer"."startDate" >= :startDate'
142 if (endDate) watchTimeDateWhere += ' AND "localVideoViewer"."endDate" <= :endDate'
143
144 const watchTimeQuery = `SELECT ` +
145 `SUM("localVideoViewer"."watchTime") AS "totalWatchTime", ` +
146 `AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` +
147 `FROM "localVideoViewer" ` +
148 `WHERE "videoId" = :videoId ${watchTimeDateWhere}`
149
150 return LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, queryOptions)
151 }
152
153 const buildWatchPeakPromise = () => {
154 let watchPeakDateWhereStart = ''
155 let watchPeakDateWhereEnd = ''
156
157 if (startDate) {
158 watchPeakDateWhereStart += ' AND "localVideoViewer"."startDate" >= :startDate'
159 watchPeakDateWhereEnd += ' AND "localVideoViewer"."endDate" >= :startDate'
160 }
161
162 if (endDate) {
163 watchPeakDateWhereStart += ' AND "localVideoViewer"."startDate" <= :endDate'
164 watchPeakDateWhereEnd += ' AND "localVideoViewer"."endDate" <= :endDate'
165 }
166
167 // Add viewers that were already here, before our start date
168 const beforeWatchersQuery = startDate
169 // eslint-disable-next-line max-len
170 ? `SELECT COUNT(*) AS "total" FROM "localVideoViewer" WHERE "localVideoViewer"."startDate" < :startDate AND "localVideoViewer"."endDate" >= :startDate`
171 : `SELECT 0 AS "total"`
172
173 const watchPeakQuery = `WITH
174 "beforeWatchers" AS (${beforeWatchersQuery}),
175 "watchPeakValues" AS (
176 SELECT "startDate" AS "dateBreakpoint", 1 AS "inc"
177 FROM "localVideoViewer"
178 WHERE "videoId" = :videoId ${watchPeakDateWhereStart}
179 UNION ALL
180 SELECT "endDate" AS "dateBreakpoint", -1 AS "inc"
181 FROM "localVideoViewer"
182 WHERE "videoId" = :videoId ${watchPeakDateWhereEnd}
183 )
184 SELECT "dateBreakpoint", "concurrent"
185 FROM (
186 SELECT "dateBreakpoint", SUM(SUM("inc")) OVER (ORDER BY "dateBreakpoint") + (SELECT "total" FROM "beforeWatchers") AS "concurrent"
187 FROM "watchPeakValues"
188 GROUP BY "dateBreakpoint"
189 ) tmp
190 ORDER BY "concurrent" DESC
191 FETCH FIRST 1 ROW ONLY`
192
193 return LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, queryOptions)
194 }
195
196 const buildCountriesPromise = () => {
197 let countryDateWhere = ''
198
199 if (startDate) countryDateWhere += ' AND "localVideoViewer"."endDate" >= :startDate'
200 if (endDate) countryDateWhere += ' AND "localVideoViewer"."startDate" <= :endDate'
201
202 const countriesQuery = `SELECT country, COUNT(country) as viewers ` +
203 `FROM "localVideoViewer" ` +
204 `WHERE "videoId" = :videoId AND country IS NOT NULL ${countryDateWhere} ` +
205 `GROUP BY country ` +
206 `ORDER BY viewers DESC`
207
208 return LocalVideoViewerModel.sequelize.query<any>(countriesQuery, queryOptions)
209 }
210
211 const [ rowsTotalViewers, rowsWatchTime, rowsWatchPeak, rowsCountries ] = await Promise.all([
212 buildTotalViewersPromise(),
213 buildWatchTimePromise(),
214 buildWatchPeakPromise(),
215 buildCountriesPromise()
216 ])
217
218 const viewersPeak = rowsWatchPeak.length !== 0
219 ? parseInt(rowsWatchPeak[0].concurrent) || 0
220 : 0
221
222 return {
223 totalWatchTime: rowsWatchTime.length !== 0
224 ? Math.round(rowsWatchTime[0].totalWatchTime) || 0
225 : 0,
226 averageWatchTime: rowsWatchTime.length !== 0
227 ? Math.round(rowsWatchTime[0].averageWatchTime) || 0
228 : 0,
229
230 totalViewers: rowsTotalViewers.length !== 0
231 ? Math.round(rowsTotalViewers[0].totalViewers) || 0
232 : 0,
233
234 viewersPeak,
235 viewersPeakDate: rowsWatchPeak.length !== 0 && viewersPeak !== 0
236 ? rowsWatchPeak[0].dateBreakpoint || null
237 : null,
238
239 countries: rowsCountries.map(r => ({
240 isoCode: r.country,
241 viewers: r.viewers
242 }))
243 }
244 }
245
246 static async getRetentionStats (video: MVideo): Promise<VideoStatsRetention> {
247 const step = Math.max(Math.round(video.duration / 100), 1)
248
249 const query = `WITH "total" AS (SELECT COUNT(*) AS viewers FROM "localVideoViewer" WHERE "videoId" = :videoId) ` +
250 `SELECT serie AS "second", ` +
251 `(COUNT("localVideoViewer".id)::float / (SELECT GREATEST("total"."viewers", 1) FROM "total")) AS "retention" ` +
252 `FROM generate_series(0, ${video.duration}, ${step}) serie ` +
253 `LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` +
254 `AND EXISTS (` +
255 `SELECT 1 FROM "localVideoViewerWatchSection" ` +
256 `WHERE "localVideoViewer"."id" = "localVideoViewerWatchSection"."localVideoViewerId" ` +
257 `AND serie >= "localVideoViewerWatchSection"."watchStart" ` +
258 `AND serie <= "localVideoViewerWatchSection"."watchEnd"` +
259 `)` +
260 `GROUP BY serie ` +
261 `ORDER BY serie ASC`
262
263 const queryOptions = {
264 type: QueryTypes.SELECT as QueryTypes.SELECT,
265 replacements: { videoId: video.id }
266 }
267
268 const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
269
270 return {
271 data: rows.map(r => ({
272 second: r.second,
273 retentionPercent: parseFloat(r.retention) * 100
274 }))
275 }
276 }
277
278 static async getTimeserieStats (options: {
279 video: MVideo
280 metric: VideoStatsTimeserieMetric
281 startDate: string
282 endDate: string
283 }): Promise<VideoStatsTimeserie> {
284 const { video, metric } = options
285
286 const { groupInterval, startDate, endDate } = buildGroupByAndBoundaries(options.startDate, options.endDate)
287
288 const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = {
289 viewers: 'COUNT("localVideoViewer"."id")',
290 aggregateWatchTime: 'SUM("localVideoViewer"."watchTime")'
291 }
292
293 const intervalWhere: { [ id in VideoStatsTimeserieMetric ]: string } = {
294 // Viewer is still in the interval. Overlap algorithm
295 viewers: '"localVideoViewer"."startDate" <= "intervals"."endDate" ' +
296 'AND "localVideoViewer"."endDate" >= "intervals"."startDate"',
297
298 // We do an aggregation, so only sum things once. Arbitrary we use the end date for that purpose
299 aggregateWatchTime: '"localVideoViewer"."endDate" >= "intervals"."startDate" ' +
300 'AND "localVideoViewer"."endDate" <= "intervals"."endDate"'
301 }
302
303 const query = `WITH "intervals" AS (
304 SELECT
305 "time" AS "startDate", "time" + :groupInterval::interval as "endDate"
306 FROM
307 generate_series(:startDate::timestamptz, :endDate::timestamptz, :groupInterval::interval) serie("time")
308 )
309 SELECT "intervals"."startDate" as "date", COALESCE(${selectMetrics[metric]}, 0) AS value
310 FROM
311 intervals
312 LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId
313 AND ${intervalWhere[metric]}
314 GROUP BY
315 "intervals"."startDate"
316 ORDER BY
317 "intervals"."startDate"`
318
319 const queryOptions = {
320 type: QueryTypes.SELECT as QueryTypes.SELECT,
321 replacements: {
322 startDate,
323 endDate,
324 groupInterval,
325 videoId: video.id
326 }
327 }
328
329 const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
330
331 return {
332 groupInterval,
333 data: rows.map(r => ({
334 date: r.date,
335 value: parseInt(r.value)
336 }))
337 }
338 }
339
340 toActivityPubObject (this: MLocalVideoViewerWithWatchSections): WatchActionObject {
341 const location = this.country
342 ? {
343 location: {
344 addressCountry: this.country
345 }
346 }
347 : {}
348
349 return {
350 id: this.url,
351 type: 'WatchAction',
352 duration: getActivityStreamDuration(this.watchTime),
353 startTime: this.startDate.toISOString(),
354 endTime: this.endDate.toISOString(),
355
356 object: this.Video.url,
357 uuid: this.uuid,
358 actionStatus: 'CompletedActionStatus',
359
360 watchSections: this.WatchSections.map(w => ({
361 startTimestamp: w.watchStart,
362 endTimestamp: w.watchEnd
363 })),
364
365 ...location
366 }
367 }
368}
diff --git a/server/models/view/video-view.ts b/server/models/view/video-view.ts
deleted file mode 100644
index 1504a364e..000000000
--- a/server/models/view/video-view.ts
+++ /dev/null
@@ -1,67 +0,0 @@
1import { literal, Op } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table } from 'sequelize-typescript'
3import { AttributesOnly } from '@shared/typescript-utils'
4import { VideoModel } from '../video/video'
5
6/**
7 *
8 * Aggregate views of all videos federated with our instance
9 * Mainly used by the trending/hot algorithms
10 *
11 */
12
13@Table({
14 tableName: 'videoView',
15 updatedAt: false,
16 indexes: [
17 {
18 fields: [ 'videoId' ]
19 },
20 {
21 fields: [ 'startDate' ]
22 }
23 ]
24})
25export class VideoViewModel extends Model<Partial<AttributesOnly<VideoViewModel>>> {
26 @CreatedAt
27 createdAt: Date
28
29 @AllowNull(false)
30 @Column(DataType.DATE)
31 startDate: Date
32
33 @AllowNull(false)
34 @Column(DataType.DATE)
35 endDate: Date
36
37 @AllowNull(false)
38 @Column
39 views: number
40
41 @ForeignKey(() => VideoModel)
42 @Column
43 videoId: number
44
45 @BelongsTo(() => VideoModel, {
46 foreignKey: {
47 allowNull: false
48 },
49 onDelete: 'CASCADE'
50 })
51 Video: VideoModel
52
53 static removeOldRemoteViewsHistory (beforeDate: string) {
54 const query = {
55 where: {
56 startDate: {
57 [Op.lt]: beforeDate
58 },
59 videoId: {
60 [Op.in]: literal('(SELECT "id" FROM "video" WHERE "remote" IS TRUE)')
61 }
62 }
63 }
64
65 return VideoViewModel.destroy(query)
66 }
67}