diff options
Diffstat (limited to 'server/models')
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 @@ | |||
1 | import { FindOptions } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { isAbuseMessageValid } from '@server/helpers/custom-validators/abuses' | ||
4 | import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models' | ||
5 | import { AbuseMessage } from '@shared/models' | ||
6 | import { AttributesOnly } from '@shared/typescript-utils' | ||
7 | import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' | ||
8 | import { getSort, throwIfNotValid } from '../shared' | ||
9 | import { AbuseModel } from './abuse' | ||
10 | |||
11 | @Table({ | ||
12 | tableName: 'abuseMessage', | ||
13 | indexes: [ | ||
14 | { | ||
15 | fields: [ 'abuseId' ] | ||
16 | }, | ||
17 | { | ||
18 | fields: [ 'accountId' ] | ||
19 | } | ||
20 | ] | ||
21 | }) | ||
22 | export 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 @@ | |||
1 | import { invert } from 'lodash' | ||
2 | import { literal, Op, QueryTypes } from 'sequelize' | ||
3 | import { | ||
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' | ||
18 | import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses' | ||
19 | import { abusePredefinedReasonsMap } from '@shared/core-utils' | ||
20 | import { | ||
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' | ||
33 | import { AttributesOnly } from '@shared/typescript-utils' | ||
34 | import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants' | ||
35 | import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models' | ||
36 | import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account' | ||
37 | import { getSort, throwIfNotValid } from '../shared' | ||
38 | import { ThumbnailModel } from '../video/thumbnail' | ||
39 | import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video' | ||
40 | import { VideoBlacklistModel } from '../video/video-blacklist' | ||
41 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel' | ||
42 | import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment' | ||
43 | import { buildAbuseListQuery, BuildAbusesQueryOptions } from './sql/abuse-query-builder' | ||
44 | import { VideoAbuseModel } from './video-abuse' | ||
45 | import { VideoCommentAbuseModel } from './video-comment-abuse' | ||
46 | |||
47 | export 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 | }) | ||
191 | export 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 | |||
2 | import { exists } from '@server/helpers/custom-validators/misc' | ||
3 | import { forceNumber } from '@shared/core-utils' | ||
4 | import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models' | ||
5 | import { buildBlockedAccountSQL, buildSortDirectionAndField } from '../../shared' | ||
6 | |||
7 | export 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 | |||
36 | function 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 | |||
159 | function buildAbuseOrder (value: string) { | ||
160 | const { direction, field } = buildSortDirectionAndField(value) | ||
161 | |||
162 | return `ORDER BY "abuse"."${field}" ${direction}` | ||
163 | } | ||
164 | |||
165 | export { | ||
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 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { AttributesOnly } from '@shared/typescript-utils' | ||
3 | import { VideoDetails } from '@shared/models' | ||
4 | import { VideoModel } from '../video/video' | ||
5 | import { AbuseModel } from './abuse' | ||
6 | |||
7 | @Table({ | ||
8 | tableName: 'videoAbuse', | ||
9 | indexes: [ | ||
10 | { | ||
11 | fields: [ 'abuseId' ] | ||
12 | }, | ||
13 | { | ||
14 | fields: [ 'videoId' ] | ||
15 | } | ||
16 | ] | ||
17 | }) | ||
18 | export 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 @@ | |||
1 | import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { AttributesOnly } from '@shared/typescript-utils' | ||
3 | import { VideoCommentModel } from '../video/video-comment' | ||
4 | import { AbuseModel } from './abuse' | ||
5 | |||
6 | @Table({ | ||
7 | tableName: 'commentAbuse', | ||
8 | indexes: [ | ||
9 | { | ||
10 | fields: [ 'abuseId' ] | ||
11 | }, | ||
12 | { | ||
13 | fields: [ 'videoCommentId' ] | ||
14 | } | ||
15 | ] | ||
16 | }) | ||
17 | export 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 @@ | |||
1 | import { FindOptions, Op, QueryTypes } from 'sequelize' | ||
2 | import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { handlesToNameAndHost } from '@server/helpers/actors' | ||
4 | import { MAccountBlocklist, MAccountBlocklistFormattable } from '@server/types/models' | ||
5 | import { AttributesOnly } from '@shared/typescript-utils' | ||
6 | import { AccountBlock } from '../../../shared/models' | ||
7 | import { ActorModel } from '../actor/actor' | ||
8 | import { ServerModel } from '../server/server' | ||
9 | import { createSafeIn, getSort, searchAttribute } from '../shared' | ||
10 | import { 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 | }) | ||
24 | export 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 @@ | |||
1 | import { FindOptions, Op, QueryTypes, Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { | ||
4 | MAccountVideoRate, | ||
5 | MAccountVideoRateAccountUrl, | ||
6 | MAccountVideoRateAccountVideo, | ||
7 | MAccountVideoRateFormattable | ||
8 | } from '@server/types/models' | ||
9 | import { AccountVideoRate, VideoRateType } from '@shared/models' | ||
10 | import { AttributesOnly } from '@shared/typescript-utils' | ||
11 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
12 | import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants' | ||
13 | import { ActorModel } from '../actor/actor' | ||
14 | import { getSort, throwIfNotValid } from '../shared' | ||
15 | import { VideoModel } from '../video/video' | ||
16 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel' | ||
17 | import { 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 | }) | ||
44 | export 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 @@ | |||
1 | import { FindOptions, Includeable, IncludeOptions, Op, Transaction, WhereOptions } from 'sequelize' | ||
2 | import { | ||
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' | ||
19 | import { ModelCache } from '@server/models/shared/model-cache' | ||
20 | import { AttributesOnly } from '@shared/typescript-utils' | ||
21 | import { Account, AccountSummary } from '../../../shared/models/actors' | ||
22 | import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' | ||
23 | import { CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants' | ||
24 | import { sendDeleteActor } from '../../lib/activitypub/send/send-delete' | ||
25 | import { | ||
26 | MAccount, | ||
27 | MAccountActor, | ||
28 | MAccountAP, | ||
29 | MAccountDefault, | ||
30 | MAccountFormattable, | ||
31 | MAccountHost, | ||
32 | MAccountSummaryFormattable, | ||
33 | MChannelHost | ||
34 | } from '../../types/models' | ||
35 | import { ActorModel } from '../actor/actor' | ||
36 | import { ActorFollowModel } from '../actor/actor-follow' | ||
37 | import { ActorImageModel } from '../actor/actor-image' | ||
38 | import { ApplicationModel } from '../application/application' | ||
39 | import { ServerModel } from '../server/server' | ||
40 | import { ServerBlocklistModel } from '../server/server-blocklist' | ||
41 | import { buildSQLAttributes, getSort, throwIfNotValid } from '../shared' | ||
42 | import { UserModel } from '../user/user' | ||
43 | import { VideoModel } from '../video/video' | ||
44 | import { VideoChannelModel } from '../video/video-channel' | ||
45 | import { VideoCommentModel } from '../video/video-comment' | ||
46 | import { VideoPlaylistModel } from '../video/video-playlist' | ||
47 | import { AccountBlocklistModel } from './account-blocklist' | ||
48 | |||
49 | export enum ScopeNames { | ||
50 | SUMMARY = 'SUMMARY' | ||
51 | } | ||
52 | |||
53 | export 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 | }) | ||
149 | export 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 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { CustomPage } from '@shared/models' | ||
3 | import { ActorModel } from '../actor/actor' | ||
4 | import { getServerActor } from '../application/application' | ||
5 | |||
6 | @Table({ | ||
7 | tableName: 'actorCustomPage', | ||
8 | indexes: [ | ||
9 | { | ||
10 | fields: [ 'actorId', 'type' ], | ||
11 | unique: true | ||
12 | } | ||
13 | ] | ||
14 | }) | ||
15 | export 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 @@ | |||
1 | import { difference } from 'lodash' | ||
2 | import { Attributes, FindOptions, Includeable, IncludeOptions, Op, QueryTypes, Transaction, WhereAttributeHash } from 'sequelize' | ||
3 | import { | ||
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' | ||
21 | import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc' | ||
22 | import { afterCommitIfTransaction } from '@server/helpers/database-utils' | ||
23 | import { getServerActor } from '@server/models/application/application' | ||
24 | import { | ||
25 | MActor, | ||
26 | MActorFollowActors, | ||
27 | MActorFollowActorsDefault, | ||
28 | MActorFollowActorsDefaultSubscription, | ||
29 | MActorFollowFollowingHost, | ||
30 | MActorFollowFormattable, | ||
31 | MActorFollowSubscriptions | ||
32 | } from '@server/types/models' | ||
33 | import { AttributesOnly } from '@shared/typescript-utils' | ||
34 | import { FollowState } from '../../../shared/models/actors' | ||
35 | import { ActorFollow } from '../../../shared/models/actors/follow.model' | ||
36 | import { logger } from '../../helpers/logger' | ||
37 | import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME, SORTABLE_COLUMNS } from '../../initializers/constants' | ||
38 | import { AccountModel } from '../account/account' | ||
39 | import { ServerModel } from '../server/server' | ||
40 | import { buildSQLAttributes, createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../shared' | ||
41 | import { doesExist } from '../shared/query' | ||
42 | import { VideoChannelModel } from '../video/video-channel' | ||
43 | import { ActorModel, unusedActorAttributesForAPI } from './actor' | ||
44 | import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder' | ||
45 | import { 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 | }) | ||
69 | export 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 @@ | |||
1 | import { remove } from 'fs-extra' | ||
2 | import { join } from 'path' | ||
3 | import { | ||
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' | ||
16 | import { MActorImage, MActorImageFormattable } from '@server/types/models' | ||
17 | import { getLowercaseExtension } from '@shared/core-utils' | ||
18 | import { ActivityIconObject, ActorImageType } from '@shared/models' | ||
19 | import { AttributesOnly } from '@shared/typescript-utils' | ||
20 | import { ActorImage } from '../../../shared/models/actors/actor-image.model' | ||
21 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
22 | import { logger } from '../../helpers/logger' | ||
23 | import { CONFIG } from '../../initializers/config' | ||
24 | import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants' | ||
25 | import { buildSQLAttributes, throwIfNotValid } from '../shared' | ||
26 | import { 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 | }) | ||
41 | export 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 @@ | |||
1 | import { col, fn, literal, Op, QueryTypes, Transaction, where } from 'sequelize' | ||
2 | import { | ||
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' | ||
18 | import { activityPubContextify } from '@server/lib/activitypub/context' | ||
19 | import { getBiggestActorImage } from '@server/lib/actor-image' | ||
20 | import { ModelCache } from '@server/models/shared/model-cache' | ||
21 | import { forceNumber, getLowercaseExtension } from '@shared/core-utils' | ||
22 | import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models' | ||
23 | import { AttributesOnly } from '@shared/typescript-utils' | ||
24 | import { | ||
25 | isActorFollowersCountValid, | ||
26 | isActorFollowingCountValid, | ||
27 | isActorPreferredUsernameValid, | ||
28 | isActorPrivateKeyValid, | ||
29 | isActorPublicKeyValid | ||
30 | } from '../../helpers/custom-validators/activitypub/actor' | ||
31 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
32 | import { | ||
33 | ACTIVITY_PUB, | ||
34 | ACTIVITY_PUB_ACTOR_TYPES, | ||
35 | CONSTRAINTS_FIELDS, | ||
36 | MIMETYPES, | ||
37 | SERVER_ACTOR_NAME, | ||
38 | WEBSERVER | ||
39 | } from '../../initializers/constants' | ||
40 | import { | ||
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' | ||
55 | import { AccountModel } from '../account/account' | ||
56 | import { getServerActor } from '../application/application' | ||
57 | import { ServerModel } from '../server/server' | ||
58 | import { buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared' | ||
59 | import { VideoModel } from '../video/video' | ||
60 | import { VideoChannelModel } from '../video/video-channel' | ||
61 | import { ActorFollowModel } from './actor-follow' | ||
62 | import { ActorImageModel } from './actor-image' | ||
63 | |||
64 | enum ScopeNames { | ||
65 | FULL = 'FULL' | ||
66 | } | ||
67 | |||
68 | export 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 | }) | ||
164 | export 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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | import { ModelBuilder } from '@server/models/shared' | ||
3 | import { MActorFollowActorsDefault } from '@server/types/models' | ||
4 | import { ActivityPubActorType, FollowState } from '@shared/models' | ||
5 | import { parseRowCountResult } from '../../shared' | ||
6 | import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder' | ||
7 | |||
8 | export 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 | |||
18 | export 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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | import { ModelBuilder } from '@server/models/shared' | ||
3 | import { MActorFollowActorsDefault } from '@server/types/models' | ||
4 | import { ActivityPubActorType, FollowState } from '@shared/models' | ||
5 | import { parseRowCountResult } from '../../shared' | ||
6 | import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder' | ||
7 | |||
8 | export 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 | |||
18 | export 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 @@ | |||
1 | import { Memoize } from '@server/helpers/memoize' | ||
2 | import { ServerModel } from '@server/models/server/server' | ||
3 | import { ActorModel } from '../../actor' | ||
4 | import { ActorFollowModel } from '../../actor-follow' | ||
5 | import { ActorImageModel } from '../../actor-image' | ||
6 | |||
7 | export 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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | import { AbstractRunQuery } from '@server/models/shared' | ||
3 | import { ActorImageType } from '@shared/models' | ||
4 | import { getInstanceFollowsSort } from '../../../shared' | ||
5 | import { ActorFollowTableAttributes } from './actor-follow-table-attributes' | ||
6 | |||
7 | type BaseOptions = { | ||
8 | sort: string | ||
9 | count: number | ||
10 | start: number | ||
11 | } | ||
12 | |||
13 | export 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 @@ | |||
1 | import memoizee from 'memoizee' | ||
2 | import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Model, Table } from 'sequelize-typescript' | ||
3 | import { getNodeABIVersion } from '@server/helpers/version' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { AccountModel } from '../account/account' | ||
6 | |||
7 | export 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 | }) | ||
29 | export 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 @@ | |||
1 | import { ModelAttributeColumnOptions } from 'sequelize' | ||
2 | |||
3 | declare 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 | |||
25 | export { | ||
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 @@ | |||
1 | import { AllowNull, Column, CreatedAt, DataType, HasMany, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { AttributesOnly } from '@shared/typescript-utils' | ||
3 | import { 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 | }) | ||
18 | export 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 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { | ||
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' | ||
15 | import { TokensCache } from '@server/lib/auth/tokens-cache' | ||
16 | import { MUserAccountId } from '@server/types/models' | ||
17 | import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' | ||
18 | import { AttributesOnly } from '@shared/typescript-utils' | ||
19 | import { logger } from '../../helpers/logger' | ||
20 | import { AccountModel } from '../account/account' | ||
21 | import { ActorModel } from '../actor/actor' | ||
22 | import { UserModel } from '../user/user' | ||
23 | import { OAuthClientModel } from './oauth-client' | ||
24 | |||
25 | export type OAuthTokenInfo = { | ||
26 | refreshToken: string | ||
27 | refreshTokenExpiresAt: Date | ||
28 | client: { | ||
29 | id: number | ||
30 | } | ||
31 | user: MUserAccountId | ||
32 | token: MOAuthTokenUser | ||
33 | } | ||
34 | |||
35 | enum 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 | }) | ||
82 | export 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 @@ | |||
1 | import { sample } from 'lodash' | ||
2 | import { literal, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize' | ||
3 | import { | ||
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' | ||
17 | import { getServerActor } from '@server/models/application/application' | ||
18 | import { MActor, MVideoForRedundancyAPI, MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/types/models' | ||
19 | import { | ||
20 | CacheFileObject, | ||
21 | FileRedundancyInformation, | ||
22 | StreamingPlaylistRedundancyInformation, | ||
23 | VideoPrivacy, | ||
24 | VideoRedundanciesTarget, | ||
25 | VideoRedundancy, | ||
26 | VideoRedundancyStrategy, | ||
27 | VideoRedundancyStrategyWithManual | ||
28 | } from '@shared/models' | ||
29 | import { AttributesOnly } from '@shared/typescript-utils' | ||
30 | import { isTestInstance } from '../../helpers/core-utils' | ||
31 | import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
32 | import { logger } from '../../helpers/logger' | ||
33 | import { CONFIG } from '../../initializers/config' | ||
34 | import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants' | ||
35 | import { ActorModel } from '../actor/actor' | ||
36 | import { ServerModel } from '../server/server' | ||
37 | import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../shared' | ||
38 | import { ScheduleVideoUpdateModel } from '../video/schedule-video-update' | ||
39 | import { VideoModel } from '../video/video' | ||
40 | import { VideoChannelModel } from '../video/video-channel' | ||
41 | import { VideoFileModel } from '../video/video-file' | ||
42 | import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist' | ||
43 | |||
44 | export 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 | }) | ||
93 | export 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 @@ | |||
1 | import { Op, Transaction } from 'sequelize' | ||
2 | import { | ||
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' | ||
16 | import { isArray, isUUIDValid } from '@server/helpers/custom-validators/misc' | ||
17 | import { CONSTRAINTS_FIELDS, RUNNER_JOB_STATES } from '@server/initializers/constants' | ||
18 | import { MRunnerJob, MRunnerJobRunner, MRunnerJobRunnerParent } from '@server/types/models/runners' | ||
19 | import { RunnerJob, RunnerJobAdmin, RunnerJobPayload, RunnerJobPrivatePayload, RunnerJobState, RunnerJobType } from '@shared/models' | ||
20 | import { AttributesOnly } from '@shared/typescript-utils' | ||
21 | import { getSort, searchAttribute } from '../shared' | ||
22 | import { RunnerModel } from './runner' | ||
23 | |||
24 | enum 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 | }) | ||
63 | export 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 @@ | |||
1 | import { FindOptions, literal } from 'sequelize' | ||
2 | import { AllowNull, Column, CreatedAt, HasMany, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { MRunnerRegistrationToken } from '@server/types/models/runners' | ||
4 | import { RunnerRegistrationToken } from '@shared/models' | ||
5 | import { AttributesOnly } from '@shared/typescript-utils' | ||
6 | import { getSort } from '../shared' | ||
7 | import { 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 | }) | ||
24 | export 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 @@ | |||
1 | import { FindOptions } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { MRunner } from '@server/types/models/runners' | ||
4 | import { Runner } from '@shared/models' | ||
5 | import { AttributesOnly } from '@shared/typescript-utils' | ||
6 | import { getSort } from '../shared' | ||
7 | import { RunnerRegistrationTokenModel } from './runner-registration-token' | ||
8 | import { 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 | }) | ||
26 | export 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 @@ | |||
1 | import { FindAndCountOptions, json, QueryTypes } from 'sequelize' | ||
2 | import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { MPlugin, MPluginFormattable } from '@server/types/models' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { PeerTubePlugin, PluginType, RegisterServerSettingOptions, SettingEntries, SettingValue } from '../../../shared/models' | ||
6 | import { | ||
7 | isPluginDescriptionValid, | ||
8 | isPluginHomepage, | ||
9 | isPluginNameValid, | ||
10 | isPluginStableOrUnstableVersionValid, | ||
11 | isPluginStableVersionValid, | ||
12 | isPluginTypeValid | ||
13 | } from '../../helpers/custom-validators/plugins' | ||
14 | import { 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 | }) | ||
31 | export 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 @@ | |||
1 | import { Op, QueryTypes } from 'sequelize' | ||
2 | import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models' | ||
4 | import { ServerBlock } from '@shared/models' | ||
5 | import { AttributesOnly } from '@shared/typescript-utils' | ||
6 | import { AccountModel } from '../account/account' | ||
7 | import { createSafeIn, getSort, searchAttribute } from '../shared' | ||
8 | import { ServerModel } from './server' | ||
9 | |||
10 | enum 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 | }) | ||
46 | export 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 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { AllowNull, Column, CreatedAt, Default, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { MServer, MServerFormattable } from '@server/types/models/server' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { isHostValid } from '../../helpers/custom-validators/servers' | ||
6 | import { ActorModel } from '../actor/actor' | ||
7 | import { buildSQLAttributes, throwIfNotValid } from '../shared' | ||
8 | import { ServerBlocklistModel } from './server-blocklist' | ||
9 | |||
10 | @Table({ | ||
11 | tableName: 'server', | ||
12 | indexes: [ | ||
13 | { | ||
14 | fields: [ 'host' ], | ||
15 | unique: true | ||
16 | } | ||
17 | ] | ||
18 | }) | ||
19 | export 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 @@ | |||
1 | import { AllowNull, BelongsToMany, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { Transaction } from 'sequelize/types' | ||
3 | import { MTracker } from '@server/types/models/server/tracker' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { VideoModel } from '../video/video' | ||
6 | import { VideoTrackerModel } from './video-tracker' | ||
7 | |||
8 | @Table({ | ||
9 | tableName: 'tracker', | ||
10 | indexes: [ | ||
11 | { | ||
12 | fields: [ 'url' ], | ||
13 | unique: true | ||
14 | } | ||
15 | ] | ||
16 | }) | ||
17 | export 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 @@ | |||
1 | import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { AttributesOnly } from '@shared/typescript-utils' | ||
3 | import { VideoModel } from '../video/video' | ||
4 | import { TrackerModel } from './tracker' | ||
5 | |||
6 | @Table({ | ||
7 | tableName: 'videoTracker', | ||
8 | indexes: [ | ||
9 | { | ||
10 | fields: [ 'videoId' ] | ||
11 | }, | ||
12 | { | ||
13 | fields: [ 'trackerId' ] | ||
14 | } | ||
15 | ] | ||
16 | }) | ||
17 | export 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 @@ | |||
1 | import { QueryTypes, Sequelize, Transaction } from 'sequelize' | ||
2 | |||
3 | /** | ||
4 | * | ||
5 | * Abstract builder to run video SQL queries | ||
6 | * | ||
7 | */ | ||
8 | |||
9 | export 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 @@ | |||
1 | export * from './abstract-run-query' | ||
2 | export * from './model-builder' | ||
3 | export * from './model-cache' | ||
4 | export * from './query' | ||
5 | export * from './sequelize-helpers' | ||
6 | export * from './sort' | ||
7 | export * from './sql' | ||
8 | export * 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 @@ | |||
1 | import { isPlainObject } from 'lodash' | ||
2 | import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize' | ||
3 | import { 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 | |||
22 | export 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 @@ | |||
1 | import { Model } from 'sequelize-typescript' | ||
2 | import { logger } from '@server/helpers/logger' | ||
3 | |||
4 | type ModelCacheType = | ||
5 | 'local-account-name' | ||
6 | | 'local-actor-name' | ||
7 | | 'local-actor-url' | ||
8 | | 'load-video-immutable-id' | ||
9 | | 'load-video-immutable-url' | ||
10 | |||
11 | type DeleteKey = | ||
12 | 'video' | ||
13 | |||
14 | class 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 | |||
88 | export { | ||
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 @@ | |||
1 | import { BindOrReplacements, Op, QueryTypes, Sequelize } from 'sequelize' | ||
2 | import validator from 'validator' | ||
3 | import { forceNumber } from '@shared/core-utils' | ||
4 | |||
5 | function 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 | |||
16 | function createSimilarityAttribute (col: string, value: string) { | ||
17 | return Sequelize.fn( | ||
18 | 'similarity', | ||
19 | |||
20 | searchTrigramNormalizeCol(col), | ||
21 | |||
22 | searchTrigramNormalizeValue(value) | ||
23 | ) | ||
24 | } | ||
25 | |||
26 | function buildWhereIdOrUUID (id: number | string) { | ||
27 | return validator.isInt('' + id) ? { id } : { uuid: id } | ||
28 | } | ||
29 | |||
30 | function 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 | |||
39 | function parseRowCountResult (result: any) { | ||
40 | if (result.length !== 0) return result[0].total | ||
41 | |||
42 | return 0 | ||
43 | } | ||
44 | |||
45 | function 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 | |||
53 | function 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 | |||
64 | export { | ||
65 | doesExist, | ||
66 | createSimilarityAttribute, | ||
67 | buildWhereIdOrUUID, | ||
68 | parseAggregateResult, | ||
69 | parseRowCountResult, | ||
70 | createSafeIn, | ||
71 | searchAttribute | ||
72 | } | ||
73 | |||
74 | // --------------------------------------------------------------------------- | ||
75 | |||
76 | function searchTrigramNormalizeValue (value: string) { | ||
77 | return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value)) | ||
78 | } | ||
79 | |||
80 | function 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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | |||
3 | function 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 | |||
15 | function 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 | |||
23 | function 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 | |||
35 | export { | ||
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 @@ | |||
1 | import { literal, OrderItem, Sequelize } from 'sequelize' | ||
2 | |||
3 | // Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ] | ||
4 | function 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 | |||
18 | function 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 | |||
37 | function 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 | |||
47 | function 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 | |||
84 | function 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 | |||
99 | function 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 | |||
112 | function 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 | |||
122 | function 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 | |||
137 | export { | ||
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 @@ | |||
1 | import { literal, Model, ModelStatic } from 'sequelize' | ||
2 | import { forceNumber } from '@shared/core-utils' | ||
3 | import { AttributesOnly } from '@shared/typescript-utils' | ||
4 | |||
5 | function 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 | |||
11 | function buildLocalActorIdsIn () { | ||
12 | return literal( | ||
13 | '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)' | ||
14 | ) | ||
15 | } | ||
16 | |||
17 | function 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 | |||
27 | function 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 | |||
37 | function 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 | |||
62 | export { | ||
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 @@ | |||
1 | import { QueryTypes, Sequelize, Transaction } from 'sequelize' | ||
2 | |||
3 | const updating = new Set<string>() | ||
4 | |||
5 | // Sequelize always skip the update if we only update updatedAt field | ||
6 | async 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 | |||
32 | export { | ||
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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | import { AbstractRunQuery, ModelBuilder } from '@server/models/shared' | ||
3 | import { UserNotificationModelForApi } from '@server/types/models' | ||
4 | import { ActorImageType } from '@shared/models' | ||
5 | import { getSort } from '../../shared' | ||
6 | |||
7 | export interface ListNotificationsOptions { | ||
8 | userId: number | ||
9 | unread?: boolean | ||
10 | sort: string | ||
11 | offset: number | ||
12 | limit: number | ||
13 | } | ||
14 | |||
15 | export 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 @@ | |||
1 | import { | ||
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' | ||
15 | import { TokensCache } from '@server/lib/auth/tokens-cache' | ||
16 | import { MNotificationSettingFormattable } from '@server/types/models' | ||
17 | import { AttributesOnly } from '@shared/typescript-utils' | ||
18 | import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model' | ||
19 | import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications' | ||
20 | import { throwIfNotValid } from '../shared' | ||
21 | import { UserModel } from './user' | ||
22 | |||
23 | @Table({ | ||
24 | tableName: 'userNotificationSetting', | ||
25 | indexes: [ | ||
26 | { | ||
27 | fields: [ 'userId' ], | ||
28 | unique: true | ||
29 | } | ||
30 | ] | ||
31 | }) | ||
32 | export 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 @@ | |||
1 | import { ModelIndexesOptions, Op, WhereOptions } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { getBiggestActorImage } from '@server/lib/actor-image' | ||
4 | import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user' | ||
5 | import { forceNumber } from '@shared/core-utils' | ||
6 | import { uuidToShort } from '@shared/extra-utils' | ||
7 | import { UserNotification, UserNotificationType } from '@shared/models' | ||
8 | import { AttributesOnly } from '@shared/typescript-utils' | ||
9 | import { isBooleanValid } from '../../helpers/custom-validators/misc' | ||
10 | import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications' | ||
11 | import { AbuseModel } from '../abuse/abuse' | ||
12 | import { AccountModel } from '../account/account' | ||
13 | import { ActorFollowModel } from '../actor/actor-follow' | ||
14 | import { ApplicationModel } from '../application/application' | ||
15 | import { PluginModel } from '../server/plugin' | ||
16 | import { throwIfNotValid } from '../shared' | ||
17 | import { VideoModel } from '../video/video' | ||
18 | import { VideoBlacklistModel } from '../video/video-blacklist' | ||
19 | import { VideoCommentModel } from '../video/video-comment' | ||
20 | import { VideoImportModel } from '../video/video-import' | ||
21 | import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder' | ||
22 | import { UserModel } from './user' | ||
23 | import { 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 | }) | ||
113 | export 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 @@ | |||
1 | import { FindOptions, Op, WhereOptions } from 'sequelize' | ||
2 | import { | ||
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' | ||
16 | import { | ||
17 | isRegistrationModerationResponseValid, | ||
18 | isRegistrationReasonValid, | ||
19 | isRegistrationStateValid | ||
20 | } from '@server/helpers/custom-validators/user-registration' | ||
21 | import { isVideoChannelDisplayNameValid } from '@server/helpers/custom-validators/video-channels' | ||
22 | import { cryptPassword } from '@server/helpers/peertube-crypto' | ||
23 | import { USER_REGISTRATION_STATES } from '@server/initializers/constants' | ||
24 | import { MRegistration, MRegistrationFormattable } from '@server/types/models' | ||
25 | import { UserRegistration, UserRegistrationState } from '@shared/models' | ||
26 | import { AttributesOnly } from '@shared/typescript-utils' | ||
27 | import { isUserDisplayNameValid, isUserEmailVerifiedValid, isUserPasswordValid } from '../../helpers/custom-validators/users' | ||
28 | import { getSort, throwIfNotValid } from '../shared' | ||
29 | import { 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 | }) | ||
52 | export 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 @@ | |||
1 | import { DestroyOptions, Op, Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { MUserAccountId, MUserId } from '@server/types/models' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { VideoModel } from '../video/video' | ||
6 | import { 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 | }) | ||
23 | export 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 @@ | |||
1 | import { col, FindOptions, fn, literal, Op, QueryTypes, where, WhereOptions } from 'sequelize' | ||
2 | import { | ||
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' | ||
23 | import { TokensCache } from '@server/lib/auth/tokens-cache' | ||
24 | import { LiveQuotaStore } from '@server/lib/live' | ||
25 | import { | ||
26 | MMyUserFormattable, | ||
27 | MUser, | ||
28 | MUserDefault, | ||
29 | MUserFormattable, | ||
30 | MUserNotifSettingChannelDefault, | ||
31 | MUserWithNotificationSetting | ||
32 | } from '@server/types/models' | ||
33 | import { forceNumber } from '@shared/core-utils' | ||
34 | import { AttributesOnly } from '@shared/typescript-utils' | ||
35 | import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users' | ||
36 | import { AbuseState, MyUser, UserRight, VideoPlaylistType } from '../../../shared/models' | ||
37 | import { User, UserRole } from '../../../shared/models/users' | ||
38 | import { UserAdminFlag } from '../../../shared/models/users/user-flag.model' | ||
39 | import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type' | ||
40 | import { isThemeNameValid } from '../../helpers/custom-validators/plugins' | ||
41 | import { | ||
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' | ||
59 | import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto' | ||
60 | import { DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants' | ||
61 | import { getThemeOrDefault } from '../../lib/plugins/theme-utils' | ||
62 | import { AccountModel } from '../account/account' | ||
63 | import { ActorModel } from '../actor/actor' | ||
64 | import { ActorFollowModel } from '../actor/actor-follow' | ||
65 | import { ActorImageModel } from '../actor/actor-image' | ||
66 | import { OAuthTokenModel } from '../oauth/oauth-token' | ||
67 | import { getAdminUsersSort, throwIfNotValid } from '../shared' | ||
68 | import { VideoModel } from '../video/video' | ||
69 | import { VideoChannelModel } from '../video/video-channel' | ||
70 | import { VideoImportModel } from '../video/video-import' | ||
71 | import { VideoLiveModel } from '../video/video-live' | ||
72 | import { VideoPlaylistModel } from '../video/video-playlist' | ||
73 | import { UserNotificationSettingModel } from './user-notification-setting' | ||
74 | |||
75 | enum 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 | }) | ||
255 | export 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 | |||
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 @@ | |||
1 | export * from './video-activity-pub-format' | ||
2 | export * 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 @@ | |||
1 | export * 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 @@ | |||
1 | import { MVideoFile } from '@server/types/models' | ||
2 | |||
3 | export 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 @@ | |||
1 | import { isArray } from '@server/helpers/custom-validators/misc' | ||
2 | import { generateMagnetUri } from '@server/helpers/webtorrent' | ||
3 | import { getActivityStreamDuration } from '@server/lib/activitypub/activity' | ||
4 | import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls' | ||
5 | import { | ||
6 | ActivityIconObject, | ||
7 | ActivityPlaylistUrlObject, | ||
8 | ActivityPubStoryboard, | ||
9 | ActivityTagObject, | ||
10 | ActivityTrackerUrlObject, | ||
11 | ActivityUrlObject, | ||
12 | VideoObject | ||
13 | } from '@shared/models' | ||
14 | import { MIMETYPES, WEBSERVER } from '../../../initializers/constants' | ||
15 | import { | ||
16 | getLocalVideoCommentsActivityPubUrl, | ||
17 | getLocalVideoDislikesActivityPubUrl, | ||
18 | getLocalVideoLikesActivityPubUrl, | ||
19 | getLocalVideoSharesActivityPubUrl | ||
20 | } from '../../../lib/activitypub/url' | ||
21 | import { MStreamingPlaylistFiles, MUserId, MVideo, MVideoAP, MVideoFile } from '../../../types/models' | ||
22 | import { VideoCaptionModel } from '../video-caption' | ||
23 | import { sortByResolutionDesc } from './shared' | ||
24 | import { getCategoryLabel, getLanguageLabel, getLicenceLabel } from './video-api-format' | ||
25 | |||
26 | export 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 | |||
118 | function 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 | |||
136 | function 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 | |||
163 | function 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 | |||
220 | function 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 | |||
232 | function 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 | |||
249 | function 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 | |||
267 | function 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 | |||
276 | function 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 | |||
287 | function 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 @@ | |||
1 | import { generateMagnetUri } from '@server/helpers/webtorrent' | ||
2 | import { tracer } from '@server/lib/opentelemetry/tracing' | ||
3 | import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls' | ||
4 | import { VideoViewsManager } from '@server/lib/views/video-views-manager' | ||
5 | import { uuidToShort } from '@shared/extra-utils' | ||
6 | import { | ||
7 | Video, | ||
8 | VideoAdditionalAttributes, | ||
9 | VideoDetails, | ||
10 | VideoFile, | ||
11 | VideoInclude, | ||
12 | VideosCommonQueryAfterSanitize, | ||
13 | VideoStreamingPlaylist | ||
14 | } from '@shared/models' | ||
15 | import { isArray } from '../../../helpers/custom-validators/misc' | ||
16 | import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, VIDEO_STATES } from '../../../initializers/constants' | ||
17 | import { MServer, MStreamingPlaylistRedundanciesOpt, MVideoFormattable, MVideoFormattableDetails } from '../../../types/models' | ||
18 | import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file' | ||
19 | import { sortByResolutionDesc } from './shared' | ||
20 | |||
21 | export 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 | |||
34 | export 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 | |||
51 | export 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 | |||
125 | export 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 | |||
166 | export 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 | |||
188 | export 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 | |||
237 | export function getCategoryLabel (id: number) { | ||
238 | return VIDEO_CATEGORIES[id] || 'Unknown' | ||
239 | } | ||
240 | |||
241 | export function getLicenceLabel (id: number) { | ||
242 | return VIDEO_LICENCES[id] || 'Unknown' | ||
243 | } | ||
244 | |||
245 | export function getLanguageLabel (id: string) { | ||
246 | return VIDEO_LANGUAGES[id] || 'Unknown' | ||
247 | } | ||
248 | |||
249 | export function getPrivacyLabel (id: number) { | ||
250 | return VIDEO_PRIVACIES[id] || 'Unknown' | ||
251 | } | ||
252 | |||
253 | export function getStateLabel (id: number) { | ||
254 | return VIDEO_STATES[id] || 'Unknown' | ||
255 | } | ||
256 | |||
257 | // --------------------------------------------------------------------------- | ||
258 | // Private | ||
259 | // --------------------------------------------------------------------------- | ||
260 | |||
261 | function 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 @@ | |||
1 | import { Op, Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { MScheduleVideoUpdateFormattable, MScheduleVideoUpdate } from '@server/types/models' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { VideoPrivacy } from '../../../shared/models/videos' | ||
6 | import { 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 | }) | ||
20 | export 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 @@ | |||
1 | import { Model, Sequelize, Transaction } from 'sequelize' | ||
2 | import { AbstractRunQuery, ModelBuilder } from '@server/models/shared' | ||
3 | import { ActorImageType, VideoPrivacy } from '@shared/models' | ||
4 | import { createSafeIn, getSort, parseRowCountResult } from '../../../shared' | ||
5 | import { VideoCommentTableAttributes } from './video-comment-table-attributes' | ||
6 | |||
7 | export 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 | |||
37 | export 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 @@ | |||
1 | import { Memoize } from '@server/helpers/memoize' | ||
2 | import { AccountModel } from '@server/models/account/account' | ||
3 | import { ActorModel } from '@server/models/actor/actor' | ||
4 | import { ActorImageModel } from '@server/models/actor/actor-image' | ||
5 | import { ServerModel } from '@server/models/server/server' | ||
6 | import { VideoCommentModel } from '../../video-comment' | ||
7 | |||
8 | export 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 @@ | |||
1 | export * from './video-model-get-query-builder' | ||
2 | export * from './videos-id-list-query-builder' | ||
3 | export * 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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | import validator from 'validator' | ||
3 | import { MUserAccountId } from '@server/types/models' | ||
4 | import { ActorImageType } from '@shared/models' | ||
5 | import { AbstractRunQuery } from '../../../../shared/abstract-run-query' | ||
6 | import { createSafeIn } from '../../../../shared' | ||
7 | import { VideoTableAttributes } from './video-table-attributes' | ||
8 | |||
9 | /** | ||
10 | * | ||
11 | * Abstract builder to create SQL query and fetch video models | ||
12 | * | ||
13 | */ | ||
14 | |||
15 | export 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 @@ | |||
1 | import { Sequelize, Transaction } from 'sequelize' | ||
2 | import { AbstractVideoQueryBuilder } from './abstract-video-query-builder' | ||
3 | |||
4 | export 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 | |||
21 | export 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 | |||
2 | import { AccountModel } from '@server/models/account/account' | ||
3 | import { AccountBlocklistModel } from '@server/models/account/account-blocklist' | ||
4 | import { ActorModel } from '@server/models/actor/actor' | ||
5 | import { ActorImageModel } from '@server/models/actor/actor-image' | ||
6 | import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy' | ||
7 | import { ServerModel } from '@server/models/server/server' | ||
8 | import { ServerBlocklistModel } from '@server/models/server/server-blocklist' | ||
9 | import { TrackerModel } from '@server/models/server/tracker' | ||
10 | import { UserVideoHistoryModel } from '@server/models/user/user-video-history' | ||
11 | import { VideoInclude } from '@shared/models' | ||
12 | import { ScheduleVideoUpdateModel } from '../../../schedule-video-update' | ||
13 | import { TagModel } from '../../../tag' | ||
14 | import { ThumbnailModel } from '../../../thumbnail' | ||
15 | import { VideoModel } from '../../../video' | ||
16 | import { VideoBlacklistModel } from '../../../video-blacklist' | ||
17 | import { VideoChannelModel } from '../../../video-channel' | ||
18 | import { VideoFileModel } from '../../../video-file' | ||
19 | import { VideoLiveModel } from '../../../video-live' | ||
20 | import { VideoStreamingPlaylistModel } from '../../../video-streaming-playlist' | ||
21 | import { VideoTableAttributes } from './video-table-attributes' | ||
22 | |||
23 | type SQLRow = { [id: string]: string | number } | ||
24 | |||
25 | /** | ||
26 | * | ||
27 | * Build video models from SQL rows | ||
28 | * | ||
29 | */ | ||
30 | |||
31 | export 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 | */ | ||
7 | export 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 @@ | |||
1 | import { Sequelize, Transaction } from 'sequelize' | ||
2 | import { pick } from '@shared/core-utils' | ||
3 | import { AbstractVideoQueryBuilder } from './shared/abstract-video-query-builder' | ||
4 | import { VideoFileQueryBuilder } from './shared/video-file-query-builder' | ||
5 | import { VideoModelBuilder } from './shared/video-model-builder' | ||
6 | import { 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 | |||
14 | export type GetType = | ||
15 | 'api' | | ||
16 | 'full' | | ||
17 | 'account-blacklist-files' | | ||
18 | 'all-files' | | ||
19 | 'thumbnails' | | ||
20 | 'thumbnails-blacklist' | | ||
21 | 'id' | | ||
22 | 'blacklist-rights' | ||
23 | |||
24 | export 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 | |||
36 | export 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 | |||
92 | export 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 @@ | |||
1 | import { Sequelize, Transaction } from 'sequelize' | ||
2 | import validator from 'validator' | ||
3 | import { exists } from '@server/helpers/custom-validators/misc' | ||
4 | import { WEBSERVER } from '@server/initializers/constants' | ||
5 | import { buildSortDirectionAndField } from '@server/models/shared' | ||
6 | import { MUserAccountId, MUserId } from '@server/types/models' | ||
7 | import { forceNumber } from '@shared/core-utils' | ||
8 | import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models' | ||
9 | import { createSafeIn, parseRowCountResult } from '../../../shared' | ||
10 | import { AbstractRunQuery } from '../../../shared/abstract-run-query' | ||
11 | |||
12 | /** | ||
13 | * | ||
14 | * Build videos list SQL query to fetch rows | ||
15 | * | ||
16 | */ | ||
17 | |||
18 | export type DisplayOnlyForFollowerOptions = { | ||
19 | actorId: number | ||
20 | orLocalVideos: boolean | ||
21 | } | ||
22 | |||
23 | export 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 | |||
87 | export 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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | import { pick } from '@shared/core-utils' | ||
3 | import { VideoInclude } from '@shared/models' | ||
4 | import { AbstractVideoQueryBuilder } from './shared/abstract-video-query-builder' | ||
5 | import { VideoFileQueryBuilder } from './shared/video-file-query-builder' | ||
6 | import { VideoModelBuilder } from './shared/video-model-builder' | ||
7 | import { BuildVideosListQueryOptions, VideosIdListQueryBuilder } from './videos-id-list-query-builder' | ||
8 | |||
9 | /** | ||
10 | * | ||
11 | * Build videos list SQL query and create video models | ||
12 | * | ||
13 | */ | ||
14 | |||
15 | export 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 @@ | |||
1 | import { remove } from 'fs-extra' | ||
2 | import { join } from 'path' | ||
3 | import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
4 | import { CONFIG } from '@server/initializers/config' | ||
5 | import { MStoryboard, MStoryboardVideo, MVideo } from '@server/types/models' | ||
6 | import { Storyboard } from '@shared/models' | ||
7 | import { AttributesOnly } from '@shared/typescript-utils' | ||
8 | import { logger } from '../../helpers/logger' | ||
9 | import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initializers/constants' | ||
10 | import { VideoModel } from './video' | ||
11 | import { 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 | }) | ||
26 | export 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 @@ | |||
1 | import { col, fn, QueryTypes, Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { MTag } from '@server/types/models' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { VideoPrivacy, VideoState } from '../../../shared/models/videos' | ||
6 | import { isVideoTagValid } from '../../helpers/custom-validators/videos' | ||
7 | import { throwIfNotValid } from '../shared' | ||
8 | import { VideoModel } from './video' | ||
9 | import { 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 | }) | ||
25 | export 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 @@ | |||
1 | import { remove } from 'fs-extra' | ||
2 | import { join } from 'path' | ||
3 | import { | ||
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' | ||
18 | import { afterCommitIfTransaction } from '@server/helpers/database-utils' | ||
19 | import { MThumbnail, MThumbnailVideo, MVideo } from '@server/types/models' | ||
20 | import { AttributesOnly } from '@shared/typescript-utils' | ||
21 | import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' | ||
22 | import { logger } from '../../helpers/logger' | ||
23 | import { CONFIG } from '../../initializers/config' | ||
24 | import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initializers/constants' | ||
25 | import { VideoModel } from './video' | ||
26 | import { 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 | }) | ||
44 | export 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 @@ | |||
1 | import { FindOptions } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { MVideoBlacklist, MVideoBlacklistFormattable } from '@server/types/models' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos' | ||
6 | import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' | ||
7 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' | ||
8 | import { getBlacklistSort, searchAttribute, throwIfNotValid } from '../shared' | ||
9 | import { ThumbnailModel } from './thumbnail' | ||
10 | import { VideoModel } from './video' | ||
11 | import { 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 | }) | ||
22 | export 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 @@ | |||
1 | import { remove } from 'fs-extra' | ||
2 | import { join } from 'path' | ||
3 | import { Op, OrderItem, Transaction } from 'sequelize' | ||
4 | import { | ||
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' | ||
18 | import { MVideo, MVideoCaption, MVideoCaptionFormattable, MVideoCaptionLanguageUrl, MVideoCaptionVideo } from '@server/types/models' | ||
19 | import { buildUUID } from '@shared/extra-utils' | ||
20 | import { AttributesOnly } from '@shared/typescript-utils' | ||
21 | import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' | ||
22 | import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' | ||
23 | import { logger } from '../../helpers/logger' | ||
24 | import { CONFIG } from '../../initializers/config' | ||
25 | import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants' | ||
26 | import { buildWhereIdOrUUID, throwIfNotValid } from '../shared' | ||
27 | import { VideoModel } from './video' | ||
28 | |||
29 | export 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 | }) | ||
61 | export 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 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@server/types/models/video/video-change-ownership' | ||
3 | import { AttributesOnly } from '@shared/typescript-utils' | ||
4 | import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos' | ||
5 | import { AccountModel } from '../account/account' | ||
6 | import { getSort } from '../shared' | ||
7 | import { ScopeNames as VideoScopeNames, VideoModel } from './video' | ||
8 | |||
9 | enum 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 | })) | ||
57 | export 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 @@ | |||
1 | import { Op } from 'sequelize' | ||
2 | import { | ||
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' | ||
16 | import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc' | ||
17 | import { isVideoChannelSyncStateValid } from '@server/helpers/custom-validators/video-channel-syncs' | ||
18 | import { CONSTRAINTS_FIELDS, VIDEO_CHANNEL_SYNC_STATE } from '@server/initializers/constants' | ||
19 | import { MChannelSync, MChannelSyncChannel, MChannelSyncFormattable } from '@server/types/models' | ||
20 | import { VideoChannelSync, VideoChannelSyncState } from '@shared/models' | ||
21 | import { AttributesOnly } from '@shared/typescript-utils' | ||
22 | import { AccountModel } from '../account/account' | ||
23 | import { UserModel } from '../user/user' | ||
24 | import { getChannelSyncSort, throwIfNotValid } from '../shared' | ||
25 | import { 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 | }) | ||
43 | export 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 @@ | |||
1 | import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize' | ||
2 | import { | ||
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' | ||
23 | import { CONFIG } from '@server/initializers/config' | ||
24 | import { InternalEventEmitter } from '@server/lib/internal-event-emitter' | ||
25 | import { MAccountHost } from '@server/types/models' | ||
26 | import { forceNumber, pick } from '@shared/core-utils' | ||
27 | import { AttributesOnly } from '@shared/typescript-utils' | ||
28 | import { ActivityPubActor } from '../../../shared/models/activitypub' | ||
29 | import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos' | ||
30 | import { | ||
31 | isVideoChannelDescriptionValid, | ||
32 | isVideoChannelDisplayNameValid, | ||
33 | isVideoChannelSupportValid | ||
34 | } from '../../helpers/custom-validators/video-channels' | ||
35 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' | ||
36 | import { sendDeleteActor } from '../../lib/activitypub/send' | ||
37 | import { | ||
38 | MChannel, | ||
39 | MChannelActor, | ||
40 | MChannelAP, | ||
41 | MChannelBannerAccountDefault, | ||
42 | MChannelFormattable, | ||
43 | MChannelHost, | ||
44 | MChannelSummaryFormattable | ||
45 | } from '../../types/models/video' | ||
46 | import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account' | ||
47 | import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor' | ||
48 | import { ActorFollowModel } from '../actor/actor-follow' | ||
49 | import { ActorImageModel } from '../actor/actor-image' | ||
50 | import { ServerModel } from '../server/server' | ||
51 | import { | ||
52 | buildServerIdsFollowedBy, | ||
53 | buildTrigramSearchIndex, | ||
54 | createSimilarityAttribute, | ||
55 | getSort, | ||
56 | setAsUpdated, | ||
57 | throwIfNotValid | ||
58 | } from '../shared' | ||
59 | import { VideoModel } from './video' | ||
60 | import { VideoPlaylistModel } from './video-playlist' | ||
61 | |||
62 | export 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 | |||
72 | type AvailableForListOptions = { | ||
73 | actorId: number | ||
74 | search?: string | ||
75 | host?: string | ||
76 | handles?: string[] | ||
77 | forCount?: boolean | ||
78 | } | ||
79 | |||
80 | type AvailableWithStatsOptions = { | ||
81 | daysPrior: number | ||
82 | } | ||
83 | |||
84 | export 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 | }) | ||
357 | export 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 @@ | |||
1 | import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize' | ||
2 | import { | ||
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' | ||
16 | import { getServerActor } from '@server/models/application/application' | ||
17 | import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' | ||
18 | import { pick, uniqify } from '@shared/core-utils' | ||
19 | import { AttributesOnly } from '@shared/typescript-utils' | ||
20 | import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' | ||
21 | import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' | ||
22 | import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/comment/video-comment.model' | ||
23 | import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' | ||
24 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
25 | import { regexpCapture } from '../../helpers/regexp' | ||
26 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' | ||
27 | import { | ||
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' | ||
40 | import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' | ||
41 | import { AccountModel } from '../account/account' | ||
42 | import { ActorModel } from '../actor/actor' | ||
43 | import { buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared' | ||
44 | import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder' | ||
45 | import { VideoModel } from './video' | ||
46 | import { VideoChannelModel } from './video-channel' | ||
47 | |||
48 | export 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 | }) | ||
114 | export 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 @@ | |||
1 | import { remove } from 'fs-extra' | ||
2 | import memoizee from 'memoizee' | ||
3 | import { join } from 'path' | ||
4 | import { FindOptions, Op, Transaction, WhereOptions } from 'sequelize' | ||
5 | import { | ||
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' | ||
21 | import validator from 'validator' | ||
22 | import { logger } from '@server/helpers/logger' | ||
23 | import { extractVideo } from '@server/helpers/video' | ||
24 | import { CONFIG } from '@server/initializers/config' | ||
25 | import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url' | ||
26 | import { | ||
27 | getHLSPrivateFileUrl, | ||
28 | getHLSPublicFileUrl, | ||
29 | getWebVideoPrivateFileUrl, | ||
30 | getWebVideoPublicFileUrl | ||
31 | } from '@server/lib/object-storage' | ||
32 | import { getFSTorrentFilePath } from '@server/lib/paths' | ||
33 | import { isVideoInPrivateDirectory } from '@server/lib/video-privacy' | ||
34 | import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoWithHost } from '@server/types/models' | ||
35 | import { VideoResolution, VideoStorage } from '@shared/models' | ||
36 | import { AttributesOnly } from '@shared/typescript-utils' | ||
37 | import { | ||
38 | isVideoFileExtnameValid, | ||
39 | isVideoFileInfoHashValid, | ||
40 | isVideoFileResolutionValid, | ||
41 | isVideoFileSizeValid, | ||
42 | isVideoFPSResolutionValid | ||
43 | } from '../../helpers/custom-validators/videos' | ||
44 | import { | ||
45 | LAZY_STATIC_PATHS, | ||
46 | MEMOIZE_LENGTH, | ||
47 | MEMOIZE_TTL, | ||
48 | STATIC_DOWNLOAD_PATHS, | ||
49 | STATIC_PATHS, | ||
50 | WEBSERVER | ||
51 | } from '../../initializers/constants' | ||
52 | import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file' | ||
53 | import { VideoRedundancyModel } from '../redundancy/video-redundancy' | ||
54 | import { doesExist, parseAggregateResult, throwIfNotValid } from '../shared' | ||
55 | import { VideoModel } from './video' | ||
56 | import { VideoStreamingPlaylistModel } from './video-streaming-playlist' | ||
57 | |||
58 | export 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 | }) | ||
160 | export 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 @@ | |||
1 | import { IncludeOptions, Op, WhereOptions } from 'sequelize' | ||
2 | import { | ||
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' | ||
17 | import { afterCommitIfTransaction } from '@server/helpers/database-utils' | ||
18 | import { MVideoImportDefault, MVideoImportFormattable } from '@server/types/models/video/video-import' | ||
19 | import { VideoImport, VideoImportState } from '@shared/models' | ||
20 | import { AttributesOnly } from '@shared/typescript-utils' | ||
21 | import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports' | ||
22 | import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos' | ||
23 | import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants' | ||
24 | import { UserModel } from '../user/user' | ||
25 | import { getSort, searchAttribute, throwIfNotValid } from '../shared' | ||
26 | import { ScopeNames as VideoModelScopeNames, VideoModel } from './video' | ||
27 | import { VideoChannelSyncModel } from './video-channel-sync' | ||
28 | |||
29 | const 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 | }) | ||
66 | export 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 @@ | |||
1 | import { Op, QueryTypes, Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, IsInt, Model, Table, Unique, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { forceNumber } from '@shared/core-utils' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { VideoModel } from './video' | ||
6 | |||
7 | export 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 | |||
23 | export 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 @@ | |||
1 | import { isVideoPrivacyValid } from '@server/helpers/custom-validators/videos' | ||
2 | import { MLiveReplaySetting } from '@server/types/models/video/video-live-replay-setting' | ||
3 | import { VideoPrivacy } from '@shared/models/videos/video-privacy.enum' | ||
4 | import { Transaction } from 'sequelize' | ||
5 | import { AllowNull, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
6 | import { throwIfNotValid } from '../shared/sequelize-helpers' | ||
7 | |||
8 | @Table({ | ||
9 | tableName: 'videoLiveReplaySetting' | ||
10 | }) | ||
11 | export 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 @@ | |||
1 | import { FindOptions } from 'sequelize' | ||
2 | import { | ||
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' | ||
15 | import { MVideoLiveSession, MVideoLiveSessionReplay } from '@server/types/models' | ||
16 | import { uuidToShort } from '@shared/extra-utils' | ||
17 | import { LiveVideoError, LiveVideoSession } from '@shared/models' | ||
18 | import { AttributesOnly } from '@shared/typescript-utils' | ||
19 | import { VideoModel } from './video' | ||
20 | import { VideoLiveReplaySettingModel } from './video-live-replay-setting' | ||
21 | |||
22 | export 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 | }) | ||
57 | export 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 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { | ||
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' | ||
15 | import { CONFIG } from '@server/initializers/config' | ||
16 | import { WEBSERVER } from '@server/initializers/constants' | ||
17 | import { MVideoLive, MVideoLiveVideoWithSetting } from '@server/types/models' | ||
18 | import { LiveVideo, LiveVideoLatencyMode, VideoState } from '@shared/models' | ||
19 | import { AttributesOnly } from '@shared/typescript-utils' | ||
20 | import { VideoModel } from './video' | ||
21 | import { VideoBlacklistModel } from './video-blacklist' | ||
22 | import { 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 | }) | ||
55 | export 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 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DefaultScope, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { VideoModel } from './video' | ||
3 | import { AttributesOnly } from '@shared/typescript-utils' | ||
4 | import { ResultList, VideoPassword } from '@shared/models' | ||
5 | import { getSort, throwIfNotValid } from '../shared' | ||
6 | import { FindOptions, Transaction } from 'sequelize' | ||
7 | import { MVideoPassword } from '@server/types/models' | ||
8 | import { isPasswordValid } from '@server/helpers/custom-validators/videos' | ||
9 | import { 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 | }) | ||
28 | export 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 @@ | |||
1 | import { AggregateOptions, Op, ScopeOptions, Sequelize, Transaction } from 'sequelize' | ||
2 | import { | ||
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' | ||
17 | import validator from 'validator' | ||
18 | import { MUserAccountId } from '@server/types/models' | ||
19 | import { | ||
20 | MVideoPlaylistElement, | ||
21 | MVideoPlaylistElementAP, | ||
22 | MVideoPlaylistElementFormattable, | ||
23 | MVideoPlaylistElementVideoUrlPlaylistPrivacy, | ||
24 | MVideoPlaylistVideoThumbnail | ||
25 | } from '@server/types/models/video/video-playlist-element' | ||
26 | import { forceNumber } from '@shared/core-utils' | ||
27 | import { AttributesOnly } from '@shared/typescript-utils' | ||
28 | import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object' | ||
29 | import { VideoPrivacy } from '../../../shared/models/videos' | ||
30 | import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/models/videos/playlist/video-playlist-element.model' | ||
31 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
32 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' | ||
33 | import { AccountModel } from '../account/account' | ||
34 | import { getSort, throwIfNotValid } from '../shared' | ||
35 | import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video' | ||
36 | import { 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 | }) | ||
53 | export 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 @@ | |||
1 | import { join } from 'path' | ||
2 | import { FindOptions, Includeable, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' | ||
3 | import { | ||
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' | ||
20 | import { activityPubCollectionPagination } from '@server/lib/activitypub/collection' | ||
21 | import { MAccountId, MChannelId } from '@server/types/models' | ||
22 | import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils' | ||
23 | import { buildUUID, uuidToShort } from '@shared/extra-utils' | ||
24 | import { ActivityIconObject, PlaylistObject, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType } from '@shared/models' | ||
25 | import { AttributesOnly } from '@shared/typescript-utils' | ||
26 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
27 | import { | ||
28 | isVideoPlaylistDescriptionValid, | ||
29 | isVideoPlaylistNameValid, | ||
30 | isVideoPlaylistPrivacyValid | ||
31 | } from '../../helpers/custom-validators/video-playlists' | ||
32 | import { | ||
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' | ||
41 | import { MThumbnail } from '../../types/models/video/thumbnail' | ||
42 | import { | ||
43 | MVideoPlaylistAccountThumbnail, | ||
44 | MVideoPlaylistAP, | ||
45 | MVideoPlaylistFormattable, | ||
46 | MVideoPlaylistFull, | ||
47 | MVideoPlaylistFullSummary, | ||
48 | MVideoPlaylistSummaryWithElements | ||
49 | } from '../../types/models/video/video-playlist' | ||
50 | import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' | ||
51 | import { ActorModel } from '../actor/actor' | ||
52 | import { | ||
53 | buildServerIdsFollowedBy, | ||
54 | buildTrigramSearchIndex, | ||
55 | buildWhereIdOrUUID, | ||
56 | createSimilarityAttribute, | ||
57 | getPlaylistSort, | ||
58 | isOutdated, | ||
59 | setAsUpdated, | ||
60 | throwIfNotValid | ||
61 | } from '../shared' | ||
62 | import { ThumbnailModel } from './thumbnail' | ||
63 | import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' | ||
64 | import { VideoPlaylistElementModel } from './video-playlist-element' | ||
65 | |||
66 | enum 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 | |||
75 | type 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 | |||
88 | function 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 | }) | ||
282 | export 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 @@ | |||
1 | import { literal, Op, QueryTypes, Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { forceNumber } from '@shared/core-utils' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
6 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' | ||
7 | import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models' | ||
8 | import { MVideoShareActor, MVideoShareFull } from '../../types/models/video' | ||
9 | import { ActorModel } from '../actor/actor' | ||
10 | import { buildLocalActorIdsIn, throwIfNotValid } from '../shared' | ||
11 | import { VideoModel } from './video' | ||
12 | |||
13 | enum 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 | }) | ||
55 | export 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 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { VideoSource } from '@shared/models/videos/video-source' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { getSort } from '../shared' | ||
6 | import { 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 | }) | ||
19 | export 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 @@ | |||
1 | import memoizee from 'memoizee' | ||
2 | import { join } from 'path' | ||
3 | import { Op, Transaction } from 'sequelize' | ||
4 | import { | ||
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' | ||
18 | import { CONFIG } from '@server/initializers/config' | ||
19 | import { getHLSPrivateFileUrl, getHLSPublicFileUrl } from '@server/lib/object-storage' | ||
20 | import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths' | ||
21 | import { isVideoInPrivateDirectory } from '@server/lib/video-privacy' | ||
22 | import { VideoFileModel } from '@server/models/video/video-file' | ||
23 | import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models' | ||
24 | import { sha1 } from '@shared/extra-utils' | ||
25 | import { VideoStorage } from '@shared/models' | ||
26 | import { AttributesOnly } from '@shared/typescript-utils' | ||
27 | import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' | ||
28 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
29 | import { isArrayOf } from '../../helpers/custom-validators/misc' | ||
30 | import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos' | ||
31 | import { | ||
32 | CONSTRAINTS_FIELDS, | ||
33 | MEMOIZE_LENGTH, | ||
34 | MEMOIZE_TTL, | ||
35 | P2P_MEDIA_LOADER_PEER_VERSION, | ||
36 | STATIC_PATHS, | ||
37 | WEBSERVER | ||
38 | } from '../../initializers/constants' | ||
39 | import { VideoRedundancyModel } from '../redundancy/video-redundancy' | ||
40 | import { doesExist, throwIfNotValid } from '../shared' | ||
41 | import { 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 | }) | ||
59 | export 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 @@ | |||
1 | import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { AttributesOnly } from '@shared/typescript-utils' | ||
3 | import { TagModel } from './tag' | ||
4 | import { VideoModel } from './video' | ||
5 | |||
6 | @Table({ | ||
7 | tableName: 'videoTag', | ||
8 | indexes: [ | ||
9 | { | ||
10 | fields: [ 'videoId' ] | ||
11 | }, | ||
12 | { | ||
13 | fields: [ 'tagId' ] | ||
14 | } | ||
15 | ] | ||
16 | }) | ||
17 | export 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 @@ | |||
1 | import Bluebird from 'bluebird' | ||
2 | import { remove } from 'fs-extra' | ||
3 | import { maxBy, minBy } from 'lodash' | ||
4 | import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' | ||
5 | import { | ||
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' | ||
29 | import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video' | ||
30 | import { InternalEventEmitter } from '@server/lib/internal-event-emitter' | ||
31 | import { LiveManager } from '@server/lib/live/live-manager' | ||
32 | import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebVideoObjectStorage } from '@server/lib/object-storage' | ||
33 | import { tracer } from '@server/lib/opentelemetry/tracing' | ||
34 | import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths' | ||
35 | import { Hooks } from '@server/lib/plugins/hooks' | ||
36 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
37 | import { isVideoInPrivateDirectory } from '@server/lib/video-privacy' | ||
38 | import { getServerActor } from '@server/models/application/application' | ||
39 | import { ModelCache } from '@server/models/shared/model-cache' | ||
40 | import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils' | ||
41 | import { uuidToShort } from '@shared/extra-utils' | ||
42 | import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@shared/ffmpeg' | ||
43 | import { | ||
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' | ||
58 | import { AttributesOnly } from '@shared/typescript-utils' | ||
59 | import { peertubeTruncate } from '../../helpers/core-utils' | ||
60 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
61 | import { exists, isArray, isBooleanValid, isUUIDValid } from '../../helpers/custom-validators/misc' | ||
62 | import { | ||
63 | isVideoDescriptionValid, | ||
64 | isVideoDurationValid, | ||
65 | isVideoNameValid, | ||
66 | isVideoPrivacyValid, | ||
67 | isVideoStateValid, | ||
68 | isVideoSupportValid | ||
69 | } from '../../helpers/custom-validators/videos' | ||
70 | import { logger } from '../../helpers/logger' | ||
71 | import { CONFIG } from '../../initializers/config' | ||
72 | import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' | ||
73 | import { sendDeleteVideo } from '../../lib/activitypub/send' | ||
74 | import { | ||
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' | ||
102 | import { MThumbnail } from '../../types/models/video/thumbnail' | ||
103 | import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file' | ||
104 | import { VideoAbuseModel } from '../abuse/video-abuse' | ||
105 | import { AccountModel } from '../account/account' | ||
106 | import { AccountVideoRateModel } from '../account/account-video-rate' | ||
107 | import { ActorModel } from '../actor/actor' | ||
108 | import { ActorImageModel } from '../actor/actor-image' | ||
109 | import { VideoRedundancyModel } from '../redundancy/video-redundancy' | ||
110 | import { ServerModel } from '../server/server' | ||
111 | import { TrackerModel } from '../server/tracker' | ||
112 | import { VideoTrackerModel } from '../server/video-tracker' | ||
113 | import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared' | ||
114 | import { UserModel } from '../user/user' | ||
115 | import { UserVideoHistoryModel } from '../user/user-video-history' | ||
116 | import { VideoViewModel } from '../view/video-view' | ||
117 | import { videoModelToActivityPubObject } from './formatter/video-activity-pub-format' | ||
118 | import { | ||
119 | videoFilesModelToFormattedJSON, | ||
120 | VideoFormattingJSONOptions, | ||
121 | videoModelToFormattedDetailsJSON, | ||
122 | videoModelToFormattedJSON | ||
123 | } from './formatter/video-api-format' | ||
124 | import { ScheduleVideoUpdateModel } from './schedule-video-update' | ||
125 | import { | ||
126 | BuildVideosListQueryOptions, | ||
127 | DisplayOnlyForFollowerOptions, | ||
128 | VideoModelGetQueryBuilder, | ||
129 | VideosIdListQueryBuilder, | ||
130 | VideosModelListQueryBuilder | ||
131 | } from './sql/video' | ||
132 | import { StoryboardModel } from './storyboard' | ||
133 | import { TagModel } from './tag' | ||
134 | import { ThumbnailModel } from './thumbnail' | ||
135 | import { VideoBlacklistModel } from './video-blacklist' | ||
136 | import { VideoCaptionModel } from './video-caption' | ||
137 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' | ||
138 | import { VideoCommentModel } from './video-comment' | ||
139 | import { VideoFileModel } from './video-file' | ||
140 | import { VideoImportModel } from './video-import' | ||
141 | import { VideoJobInfoModel } from './video-job-info' | ||
142 | import { VideoLiveModel } from './video-live' | ||
143 | import { VideoPasswordModel } from './video-password' | ||
144 | import { VideoPlaylistElementModel } from './video-playlist-element' | ||
145 | import { VideoShareModel } from './video-share' | ||
146 | import { VideoSourceModel } from './video-source' | ||
147 | import { VideoStreamingPlaylistModel } from './video-streaming-playlist' | ||
148 | import { VideoTagModel } from './video-tag' | ||
149 | |||
150 | export 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 | |||
163 | export 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 | }) | ||
441 | export 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 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table } from 'sequelize-typescript' | ||
3 | import { MLocalVideoViewerWatchSection } from '@server/types/models' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { LocalVideoViewerModel } from './local-video-viewer' | ||
6 | |||
7 | @Table({ | ||
8 | tableName: 'localVideoViewerWatchSection', | ||
9 | updatedAt: false, | ||
10 | indexes: [ | ||
11 | { | ||
12 | fields: [ 'localVideoViewerId' ] | ||
13 | } | ||
14 | ] | ||
15 | }) | ||
16 | export 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 @@ | |||
1 | import { QueryTypes } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, IsUUID, Model, Table } from 'sequelize-typescript' | ||
3 | import { getActivityStreamDuration } from '@server/lib/activitypub/activity' | ||
4 | import { buildGroupByAndBoundaries } from '@server/lib/timeserie' | ||
5 | import { MLocalVideoViewer, MLocalVideoViewerWithWatchSections, MVideo } from '@server/types/models' | ||
6 | import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric, WatchActionObject } from '@shared/models' | ||
7 | import { AttributesOnly } from '@shared/typescript-utils' | ||
8 | import { VideoModel } from '../video/video' | ||
9 | import { 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 | }) | ||
31 | export 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 @@ | |||
1 | import { literal, Op } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table } from 'sequelize-typescript' | ||
3 | import { AttributesOnly } from '@shared/typescript-utils' | ||
4 | import { 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 | }) | ||
25 | export 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 | } | ||