diff options
Diffstat (limited to 'server/models')
30 files changed, 2454 insertions, 1077 deletions
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts index 6ebe32556..d8a7ce4b4 100644 --- a/server/models/account/account-blocklist.ts +++ b/server/models/account/account-blocklist.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | 1 | import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' |
2 | import { AccountModel } from './account' | 2 | import { AccountModel } from './account' |
3 | import { getSort } from '../utils' | 3 | import { getSort, searchAttribute } from '../utils' |
4 | import { AccountBlock } from '../../../shared/models/blocklist' | 4 | import { AccountBlock } from '../../../shared/models/blocklist' |
5 | import { Op } from 'sequelize' | 5 | import { Op } from 'sequelize' |
6 | import * as Bluebird from 'bluebird' | 6 | import * as Bluebird from 'bluebird' |
@@ -80,7 +80,7 @@ export class AccountBlocklistModel extends Model<AccountBlocklistModel> { | |||
80 | attributes: [ 'accountId', 'id' ], | 80 | attributes: [ 'accountId', 'id' ], |
81 | where: { | 81 | where: { |
82 | accountId: { | 82 | accountId: { |
83 | [Op.in]: accountIds // FIXME: sequelize ANY seems broken | 83 | [Op.in]: accountIds |
84 | }, | 84 | }, |
85 | targetAccountId | 85 | targetAccountId |
86 | }, | 86 | }, |
@@ -111,16 +111,36 @@ export class AccountBlocklistModel extends Model<AccountBlocklistModel> { | |||
111 | return AccountBlocklistModel.findOne(query) | 111 | return AccountBlocklistModel.findOne(query) |
112 | } | 112 | } |
113 | 113 | ||
114 | static listForApi (accountId: number, start: number, count: number, sort: string) { | 114 | static listForApi (parameters: { |
115 | start: number | ||
116 | count: number | ||
117 | sort: string | ||
118 | search?: string | ||
119 | accountId: number | ||
120 | }) { | ||
121 | const { start, count, sort, search, accountId } = parameters | ||
122 | |||
115 | const query = { | 123 | const query = { |
116 | offset: start, | 124 | offset: start, |
117 | limit: count, | 125 | limit: count, |
118 | order: getSort(sort), | 126 | order: getSort(sort) |
119 | where: { | 127 | } |
120 | accountId | 128 | |
121 | } | 129 | const where = { |
130 | accountId | ||
122 | } | 131 | } |
123 | 132 | ||
133 | if (search) { | ||
134 | Object.assign(where, { | ||
135 | [Op.or]: [ | ||
136 | searchAttribute(search, '$BlockedAccount.name$'), | ||
137 | searchAttribute(search, '$BlockedAccount.Actor.url$') | ||
138 | ] | ||
139 | }) | ||
140 | } | ||
141 | |||
142 | Object.assign(query, { where }) | ||
143 | |||
124 | return AccountBlocklistModel | 144 | return AccountBlocklistModel |
125 | .scope([ ScopeNames.WITH_ACCOUNTS ]) | 145 | .scope([ ScopeNames.WITH_ACCOUNTS ]) |
126 | .findAndCountAll<MAccountBlocklistAccounts>(query) | 146 | .findAndCountAll<MAccountBlocklistAccounts>(query) |
diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts index c593595b2..8aeb486d1 100644 --- a/server/models/account/account-video-rate.ts +++ b/server/models/account/account-video-rate.ts | |||
@@ -99,7 +99,7 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> { | |||
99 | static loadByAccountAndVideoOrUrl (accountId: number, videoId: number, url: string, t?: Transaction): Bluebird<MAccountVideoRate> { | 99 | static loadByAccountAndVideoOrUrl (accountId: number, videoId: number, url: string, t?: Transaction): Bluebird<MAccountVideoRate> { |
100 | const options: FindOptions = { | 100 | const options: FindOptions = { |
101 | where: { | 101 | where: { |
102 | [ Op.or]: [ | 102 | [Op.or]: [ |
103 | { | 103 | { |
104 | accountId, | 104 | accountId, |
105 | videoId | 105 | videoId |
@@ -116,10 +116,10 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> { | |||
116 | } | 116 | } |
117 | 117 | ||
118 | static listByAccountForApi (options: { | 118 | static listByAccountForApi (options: { |
119 | start: number, | 119 | start: number |
120 | count: number, | 120 | count: number |
121 | sort: string, | 121 | sort: string |
122 | type?: string, | 122 | type?: string |
123 | accountId: number | 123 | accountId: number |
124 | }) { | 124 | }) { |
125 | const query: FindOptions = { | 125 | const query: FindOptions = { |
@@ -135,7 +135,7 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> { | |||
135 | required: true, | 135 | required: true, |
136 | include: [ | 136 | include: [ |
137 | { | 137 | { |
138 | model: VideoChannelModel.scope({ method: [VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }), | 138 | model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }), |
139 | required: true | 139 | required: true |
140 | } | 140 | } |
141 | ] | 141 | ] |
diff --git a/server/models/account/account.ts b/server/models/account/account.ts index 8a0ffeb63..a0081f259 100644 --- a/server/models/account/account.ts +++ b/server/models/account/account.ts | |||
@@ -32,8 +32,9 @@ import { FindOptions, IncludeOptions, Op, Transaction, WhereOptions } from 'sequ | |||
32 | import { AccountBlocklistModel } from './account-blocklist' | 32 | import { AccountBlocklistModel } from './account-blocklist' |
33 | import { ServerBlocklistModel } from '../server/server-blocklist' | 33 | import { ServerBlocklistModel } from '../server/server-blocklist' |
34 | import { ActorFollowModel } from '../activitypub/actor-follow' | 34 | import { ActorFollowModel } from '../activitypub/actor-follow' |
35 | import { MAccountActor, MAccountDefault, MAccountSummaryFormattable, MAccountFormattable, MAccountAP } from '../../typings/models' | 35 | import { MAccountActor, MAccountAP, MAccountDefault, MAccountFormattable, MAccountSummaryFormattable } from '../../typings/models' |
36 | import * as Bluebird from 'bluebird' | 36 | import * as Bluebird from 'bluebird' |
37 | import { ModelCache } from '@server/models/model-cache' | ||
37 | 38 | ||
38 | export enum ScopeNames { | 39 | export enum ScopeNames { |
39 | SUMMARY = 'SUMMARY' | 40 | SUMMARY = 'SUMMARY' |
@@ -53,7 +54,7 @@ export type SummaryOptions = { | |||
53 | ] | 54 | ] |
54 | })) | 55 | })) |
55 | @Scopes(() => ({ | 56 | @Scopes(() => ({ |
56 | [ ScopeNames.SUMMARY ]: (options: SummaryOptions = {}) => { | 57 | [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => { |
57 | const whereActor = options.whereActor || undefined | 58 | const whereActor = options.whereActor || undefined |
58 | 59 | ||
59 | const serverInclude: IncludeOptions = { | 60 | const serverInclude: IncludeOptions = { |
@@ -218,8 +219,6 @@ export class AccountModel extends Model<AccountModel> { | |||
218 | }) | 219 | }) |
219 | BlockedAccounts: AccountBlocklistModel[] | 220 | BlockedAccounts: AccountBlocklistModel[] |
220 | 221 | ||
221 | private static cache: { [ id: string ]: any } = {} | ||
222 | |||
223 | @BeforeDestroy | 222 | @BeforeDestroy |
224 | static async sendDeleteIfOwned (instance: AccountModel, options) { | 223 | static async sendDeleteIfOwned (instance: AccountModel, options) { |
225 | if (!instance.Actor) { | 224 | if (!instance.Actor) { |
@@ -247,45 +246,43 @@ export class AccountModel extends Model<AccountModel> { | |||
247 | } | 246 | } |
248 | 247 | ||
249 | static loadLocalByName (name: string): Bluebird<MAccountDefault> { | 248 | static loadLocalByName (name: string): Bluebird<MAccountDefault> { |
250 | // The server actor never change, so we can easily cache it | 249 | const fun = () => { |
251 | if (name === SERVER_ACTOR_NAME && AccountModel.cache[name]) { | 250 | const query = { |
252 | return Bluebird.resolve(AccountModel.cache[name]) | 251 | where: { |
253 | } | 252 | [Op.or]: [ |
254 | 253 | { | |
255 | const query = { | 254 | userId: { |
256 | where: { | 255 | [Op.ne]: null |
257 | [ Op.or ]: [ | 256 | } |
258 | { | 257 | }, |
259 | userId: { | 258 | { |
260 | [ Op.ne ]: null | 259 | applicationId: { |
260 | [Op.ne]: null | ||
261 | } | ||
261 | } | 262 | } |
262 | }, | 263 | ] |
264 | }, | ||
265 | include: [ | ||
263 | { | 266 | { |
264 | applicationId: { | 267 | model: ActorModel, |
265 | [ Op.ne ]: null | 268 | required: true, |
269 | where: { | ||
270 | preferredUsername: name | ||
266 | } | 271 | } |
267 | } | 272 | } |
268 | ] | 273 | ] |
269 | }, | 274 | } |
270 | include: [ | ||
271 | { | ||
272 | model: ActorModel, | ||
273 | required: true, | ||
274 | where: { | ||
275 | preferredUsername: name | ||
276 | } | ||
277 | } | ||
278 | ] | ||
279 | } | ||
280 | 275 | ||
281 | return AccountModel.findOne(query) | 276 | return AccountModel.findOne(query) |
282 | .then(account => { | 277 | } |
283 | if (name === SERVER_ACTOR_NAME) { | ||
284 | AccountModel.cache[name] = account | ||
285 | } | ||
286 | 278 | ||
287 | return account | 279 | return ModelCache.Instance.doCache({ |
288 | }) | 280 | cacheType: 'local-account-name', |
281 | key: name, | ||
282 | fun, | ||
283 | // The server actor never change, so we can easily cache it | ||
284 | whitelist: () => name === SERVER_ACTOR_NAME | ||
285 | }) | ||
289 | } | 286 | } |
290 | 287 | ||
291 | static loadByNameAndHost (name: string, host: string): Bluebird<MAccountDefault> { | 288 | static loadByNameAndHost (name: string, host: string): Bluebird<MAccountDefault> { |
diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts index a05f30175..5a725187a 100644 --- a/server/models/account/user-notification.ts +++ b/server/models/account/user-notification.ts | |||
@@ -363,7 +363,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> { | |||
363 | where: { | 363 | where: { |
364 | userId, | 364 | userId, |
365 | id: { | 365 | id: { |
366 | [Op.in]: notificationIds // FIXME: sequelize ANY seems broken | 366 | [Op.in]: notificationIds |
367 | } | 367 | } |
368 | } | 368 | } |
369 | } | 369 | } |
@@ -379,7 +379,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> { | |||
379 | 379 | ||
380 | toFormattedJSON (this: UserNotificationModelForApi): UserNotification { | 380 | toFormattedJSON (this: UserNotificationModelForApi): UserNotification { |
381 | const video = this.Video | 381 | const video = this.Video |
382 | ? Object.assign(this.formatVideo(this.Video),{ channel: this.formatActor(this.Video.VideoChannel) }) | 382 | ? Object.assign(this.formatVideo(this.Video), { channel: this.formatActor(this.Video.VideoChannel) }) |
383 | : undefined | 383 | : undefined |
384 | 384 | ||
385 | const videoImport = this.VideoImport ? { | 385 | const videoImport = this.VideoImport ? { |
diff --git a/server/models/account/user-video-history.ts b/server/models/account/user-video-history.ts index 3fe4c8db1..522eebeaf 100644 --- a/server/models/account/user-video-history.ts +++ b/server/models/account/user-video-history.ts | |||
@@ -59,7 +59,7 @@ export class UserVideoHistoryModel extends Model<UserVideoHistoryModel> { | |||
59 | return VideoModel.listForApi({ | 59 | return VideoModel.listForApi({ |
60 | start, | 60 | start, |
61 | count, | 61 | count, |
62 | sort: '-UserVideoHistories.updatedAt', | 62 | sort: '-"userVideoHistory"."updatedAt"', |
63 | nsfw: null, // All | 63 | nsfw: null, // All |
64 | includeLocalVideos: true, | 64 | includeLocalVideos: true, |
65 | withFiles: false, | 65 | withFiles: false, |
diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 4c2c5e278..fbd3080c6 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { FindOptions, literal, Op, QueryTypes, where, fn, col } from 'sequelize' | 1 | import { col, FindOptions, fn, literal, Op, QueryTypes, where, WhereOptions } from 'sequelize' |
2 | import { | 2 | import { |
3 | AfterDestroy, | 3 | AfterDestroy, |
4 | AfterUpdate, | 4 | AfterUpdate, |
@@ -19,7 +19,7 @@ import { | |||
19 | Table, | 19 | Table, |
20 | UpdatedAt | 20 | UpdatedAt |
21 | } from 'sequelize-typescript' | 21 | } from 'sequelize-typescript' |
22 | import { hasUserRight, MyUser, USER_ROLE_LABELS, UserRight, VideoPlaylistType, VideoPrivacy } from '../../../shared' | 22 | import { hasUserRight, MyUser, USER_ROLE_LABELS, UserRight, VideoAbuseState, VideoPlaylistType, VideoPrivacy } from '../../../shared' |
23 | import { User, UserRole } from '../../../shared/models/users' | 23 | import { User, UserRole } from '../../../shared/models/users' |
24 | import { | 24 | import { |
25 | isNoInstanceConfigWarningModal, | 25 | isNoInstanceConfigWarningModal, |
@@ -49,7 +49,7 @@ import { VideoPlaylistModel } from '../video/video-playlist' | |||
49 | import { AccountModel } from './account' | 49 | import { AccountModel } from './account' |
50 | import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type' | 50 | import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type' |
51 | import { values } from 'lodash' | 51 | import { values } from 'lodash' |
52 | import { DEFAULT_THEME_NAME, DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants' | 52 | import { DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants' |
53 | import { clearCacheByUserId } from '../../lib/oauth-model' | 53 | import { clearCacheByUserId } from '../../lib/oauth-model' |
54 | import { UserNotificationSettingModel } from './user-notification-setting' | 54 | import { UserNotificationSettingModel } from './user-notification-setting' |
55 | import { VideoModel } from '../video/video' | 55 | import { VideoModel } from '../video/video' |
@@ -71,7 +71,9 @@ import { | |||
71 | } from '@server/typings/models' | 71 | } from '@server/typings/models' |
72 | 72 | ||
73 | enum ScopeNames { | 73 | enum ScopeNames { |
74 | FOR_ME_API = 'FOR_ME_API' | 74 | FOR_ME_API = 'FOR_ME_API', |
75 | WITH_VIDEOCHANNELS = 'WITH_VIDEOCHANNELS', | ||
76 | WITH_STATS = 'WITH_STATS' | ||
75 | } | 77 | } |
76 | 78 | ||
77 | @DefaultScope(() => ({ | 79 | @DefaultScope(() => ({ |
@@ -101,7 +103,7 @@ enum ScopeNames { | |||
101 | required: true, | 103 | required: true, |
102 | where: { | 104 | where: { |
103 | type: { | 105 | type: { |
104 | [ Op.ne ]: VideoPlaylistType.REGULAR | 106 | [Op.ne]: VideoPlaylistType.REGULAR |
105 | } | 107 | } |
106 | } | 108 | } |
107 | } | 109 | } |
@@ -112,6 +114,96 @@ enum ScopeNames { | |||
112 | required: true | 114 | required: true |
113 | } | 115 | } |
114 | ] | 116 | ] |
117 | }, | ||
118 | [ScopeNames.WITH_VIDEOCHANNELS]: { | ||
119 | include: [ | ||
120 | { | ||
121 | model: AccountModel, | ||
122 | include: [ | ||
123 | { | ||
124 | model: VideoChannelModel | ||
125 | }, | ||
126 | { | ||
127 | attributes: [ 'id', 'name', 'type' ], | ||
128 | model: VideoPlaylistModel.unscoped(), | ||
129 | required: true, | ||
130 | where: { | ||
131 | type: { | ||
132 | [Op.ne]: VideoPlaylistType.REGULAR | ||
133 | } | ||
134 | } | ||
135 | } | ||
136 | ] | ||
137 | } | ||
138 | ] | ||
139 | }, | ||
140 | [ScopeNames.WITH_STATS]: { | ||
141 | attributes: { | ||
142 | include: [ | ||
143 | [ | ||
144 | literal( | ||
145 | '(' + | ||
146 | UserModel.generateUserQuotaBaseSQL({ | ||
147 | withSelect: false, | ||
148 | whereUserId: '"UserModel"."id"' | ||
149 | }) + | ||
150 | ')' | ||
151 | ), | ||
152 | 'videoQuotaUsed' | ||
153 | ], | ||
154 | [ | ||
155 | literal( | ||
156 | '(' + | ||
157 | 'SELECT COUNT("video"."id") ' + | ||
158 | 'FROM "video" ' + | ||
159 | 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + | ||
160 | 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' + | ||
161 | 'WHERE "account"."userId" = "UserModel"."id"' + | ||
162 | ')' | ||
163 | ), | ||
164 | 'videosCount' | ||
165 | ], | ||
166 | [ | ||
167 | literal( | ||
168 | '(' + | ||
169 | `SELECT concat_ws(':', "abuses", "acceptedAbuses") ` + | ||
170 | 'FROM (' + | ||
171 | 'SELECT COUNT("videoAbuse"."id") AS "abuses", ' + | ||
172 | `COUNT("videoAbuse"."id") FILTER (WHERE "videoAbuse"."state" = ${VideoAbuseState.ACCEPTED}) AS "acceptedAbuses" ` + | ||
173 | 'FROM "videoAbuse" ' + | ||
174 | 'INNER JOIN "video" ON "videoAbuse"."videoId" = "video"."id" ' + | ||
175 | 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + | ||
176 | 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' + | ||
177 | 'WHERE "account"."userId" = "UserModel"."id"' + | ||
178 | ') t' + | ||
179 | ')' | ||
180 | ), | ||
181 | 'videoAbusesCount' | ||
182 | ], | ||
183 | [ | ||
184 | literal( | ||
185 | '(' + | ||
186 | 'SELECT COUNT("videoAbuse"."id") ' + | ||
187 | 'FROM "videoAbuse" ' + | ||
188 | 'INNER JOIN "account" ON "account"."id" = "videoAbuse"."reporterAccountId" ' + | ||
189 | 'WHERE "account"."userId" = "UserModel"."id"' + | ||
190 | ')' | ||
191 | ), | ||
192 | 'videoAbusesCreatedCount' | ||
193 | ], | ||
194 | [ | ||
195 | literal( | ||
196 | '(' + | ||
197 | 'SELECT COUNT("videoComment"."id") ' + | ||
198 | 'FROM "videoComment" ' + | ||
199 | 'INNER JOIN "account" ON "account"."id" = "videoComment"."accountId" ' + | ||
200 | 'WHERE "account"."userId" = "UserModel"."id"' + | ||
201 | ')' | ||
202 | ), | ||
203 | 'videoCommentsCount' | ||
204 | ] | ||
205 | ] | ||
206 | } | ||
115 | } | 207 | } |
116 | })) | 208 | })) |
117 | @Table({ | 209 | @Table({ |
@@ -129,13 +221,13 @@ enum ScopeNames { | |||
129 | }) | 221 | }) |
130 | export class UserModel extends Model<UserModel> { | 222 | export class UserModel extends Model<UserModel> { |
131 | 223 | ||
132 | @AllowNull(false) | 224 | @AllowNull(true) |
133 | @Is('UserPassword', value => throwIfNotValid(value, isUserPasswordValid, 'user password')) | 225 | @Is('UserPassword', value => throwIfNotValid(value, isUserPasswordValid, 'user password', true)) |
134 | @Column | 226 | @Column |
135 | password: string | 227 | password: string |
136 | 228 | ||
137 | @AllowNull(false) | 229 | @AllowNull(false) |
138 | @Is('UserPassword', value => throwIfNotValid(value, isUserUsernameValid, 'user name')) | 230 | @Is('UserUsername', value => throwIfNotValid(value, isUserUsernameValid, 'user name')) |
139 | @Column | 231 | @Column |
140 | username: string | 232 | username: string |
141 | 233 | ||
@@ -186,7 +278,10 @@ export class UserModel extends Model<UserModel> { | |||
186 | 278 | ||
187 | @AllowNull(false) | 279 | @AllowNull(false) |
188 | @Default(true) | 280 | @Default(true) |
189 | @Is('UserAutoPlayNextVideoPlaylist', value => throwIfNotValid(value, isUserAutoPlayNextVideoPlaylistValid, 'auto play next video for playlists boolean')) | 281 | @Is( |
282 | 'UserAutoPlayNextVideoPlaylist', | ||
283 | value => throwIfNotValid(value, isUserAutoPlayNextVideoPlaylistValid, 'auto play next video for playlists boolean') | ||
284 | ) | ||
190 | @Column | 285 | @Column |
191 | autoPlayNextVideoPlaylist: boolean | 286 | autoPlayNextVideoPlaylist: boolean |
192 | 287 | ||
@@ -230,7 +325,7 @@ export class UserModel extends Model<UserModel> { | |||
230 | videoQuotaDaily: number | 325 | videoQuotaDaily: number |
231 | 326 | ||
232 | @AllowNull(false) | 327 | @AllowNull(false) |
233 | @Default(DEFAULT_THEME_NAME) | 328 | @Default(DEFAULT_USER_THEME_NAME) |
234 | @Is('UserTheme', value => throwIfNotValid(value, isThemeNameValid, 'theme')) | 329 | @Is('UserTheme', value => throwIfNotValid(value, isThemeNameValid, 'theme')) |
235 | @Column | 330 | @Column |
236 | theme: string | 331 | theme: string |
@@ -253,6 +348,16 @@ export class UserModel extends Model<UserModel> { | |||
253 | @Column | 348 | @Column |
254 | noWelcomeModal: boolean | 349 | noWelcomeModal: boolean |
255 | 350 | ||
351 | @AllowNull(true) | ||
352 | @Default(null) | ||
353 | @Column | ||
354 | pluginAuth: string | ||
355 | |||
356 | @AllowNull(true) | ||
357 | @Default(null) | ||
358 | @Column | ||
359 | lastLoginDate: Date | ||
360 | |||
256 | @CreatedAt | 361 | @CreatedAt |
257 | createdAt: Date | 362 | createdAt: Date |
258 | 363 | ||
@@ -288,7 +393,7 @@ export class UserModel extends Model<UserModel> { | |||
288 | @BeforeCreate | 393 | @BeforeCreate |
289 | @BeforeUpdate | 394 | @BeforeUpdate |
290 | static cryptPasswordIfNeeded (instance: UserModel) { | 395 | static cryptPasswordIfNeeded (instance: UserModel) { |
291 | if (instance.changed('password')) { | 396 | if (instance.changed('password') && instance.password) { |
292 | return cryptPassword(instance.password) | 397 | return cryptPassword(instance.password) |
293 | .then(hash => { | 398 | .then(hash => { |
294 | instance.password = hash | 399 | instance.password = hash |
@@ -308,7 +413,8 @@ export class UserModel extends Model<UserModel> { | |||
308 | } | 413 | } |
309 | 414 | ||
310 | static listForApi (start: number, count: number, sort: string, search?: string) { | 415 | static listForApi (start: number, count: number, sort: string, search?: string) { |
311 | let where = undefined | 416 | let where: WhereOptions |
417 | |||
312 | if (search) { | 418 | if (search) { |
313 | where = { | 419 | where = { |
314 | [Op.or]: [ | 420 | [Op.or]: [ |
@@ -319,7 +425,7 @@ export class UserModel extends Model<UserModel> { | |||
319 | }, | 425 | }, |
320 | { | 426 | { |
321 | username: { | 427 | username: { |
322 | [ Op.iLike ]: '%' + search + '%' | 428 | [Op.iLike]: '%' + search + '%' |
323 | } | 429 | } |
324 | } | 430 | } |
325 | ] | 431 | ] |
@@ -332,18 +438,14 @@ export class UserModel extends Model<UserModel> { | |||
332 | [ | 438 | [ |
333 | literal( | 439 | literal( |
334 | '(' + | 440 | '(' + |
335 | 'SELECT COALESCE(SUM("size"), 0) ' + | 441 | UserModel.generateUserQuotaBaseSQL({ |
336 | 'FROM (' + | 442 | withSelect: false, |
337 | 'SELECT MAX("videoFile"."size") AS "size" FROM "videoFile" ' + | 443 | whereUserId: '"UserModel"."id"' |
338 | 'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' + | 444 | }) + |
339 | 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + | ||
340 | 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' + | ||
341 | 'WHERE "account"."userId" = "UserModel"."id" GROUP BY "video"."id"' + | ||
342 | ') t' + | ||
343 | ')' | 445 | ')' |
344 | ), | 446 | ), |
345 | 'videoQuotaUsed' | 447 | 'videoQuotaUsed' |
346 | ] | 448 | ] as any // FIXME: typings |
347 | ] | 449 | ] |
348 | }, | 450 | }, |
349 | offset: start, | 451 | offset: start, |
@@ -353,18 +455,18 @@ export class UserModel extends Model<UserModel> { | |||
353 | } | 455 | } |
354 | 456 | ||
355 | return UserModel.findAndCountAll(query) | 457 | return UserModel.findAndCountAll(query) |
356 | .then(({ rows, count }) => { | 458 | .then(({ rows, count }) => { |
357 | return { | 459 | return { |
358 | data: rows, | 460 | data: rows, |
359 | total: count | 461 | total: count |
360 | } | 462 | } |
361 | }) | 463 | }) |
362 | } | 464 | } |
363 | 465 | ||
364 | static listWithRight (right: UserRight): Bluebird<MUserDefault[]> { | 466 | static listWithRight (right: UserRight): Bluebird<MUserDefault[]> { |
365 | const roles = Object.keys(USER_ROLE_LABELS) | 467 | const roles = Object.keys(USER_ROLE_LABELS) |
366 | .map(k => parseInt(k, 10) as UserRole) | 468 | .map(k => parseInt(k, 10) as UserRole) |
367 | .filter(role => hasUserRight(role, right)) | 469 | .filter(role => hasUserRight(role, right)) |
368 | 470 | ||
369 | const query = { | 471 | const query = { |
370 | where: { | 472 | where: { |
@@ -390,7 +492,7 @@ export class UserModel extends Model<UserModel> { | |||
390 | required: true, | 492 | required: true, |
391 | include: [ | 493 | include: [ |
392 | { | 494 | { |
393 | attributes: [ ], | 495 | attributes: [], |
394 | model: ActorModel.unscoped(), | 496 | model: ActorModel.unscoped(), |
395 | required: true, | 497 | required: true, |
396 | where: { | 498 | where: { |
@@ -398,7 +500,7 @@ export class UserModel extends Model<UserModel> { | |||
398 | }, | 500 | }, |
399 | include: [ | 501 | include: [ |
400 | { | 502 | { |
401 | attributes: [ ], | 503 | attributes: [], |
402 | as: 'ActorFollowings', | 504 | as: 'ActorFollowings', |
403 | model: ActorFollowModel.unscoped(), | 505 | model: ActorFollowModel.unscoped(), |
404 | required: true, | 506 | required: true, |
@@ -426,14 +528,20 @@ export class UserModel extends Model<UserModel> { | |||
426 | return UserModel.findAll(query) | 528 | return UserModel.findAll(query) |
427 | } | 529 | } |
428 | 530 | ||
429 | static loadById (id: number): Bluebird<MUserDefault> { | 531 | static loadById (id: number, withStats = false): Bluebird<MUserDefault> { |
430 | return UserModel.findByPk(id) | 532 | const scopes = [ |
533 | ScopeNames.WITH_VIDEOCHANNELS | ||
534 | ] | ||
535 | |||
536 | if (withStats) scopes.push(ScopeNames.WITH_STATS) | ||
537 | |||
538 | return UserModel.scope(scopes).findByPk(id) | ||
431 | } | 539 | } |
432 | 540 | ||
433 | static loadByUsername (username: string): Bluebird<MUserDefault> { | 541 | static loadByUsername (username: string): Bluebird<MUserDefault> { |
434 | const query = { | 542 | const query = { |
435 | where: { | 543 | where: { |
436 | username: { [ Op.iLike ]: username } | 544 | username: { [Op.iLike]: username } |
437 | } | 545 | } |
438 | } | 546 | } |
439 | 547 | ||
@@ -443,7 +551,7 @@ export class UserModel extends Model<UserModel> { | |||
443 | static loadForMeAPI (username: string): Bluebird<MUserNotifSettingChannelDefault> { | 551 | static loadForMeAPI (username: string): Bluebird<MUserNotifSettingChannelDefault> { |
444 | const query = { | 552 | const query = { |
445 | where: { | 553 | where: { |
446 | username: { [ Op.iLike ]: username } | 554 | username: { [Op.iLike]: username } |
447 | } | 555 | } |
448 | } | 556 | } |
449 | 557 | ||
@@ -465,7 +573,7 @@ export class UserModel extends Model<UserModel> { | |||
465 | 573 | ||
466 | const query = { | 574 | const query = { |
467 | where: { | 575 | where: { |
468 | [ Op.or ]: [ | 576 | [Op.or]: [ |
469 | where(fn('lower', col('username')), fn('lower', username)), | 577 | where(fn('lower', col('username')), fn('lower', username)), |
470 | 578 | ||
471 | { email } | 579 | { email } |
@@ -567,7 +675,10 @@ export class UserModel extends Model<UserModel> { | |||
567 | 675 | ||
568 | static getOriginalVideoFileTotalFromUser (user: MUserId) { | 676 | static getOriginalVideoFileTotalFromUser (user: MUserId) { |
569 | // Don't use sequelize because we need to use a sub query | 677 | // Don't use sequelize because we need to use a sub query |
570 | const query = UserModel.generateUserQuotaBaseSQL() | 678 | const query = UserModel.generateUserQuotaBaseSQL({ |
679 | withSelect: true, | ||
680 | whereUserId: '$userId' | ||
681 | }) | ||
571 | 682 | ||
572 | return UserModel.getTotalRawQuery(query, user.id) | 683 | return UserModel.getTotalRawQuery(query, user.id) |
573 | } | 684 | } |
@@ -575,16 +686,38 @@ export class UserModel extends Model<UserModel> { | |||
575 | // Returns cumulative size of all video files uploaded in the last 24 hours. | 686 | // Returns cumulative size of all video files uploaded in the last 24 hours. |
576 | static getOriginalVideoFileTotalDailyFromUser (user: MUserId) { | 687 | static getOriginalVideoFileTotalDailyFromUser (user: MUserId) { |
577 | // Don't use sequelize because we need to use a sub query | 688 | // Don't use sequelize because we need to use a sub query |
578 | const query = UserModel.generateUserQuotaBaseSQL('"video"."createdAt" > now() - interval \'24 hours\'') | 689 | const query = UserModel.generateUserQuotaBaseSQL({ |
690 | withSelect: true, | ||
691 | whereUserId: '$userId', | ||
692 | where: '"video"."createdAt" > now() - interval \'24 hours\'' | ||
693 | }) | ||
579 | 694 | ||
580 | return UserModel.getTotalRawQuery(query, user.id) | 695 | return UserModel.getTotalRawQuery(query, user.id) |
581 | } | 696 | } |
582 | 697 | ||
583 | static async getStats () { | 698 | static async getStats () { |
699 | function getActiveUsers (days: number) { | ||
700 | const query = { | ||
701 | where: { | ||
702 | [Op.and]: [ | ||
703 | literal(`"lastLoginDate" > NOW() - INTERVAL '${days}d'`) | ||
704 | ] | ||
705 | } | ||
706 | } | ||
707 | |||
708 | return UserModel.count(query) | ||
709 | } | ||
710 | |||
584 | const totalUsers = await UserModel.count() | 711 | const totalUsers = await UserModel.count() |
712 | const totalDailyActiveUsers = await getActiveUsers(1) | ||
713 | const totalWeeklyActiveUsers = await getActiveUsers(7) | ||
714 | const totalMonthlyActiveUsers = await getActiveUsers(30) | ||
585 | 715 | ||
586 | return { | 716 | return { |
587 | totalUsers | 717 | totalUsers, |
718 | totalDailyActiveUsers, | ||
719 | totalWeeklyActiveUsers, | ||
720 | totalMonthlyActiveUsers | ||
588 | } | 721 | } |
589 | } | 722 | } |
590 | 723 | ||
@@ -592,7 +725,7 @@ export class UserModel extends Model<UserModel> { | |||
592 | const query = { | 725 | const query = { |
593 | where: { | 726 | where: { |
594 | username: { | 727 | username: { |
595 | [ Op.like ]: `%${search}%` | 728 | [Op.like]: `%${search}%` |
596 | } | 729 | } |
597 | }, | 730 | }, |
598 | limit: 10 | 731 | limit: 10 |
@@ -633,6 +766,10 @@ export class UserModel extends Model<UserModel> { | |||
633 | toFormattedJSON (this: MUserFormattable, parameters: { withAdminFlags?: boolean } = {}): User { | 766 | toFormattedJSON (this: MUserFormattable, parameters: { withAdminFlags?: boolean } = {}): User { |
634 | const videoQuotaUsed = this.get('videoQuotaUsed') | 767 | const videoQuotaUsed = this.get('videoQuotaUsed') |
635 | const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily') | 768 | const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily') |
769 | const videosCount = this.get('videosCount') | ||
770 | const [ videoAbusesCount, videoAbusesAcceptedCount ] = (this.get('videoAbusesCount') as string || ':').split(':') | ||
771 | const videoAbusesCreatedCount = this.get('videoAbusesCreatedCount') | ||
772 | const videoCommentsCount = this.get('videoCommentsCount') | ||
636 | 773 | ||
637 | const json: User = { | 774 | const json: User = { |
638 | id: this.id, | 775 | id: this.id, |
@@ -652,7 +789,7 @@ export class UserModel extends Model<UserModel> { | |||
652 | videoLanguages: this.videoLanguages, | 789 | videoLanguages: this.videoLanguages, |
653 | 790 | ||
654 | role: this.role, | 791 | role: this.role, |
655 | roleLabel: USER_ROLE_LABELS[ this.role ], | 792 | roleLabel: USER_ROLE_LABELS[this.role], |
656 | 793 | ||
657 | videoQuota: this.videoQuota, | 794 | videoQuota: this.videoQuota, |
658 | videoQuotaDaily: this.videoQuotaDaily, | 795 | videoQuotaDaily: this.videoQuotaDaily, |
@@ -662,6 +799,21 @@ export class UserModel extends Model<UserModel> { | |||
662 | videoQuotaUsedDaily: videoQuotaUsedDaily !== undefined | 799 | videoQuotaUsedDaily: videoQuotaUsedDaily !== undefined |
663 | ? parseInt(videoQuotaUsedDaily + '', 10) | 800 | ? parseInt(videoQuotaUsedDaily + '', 10) |
664 | : undefined, | 801 | : undefined, |
802 | videosCount: videosCount !== undefined | ||
803 | ? parseInt(videosCount + '', 10) | ||
804 | : undefined, | ||
805 | videoAbusesCount: videoAbusesCount | ||
806 | ? parseInt(videoAbusesCount, 10) | ||
807 | : undefined, | ||
808 | videoAbusesAcceptedCount: videoAbusesAcceptedCount | ||
809 | ? parseInt(videoAbusesAcceptedCount, 10) | ||
810 | : undefined, | ||
811 | videoAbusesCreatedCount: videoAbusesCreatedCount !== undefined | ||
812 | ? parseInt(videoAbusesCreatedCount + '', 10) | ||
813 | : undefined, | ||
814 | videoCommentsCount: videoCommentsCount !== undefined | ||
815 | ? parseInt(videoCommentsCount + '', 10) | ||
816 | : undefined, | ||
665 | 817 | ||
666 | noInstanceConfigWarningModal: this.noInstanceConfigWarningModal, | 818 | noInstanceConfigWarningModal: this.noInstanceConfigWarningModal, |
667 | noWelcomeModal: this.noWelcomeModal, | 819 | noWelcomeModal: this.noWelcomeModal, |
@@ -677,7 +829,11 @@ export class UserModel extends Model<UserModel> { | |||
677 | 829 | ||
678 | videoChannels: [], | 830 | videoChannels: [], |
679 | 831 | ||
680 | createdAt: this.createdAt | 832 | createdAt: this.createdAt, |
833 | |||
834 | pluginAuth: this.pluginAuth, | ||
835 | |||
836 | lastLoginDate: this.lastLoginDate | ||
681 | } | 837 | } |
682 | 838 | ||
683 | if (parameters.withAdminFlags) { | 839 | if (parameters.withAdminFlags) { |
@@ -686,13 +842,13 @@ export class UserModel extends Model<UserModel> { | |||
686 | 842 | ||
687 | if (Array.isArray(this.Account.VideoChannels) === true) { | 843 | if (Array.isArray(this.Account.VideoChannels) === true) { |
688 | json.videoChannels = this.Account.VideoChannels | 844 | json.videoChannels = this.Account.VideoChannels |
689 | .map(c => c.toFormattedJSON()) | 845 | .map(c => c.toFormattedJSON()) |
690 | .sort((v1, v2) => { | 846 | .sort((v1, v2) => { |
691 | if (v1.createdAt < v2.createdAt) return -1 | 847 | if (v1.createdAt < v2.createdAt) return -1 |
692 | if (v1.createdAt === v2.createdAt) return 0 | 848 | if (v1.createdAt === v2.createdAt) return 0 |
693 | 849 | ||
694 | return 1 | 850 | return 1 |
695 | }) | 851 | }) |
696 | } | 852 | } |
697 | 853 | ||
698 | return json | 854 | return json |
@@ -702,7 +858,7 @@ export class UserModel extends Model<UserModel> { | |||
702 | const formatted = this.toFormattedJSON() | 858 | const formatted = this.toFormattedJSON() |
703 | 859 | ||
704 | const specialPlaylists = this.Account.VideoPlaylists | 860 | const specialPlaylists = this.Account.VideoPlaylists |
705 | .map(p => ({ id: p.id, name: p.name, type: p.type })) | 861 | .map(p => ({ id: p.id, name: p.name, type: p.type })) |
706 | 862 | ||
707 | return Object.assign(formatted, { specialPlaylists }) | 863 | return Object.assign(formatted, { specialPlaylists }) |
708 | } | 864 | } |
@@ -724,18 +880,33 @@ export class UserModel extends Model<UserModel> { | |||
724 | return uploadedTotal < this.videoQuota && uploadedDaily < this.videoQuotaDaily | 880 | return uploadedTotal < this.videoQuota && uploadedDaily < this.videoQuotaDaily |
725 | } | 881 | } |
726 | 882 | ||
727 | private static generateUserQuotaBaseSQL (where?: string) { | 883 | private static generateUserQuotaBaseSQL (options: { |
728 | const andWhere = where ? 'AND ' + where : '' | 884 | whereUserId: '$userId' | '"UserModel"."id"' |
729 | 885 | withSelect: boolean | |
730 | return 'SELECT SUM("size") AS "total" ' + | 886 | where?: string |
887 | }) { | ||
888 | const andWhere = options.where | ||
889 | ? 'AND ' + options.where | ||
890 | : '' | ||
891 | |||
892 | const videoChannelJoin = 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + | ||
893 | 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' + | ||
894 | `WHERE "account"."userId" = ${options.whereUserId} ${andWhere}` | ||
895 | |||
896 | const webtorrentFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' + | ||
897 | 'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' + | ||
898 | videoChannelJoin | ||
899 | |||
900 | const hlsFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' + | ||
901 | 'INNER JOIN "videoStreamingPlaylist" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id ' + | ||
902 | 'INNER JOIN "video" ON "videoStreamingPlaylist"."videoId" = "video"."id" ' + | ||
903 | videoChannelJoin | ||
904 | |||
905 | return 'SELECT COALESCE(SUM("size"), 0) AS "total" ' + | ||
731 | 'FROM (' + | 906 | 'FROM (' + |
732 | 'SELECT MAX("videoFile"."size") AS "size" FROM "videoFile" ' + | 907 | `SELECT MAX("t1"."size") AS "size" FROM (${webtorrentFiles} UNION ${hlsFiles}) t1 ` + |
733 | 'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' + | 908 | 'GROUP BY "t1"."videoId"' + |
734 | 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + | 909 | ') t2' |
735 | 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' + | ||
736 | 'WHERE "account"."userId" = $userId ' + andWhere + | ||
737 | 'GROUP BY "video"."id"' + | ||
738 | ') t' | ||
739 | } | 910 | } |
740 | 911 | ||
741 | private static getTotalRawQuery (query: string, userId: number) { | 912 | private static getTotalRawQuery (query: string, userId: number) { |
diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts index f21d2b8a2..85a371026 100644 --- a/server/models/activitypub/actor-follow.ts +++ b/server/models/activitypub/actor-follow.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import * as Bluebird from 'bluebird' | 1 | import * as Bluebird from 'bluebird' |
2 | import { values, difference } from 'lodash' | 2 | import { difference, values } from 'lodash' |
3 | import { | 3 | import { |
4 | AfterCreate, | 4 | AfterCreate, |
5 | AfterDestroy, | 5 | AfterDestroy, |
@@ -20,10 +20,9 @@ import { | |||
20 | import { FollowState } from '../../../shared/models/actors' | 20 | import { FollowState } from '../../../shared/models/actors' |
21 | import { ActorFollow } from '../../../shared/models/actors/follow.model' | 21 | import { ActorFollow } from '../../../shared/models/actors/follow.model' |
22 | import { logger } from '../../helpers/logger' | 22 | import { logger } from '../../helpers/logger' |
23 | import { getServerActor } from '../../helpers/utils' | ||
24 | import { ACTOR_FOLLOW_SCORE, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants' | 23 | import { ACTOR_FOLLOW_SCORE, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants' |
25 | import { ServerModel } from '../server/server' | 24 | import { ServerModel } from '../server/server' |
26 | import { createSafeIn, getSort, getFollowsSort } from '../utils' | 25 | import { createSafeIn, getFollowsSort, getSort } from '../utils' |
27 | import { ActorModel, unusedActorAttributesForAPI } from './actor' | 26 | import { ActorModel, unusedActorAttributesForAPI } from './actor' |
28 | import { VideoChannelModel } from '../video/video-channel' | 27 | import { VideoChannelModel } from '../video/video-channel' |
29 | import { AccountModel } from '../account/account' | 28 | import { AccountModel } from '../account/account' |
@@ -36,7 +35,8 @@ import { | |||
36 | MActorFollowSubscriptions | 35 | MActorFollowSubscriptions |
37 | } from '@server/typings/models' | 36 | } from '@server/typings/models' |
38 | import { ActivityPubActorType } from '@shared/models' | 37 | import { ActivityPubActorType } from '@shared/models' |
39 | import { afterCommitIfTransaction } from '@server/helpers/database-utils' | 38 | import { VideoModel } from '@server/models/video/video' |
39 | import { getServerActor } from '@server/models/application/application' | ||
40 | 40 | ||
41 | @Table({ | 41 | @Table({ |
42 | tableName: 'actorFollow', | 42 | tableName: 'actorFollow', |
@@ -152,6 +152,18 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
152 | if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved) | 152 | if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved) |
153 | } | 153 | } |
154 | 154 | ||
155 | static isFollowedBy (actorId: number, followerActorId: number) { | ||
156 | const query = 'SELECT 1 FROM "actorFollow" WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId LIMIT 1' | ||
157 | const options = { | ||
158 | type: QueryTypes.SELECT as QueryTypes.SELECT, | ||
159 | bind: { actorId, followerActorId }, | ||
160 | raw: true | ||
161 | } | ||
162 | |||
163 | return VideoModel.sequelize.query(query, options) | ||
164 | .then(results => results.length === 1) | ||
165 | } | ||
166 | |||
155 | static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Bluebird<MActorFollowActorsDefault> { | 167 | static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Bluebird<MActorFollowActorsDefault> { |
156 | const query = { | 168 | const query = { |
157 | where: { | 169 | where: { |
@@ -226,7 +238,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
226 | 238 | ||
227 | return ActorFollowModel.findOne(query) | 239 | return ActorFollowModel.findOne(query) |
228 | .then(result => { | 240 | .then(result => { |
229 | if (result && result.ActorFollowing.VideoChannel) { | 241 | if (result?.ActorFollowing.VideoChannel) { |
230 | result.ActorFollowing.VideoChannel.Actor = result.ActorFollowing | 242 | result.ActorFollowing.VideoChannel.Actor = result.ActorFollowing |
231 | } | 243 | } |
232 | 244 | ||
@@ -239,24 +251,24 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
239 | .map(t => { | 251 | .map(t => { |
240 | if (t.host) { | 252 | if (t.host) { |
241 | return { | 253 | return { |
242 | [ Op.and ]: [ | 254 | [Op.and]: [ |
243 | { | 255 | { |
244 | '$preferredUsername$': t.name | 256 | $preferredUsername$: t.name |
245 | }, | 257 | }, |
246 | { | 258 | { |
247 | '$host$': t.host | 259 | $host$: t.host |
248 | } | 260 | } |
249 | ] | 261 | ] |
250 | } | 262 | } |
251 | } | 263 | } |
252 | 264 | ||
253 | return { | 265 | return { |
254 | [ Op.and ]: [ | 266 | [Op.and]: [ |
255 | { | 267 | { |
256 | '$preferredUsername$': t.name | 268 | $preferredUsername$: t.name |
257 | }, | 269 | }, |
258 | { | 270 | { |
259 | '$serverId$': null | 271 | $serverId$: null |
260 | } | 272 | } |
261 | ] | 273 | ] |
262 | } | 274 | } |
@@ -265,9 +277,9 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
265 | const query = { | 277 | const query = { |
266 | attributes: [], | 278 | attributes: [], |
267 | where: { | 279 | where: { |
268 | [ Op.and ]: [ | 280 | [Op.and]: [ |
269 | { | 281 | { |
270 | [ Op.or ]: whereTab | 282 | [Op.or]: whereTab |
271 | }, | 283 | }, |
272 | { | 284 | { |
273 | actorId | 285 | actorId |
@@ -295,12 +307,12 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
295 | } | 307 | } |
296 | 308 | ||
297 | static listFollowingForApi (options: { | 309 | static listFollowingForApi (options: { |
298 | id: number, | 310 | id: number |
299 | start: number, | 311 | start: number |
300 | count: number, | 312 | count: number |
301 | sort: string, | 313 | sort: string |
302 | state?: FollowState, | 314 | state?: FollowState |
303 | actorType?: ActivityPubActorType, | 315 | actorType?: ActivityPubActorType |
304 | search?: string | 316 | search?: string |
305 | }) { | 317 | }) { |
306 | const { id, start, count, sort, search, state, actorType } = options | 318 | const { id, start, count, sort, search, state, actorType } = options |
@@ -312,7 +324,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
312 | if (search) { | 324 | if (search) { |
313 | Object.assign(followingServerWhere, { | 325 | Object.assign(followingServerWhere, { |
314 | host: { | 326 | host: { |
315 | [ Op.iLike ]: '%' + search + '%' | 327 | [Op.iLike]: '%' + search + '%' |
316 | } | 328 | } |
317 | }) | 329 | }) |
318 | } | 330 | } |
@@ -362,12 +374,12 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
362 | } | 374 | } |
363 | 375 | ||
364 | static listFollowersForApi (options: { | 376 | static listFollowersForApi (options: { |
365 | actorId: number, | 377 | actorId: number |
366 | start: number, | 378 | start: number |
367 | count: number, | 379 | count: number |
368 | sort: string, | 380 | sort: string |
369 | state?: FollowState, | 381 | state?: FollowState |
370 | actorType?: ActivityPubActorType, | 382 | actorType?: ActivityPubActorType |
371 | search?: string | 383 | search?: string |
372 | }) { | 384 | }) { |
373 | const { actorId, start, count, sort, search, state, actorType } = options | 385 | const { actorId, start, count, sort, search, state, actorType } = options |
@@ -379,7 +391,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
379 | if (search) { | 391 | if (search) { |
380 | Object.assign(followerServerWhere, { | 392 | Object.assign(followerServerWhere, { |
381 | host: { | 393 | host: { |
382 | [ Op.iLike ]: '%' + search + '%' | 394 | [Op.iLike]: '%' + search + '%' |
383 | } | 395 | } |
384 | }) | 396 | }) |
385 | } | 397 | } |
@@ -631,7 +643,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
631 | 643 | ||
632 | const tasks: Bluebird<any>[] = [] | 644 | const tasks: Bluebird<any>[] = [] |
633 | 645 | ||
634 | for (let selection of selections) { | 646 | for (const selection of selections) { |
635 | let query = 'SELECT ' + selection + ' FROM "actor" ' + | 647 | let query = 'SELECT ' + selection + ' FROM "actor" ' + |
636 | 'INNER JOIN "actorFollow" ON "actorFollow"."' + firstJoin + '" = "actor"."id" ' + | 648 | 'INNER JOIN "actorFollow" ON "actorFollow"."' + firstJoin + '" = "actor"."id" ' + |
637 | 'INNER JOIN "actor" AS "Follows" ON "actorFollow"."' + secondJoin + '" = "Follows"."id" ' + | 649 | 'INNER JOIN "actor" AS "Follows" ON "actorFollow"."' + secondJoin + '" = "Follows"."id" ' + |
diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts index 007647ced..34bc91706 100644 --- a/server/models/activitypub/actor.ts +++ b/server/models/activitypub/actor.ts | |||
@@ -16,7 +16,7 @@ import { | |||
16 | Table, | 16 | Table, |
17 | UpdatedAt | 17 | UpdatedAt |
18 | } from 'sequelize-typescript' | 18 | } from 'sequelize-typescript' |
19 | import { ActivityPubActorType } from '../../../shared/models/activitypub' | 19 | import { ActivityIconObject, ActivityPubActorType } from '../../../shared/models/activitypub' |
20 | import { Avatar } from '../../../shared/models/avatars/avatar.model' | 20 | import { Avatar } from '../../../shared/models/avatars/avatar.model' |
21 | import { activityPubContextify } from '../../helpers/activitypub' | 21 | import { activityPubContextify } from '../../helpers/activitypub' |
22 | import { | 22 | import { |
@@ -43,11 +43,12 @@ import { | |||
43 | MActorFull, | 43 | MActorFull, |
44 | MActorHost, | 44 | MActorHost, |
45 | MActorServer, | 45 | MActorServer, |
46 | MActorSummaryFormattable, | 46 | MActorSummaryFormattable, MActorUrl, |
47 | MActorWithInboxes | 47 | MActorWithInboxes |
48 | } from '../../typings/models' | 48 | } from '../../typings/models' |
49 | import * as Bluebird from 'bluebird' | 49 | import * as Bluebird from 'bluebird' |
50 | import { Op, Transaction, literal } from 'sequelize' | 50 | import { Op, Transaction, literal } from 'sequelize' |
51 | import { ModelCache } from '@server/models/model-cache' | ||
51 | 52 | ||
52 | enum ScopeNames { | 53 | enum ScopeNames { |
53 | FULL = 'FULL' | 54 | FULL = 'FULL' |
@@ -122,13 +123,13 @@ export const unusedActorAttributesForAPI = [ | |||
122 | } | 123 | } |
123 | } | 124 | } |
124 | }, | 125 | }, |
125 | // { | 126 | { |
126 | // fields: [ 'preferredUsername' ], | 127 | fields: [ 'preferredUsername' ], |
127 | // unique: true, | 128 | unique: true, |
128 | // where: { | 129 | where: { |
129 | // serverId: null | 130 | serverId: null |
130 | // } | 131 | } |
131 | // }, | 132 | }, |
132 | { | 133 | { |
133 | fields: [ 'inboxUrl', 'sharedInboxUrl' ] | 134 | fields: [ 'inboxUrl', 'sharedInboxUrl' ] |
134 | }, | 135 | }, |
@@ -276,8 +277,6 @@ export class ActorModel extends Model<ActorModel> { | |||
276 | }) | 277 | }) |
277 | VideoChannel: VideoChannelModel | 278 | VideoChannel: VideoChannelModel |
278 | 279 | ||
279 | private static cache: { [ id: string ]: any } = {} | ||
280 | |||
281 | static load (id: number): Bluebird<MActor> { | 280 | static load (id: number): Bluebird<MActor> { |
282 | return ActorModel.unscoped().findByPk(id) | 281 | return ActorModel.unscoped().findByPk(id) |
283 | } | 282 | } |
@@ -334,7 +333,7 @@ export class ActorModel extends Model<ActorModel> { | |||
334 | const query = { | 333 | const query = { |
335 | where: { | 334 | where: { |
336 | followersUrl: { | 335 | followersUrl: { |
337 | [ Op.in ]: followersUrls | 336 | [Op.in]: followersUrls |
338 | } | 337 | } |
339 | }, | 338 | }, |
340 | transaction | 339 | transaction |
@@ -344,28 +343,50 @@ export class ActorModel extends Model<ActorModel> { | |||
344 | } | 343 | } |
345 | 344 | ||
346 | static loadLocalByName (preferredUsername: string, transaction?: Transaction): Bluebird<MActorFull> { | 345 | static loadLocalByName (preferredUsername: string, transaction?: Transaction): Bluebird<MActorFull> { |
347 | // The server actor never change, so we can easily cache it | 346 | const fun = () => { |
348 | if (preferredUsername === SERVER_ACTOR_NAME && ActorModel.cache[preferredUsername]) { | 347 | const query = { |
349 | return Bluebird.resolve(ActorModel.cache[preferredUsername]) | 348 | where: { |
350 | } | 349 | preferredUsername, |
350 | serverId: null | ||
351 | }, | ||
352 | transaction | ||
353 | } | ||
351 | 354 | ||
352 | const query = { | 355 | return ActorModel.scope(ScopeNames.FULL) |
353 | where: { | 356 | .findOne(query) |
354 | preferredUsername, | ||
355 | serverId: null | ||
356 | }, | ||
357 | transaction | ||
358 | } | 357 | } |
359 | 358 | ||
360 | return ActorModel.scope(ScopeNames.FULL) | 359 | return ModelCache.Instance.doCache({ |
361 | .findOne(query) | 360 | cacheType: 'local-actor-name', |
362 | .then(actor => { | 361 | key: preferredUsername, |
363 | if (preferredUsername === SERVER_ACTOR_NAME) { | 362 | // The server actor never change, so we can easily cache it |
364 | ActorModel.cache[ preferredUsername ] = actor | 363 | whitelist: () => preferredUsername === SERVER_ACTOR_NAME, |
365 | } | 364 | fun |
365 | }) | ||
366 | } | ||
367 | |||
368 | static loadLocalUrlByName (preferredUsername: string, transaction?: Transaction): Bluebird<MActorUrl> { | ||
369 | const fun = () => { | ||
370 | const query = { | ||
371 | attributes: [ 'url' ], | ||
372 | where: { | ||
373 | preferredUsername, | ||
374 | serverId: null | ||
375 | }, | ||
376 | transaction | ||
377 | } | ||
366 | 378 | ||
367 | return actor | 379 | return ActorModel.unscoped() |
368 | }) | 380 | .findOne(query) |
381 | } | ||
382 | |||
383 | return ModelCache.Instance.doCache({ | ||
384 | cacheType: 'local-actor-name', | ||
385 | key: preferredUsername, | ||
386 | // The server actor never change, so we can easily cache it | ||
387 | whitelist: () => preferredUsername === SERVER_ACTOR_NAME, | ||
388 | fun | ||
389 | }) | ||
369 | } | 390 | } |
370 | 391 | ||
371 | static loadByNameAndHost (preferredUsername: string, host: string): Bluebird<MActorFull> { | 392 | static loadByNameAndHost (preferredUsername: string, host: string): Bluebird<MActorFull> { |
@@ -441,6 +462,36 @@ export class ActorModel extends Model<ActorModel> { | |||
441 | }, { where, transaction }) | 462 | }, { where, transaction }) |
442 | } | 463 | } |
443 | 464 | ||
465 | static loadAccountActorByVideoId (videoId: number): Bluebird<MActor> { | ||
466 | const query = { | ||
467 | include: [ | ||
468 | { | ||
469 | attributes: [ 'id' ], | ||
470 | model: AccountModel.unscoped(), | ||
471 | required: true, | ||
472 | include: [ | ||
473 | { | ||
474 | attributes: [ 'id', 'accountId' ], | ||
475 | model: VideoChannelModel.unscoped(), | ||
476 | required: true, | ||
477 | include: [ | ||
478 | { | ||
479 | attributes: [ 'id', 'channelId' ], | ||
480 | model: VideoModel.unscoped(), | ||
481 | where: { | ||
482 | id: videoId | ||
483 | } | ||
484 | } | ||
485 | ] | ||
486 | } | ||
487 | ] | ||
488 | } | ||
489 | ] | ||
490 | } | ||
491 | |||
492 | return ActorModel.unscoped().findOne(query) | ||
493 | } | ||
494 | |||
444 | getSharedInbox (this: MActorWithInboxes) { | 495 | getSharedInbox (this: MActorWithInboxes) { |
445 | return this.sharedInboxUrl || this.inboxUrl | 496 | return this.sharedInboxUrl || this.inboxUrl |
446 | } | 497 | } |
@@ -473,9 +524,11 @@ export class ActorModel extends Model<ActorModel> { | |||
473 | } | 524 | } |
474 | 525 | ||
475 | toActivityPubObject (this: MActorAP, name: string) { | 526 | toActivityPubObject (this: MActorAP, name: string) { |
476 | let icon = undefined | 527 | let icon: ActivityIconObject |
528 | |||
477 | if (this.avatarId) { | 529 | if (this.avatarId) { |
478 | const extension = extname(this.Avatar.filename) | 530 | const extension = extname(this.Avatar.filename) |
531 | |||
479 | icon = { | 532 | icon = { |
480 | type: 'Image', | 533 | type: 'Image', |
481 | mediaType: extension === '.png' ? 'image/png' : 'image/jpeg', | 534 | mediaType: extension === '.png' ? 'image/png' : 'image/jpeg', |
diff --git a/server/models/application/application.ts b/server/models/application/application.ts index 81320b9af..3bba2c70e 100644 --- a/server/models/application/application.ts +++ b/server/models/application/application.ts | |||
@@ -1,5 +1,16 @@ | |||
1 | import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Model, Table } from 'sequelize-typescript' | 1 | import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Model, Table } from 'sequelize-typescript' |
2 | import { AccountModel } from '../account/account' | 2 | import { AccountModel } from '../account/account' |
3 | import * as memoizee from 'memoizee' | ||
4 | |||
5 | export const getServerActor = memoizee(async function () { | ||
6 | const application = await ApplicationModel.load() | ||
7 | if (!application) throw Error('Could not load Application from database.') | ||
8 | |||
9 | const actor = application.Account.Actor | ||
10 | actor.Account = application.Account | ||
11 | |||
12 | return actor | ||
13 | }, { promise: true }) | ||
3 | 14 | ||
4 | @DefaultScope(() => ({ | 15 | @DefaultScope(() => ({ |
5 | include: [ | 16 | include: [ |
diff --git a/server/models/model-cache.ts b/server/models/model-cache.ts new file mode 100644 index 000000000..a87f99aa2 --- /dev/null +++ b/server/models/model-cache.ts | |||
@@ -0,0 +1,91 @@ | |||
1 | import { Model } from 'sequelize-typescript' | ||
2 | import * as Bluebird from 'bluebird' | ||
3 | import { logger } from '@server/helpers/logger' | ||
4 | |||
5 | type ModelCacheType = | ||
6 | 'local-account-name' | ||
7 | | 'local-actor-name' | ||
8 | | 'local-actor-url' | ||
9 | | 'load-video-immutable-id' | ||
10 | | 'load-video-immutable-url' | ||
11 | |||
12 | type DeleteKey = | ||
13 | 'video' | ||
14 | |||
15 | class ModelCache { | ||
16 | |||
17 | private static instance: ModelCache | ||
18 | |||
19 | private readonly localCache: { [id in ModelCacheType]: Map<string, any> } = { | ||
20 | 'local-account-name': new Map(), | ||
21 | 'local-actor-name': new Map(), | ||
22 | 'local-actor-url': new Map(), | ||
23 | 'load-video-immutable-id': new Map(), | ||
24 | 'load-video-immutable-url': new Map() | ||
25 | } | ||
26 | |||
27 | private readonly deleteIds: { | ||
28 | [deleteKey in DeleteKey]: Map<number, { cacheType: ModelCacheType, key: string }[]> | ||
29 | } = { | ||
30 | video: new Map() | ||
31 | } | ||
32 | |||
33 | private constructor () { | ||
34 | } | ||
35 | |||
36 | static get Instance () { | ||
37 | return this.instance || (this.instance = new this()) | ||
38 | } | ||
39 | |||
40 | doCache<T extends Model> (options: { | ||
41 | cacheType: ModelCacheType | ||
42 | key: string | ||
43 | fun: () => Bluebird<T> | ||
44 | whitelist?: () => boolean | ||
45 | deleteKey?: DeleteKey | ||
46 | }) { | ||
47 | const { cacheType, key, fun, whitelist, deleteKey } = options | ||
48 | |||
49 | if (whitelist && whitelist() !== true) return fun() | ||
50 | |||
51 | const cache = this.localCache[cacheType] | ||
52 | |||
53 | if (cache.has(key)) { | ||
54 | logger.debug('Model cache hit for %s -> %s.', cacheType, key) | ||
55 | return Bluebird.resolve<T>(cache.get(key)) | ||
56 | } | ||
57 | |||
58 | return fun().then(m => { | ||
59 | if (!m) return m | ||
60 | |||
61 | if (!whitelist || whitelist()) cache.set(key, m) | ||
62 | |||
63 | if (deleteKey) { | ||
64 | const map = this.deleteIds[deleteKey] | ||
65 | if (!map.has(m.id)) map.set(m.id, []) | ||
66 | |||
67 | const a = map.get(m.id) | ||
68 | a.push({ cacheType, key }) | ||
69 | } | ||
70 | |||
71 | return m | ||
72 | }) | ||
73 | } | ||
74 | |||
75 | invalidateCache (deleteKey: DeleteKey, modelId: number) { | ||
76 | const map = this.deleteIds[deleteKey] | ||
77 | |||
78 | if (!map.has(modelId)) return | ||
79 | |||
80 | for (const toDelete of map.get(modelId)) { | ||
81 | logger.debug('Removing %s -> %d of model cache %s -> %s.', deleteKey, modelId, toDelete.cacheType, toDelete.key) | ||
82 | this.localCache[toDelete.cacheType].delete(toDelete.key) | ||
83 | } | ||
84 | |||
85 | map.delete(modelId) | ||
86 | } | ||
87 | } | ||
88 | |||
89 | export { | ||
90 | ModelCache | ||
91 | } | ||
diff --git a/server/models/oauth/oauth-token.ts b/server/models/oauth/oauth-token.ts index b680be237..38953e8ad 100644 --- a/server/models/oauth/oauth-token.ts +++ b/server/models/oauth/oauth-token.ts | |||
@@ -23,13 +23,14 @@ import { MOAuthTokenUser } from '@server/typings/models/oauth/oauth-token' | |||
23 | 23 | ||
24 | export type OAuthTokenInfo = { | 24 | export type OAuthTokenInfo = { |
25 | refreshToken: string | 25 | refreshToken: string |
26 | refreshTokenExpiresAt: Date, | 26 | refreshTokenExpiresAt: Date |
27 | client: { | 27 | client: { |
28 | id: number | 28 | id: number |
29 | }, | 29 | } |
30 | user: { | 30 | user: { |
31 | id: number | 31 | id: number |
32 | } | 32 | } |
33 | token: MOAuthTokenUser | ||
33 | } | 34 | } |
34 | 35 | ||
35 | enum ScopeNames { | 36 | enum ScopeNames { |
@@ -97,6 +98,9 @@ export class OAuthTokenModel extends Model<OAuthTokenModel> { | |||
97 | @Column | 98 | @Column |
98 | refreshTokenExpiresAt: Date | 99 | refreshTokenExpiresAt: Date |
99 | 100 | ||
101 | @Column | ||
102 | authName: string | ||
103 | |||
100 | @CreatedAt | 104 | @CreatedAt |
101 | createdAt: Date | 105 | createdAt: Date |
102 | 106 | ||
@@ -133,33 +137,41 @@ export class OAuthTokenModel extends Model<OAuthTokenModel> { | |||
133 | return clearCacheByToken(token.accessToken) | 137 | return clearCacheByToken(token.accessToken) |
134 | } | 138 | } |
135 | 139 | ||
140 | static loadByRefreshToken (refreshToken: string) { | ||
141 | const query = { | ||
142 | where: { refreshToken } | ||
143 | } | ||
144 | |||
145 | return OAuthTokenModel.findOne(query) | ||
146 | } | ||
147 | |||
136 | static getByRefreshTokenAndPopulateClient (refreshToken: string) { | 148 | static getByRefreshTokenAndPopulateClient (refreshToken: string) { |
137 | const query = { | 149 | const query = { |
138 | where: { | 150 | where: { |
139 | refreshToken: refreshToken | 151 | refreshToken |
140 | }, | 152 | }, |
141 | include: [ OAuthClientModel ] | 153 | include: [ OAuthClientModel ] |
142 | } | 154 | } |
143 | 155 | ||
144 | return OAuthTokenModel.findOne(query) | 156 | return OAuthTokenModel.scope(ScopeNames.WITH_USER) |
145 | .then(token => { | 157 | .findOne(query) |
146 | if (!token) return null | 158 | .then(token => { |
147 | 159 | if (!token) return null | |
148 | return { | 160 | |
149 | refreshToken: token.refreshToken, | 161 | return { |
150 | refreshTokenExpiresAt: token.refreshTokenExpiresAt, | 162 | refreshToken: token.refreshToken, |
151 | client: { | 163 | refreshTokenExpiresAt: token.refreshTokenExpiresAt, |
152 | id: token.oAuthClientId | 164 | client: { |
153 | }, | 165 | id: token.oAuthClientId |
154 | user: { | 166 | }, |
155 | id: token.userId | 167 | user: token.User, |
156 | } | 168 | token |
157 | } as OAuthTokenInfo | 169 | } as OAuthTokenInfo |
158 | }) | 170 | }) |
159 | .catch(err => { | 171 | .catch(err => { |
160 | logger.error('getRefreshToken error.', { err }) | 172 | logger.error('getRefreshToken error.', { err }) |
161 | throw err | 173 | throw err |
162 | }) | 174 | }) |
163 | } | 175 | } |
164 | 176 | ||
165 | static getByTokenAndPopulateUser (bearerToken: string): Bluebird<MOAuthTokenUser> { | 177 | static getByTokenAndPopulateUser (bearerToken: string): Bluebird<MOAuthTokenUser> { |
@@ -181,14 +193,14 @@ export class OAuthTokenModel extends Model<OAuthTokenModel> { | |||
181 | static getByRefreshTokenAndPopulateUser (refreshToken: string): Bluebird<MOAuthTokenUser> { | 193 | static getByRefreshTokenAndPopulateUser (refreshToken: string): Bluebird<MOAuthTokenUser> { |
182 | const query = { | 194 | const query = { |
183 | where: { | 195 | where: { |
184 | refreshToken: refreshToken | 196 | refreshToken |
185 | } | 197 | } |
186 | } | 198 | } |
187 | 199 | ||
188 | return OAuthTokenModel.scope(ScopeNames.WITH_USER) | 200 | return OAuthTokenModel.scope(ScopeNames.WITH_USER) |
189 | .findOne(query) | 201 | .findOne(query) |
190 | .then(token => { | 202 | .then(token => { |
191 | if (!token) return new OAuthTokenModel() | 203 | if (!token) return undefined |
192 | 204 | ||
193 | return Object.assign(token, { user: token.User }) | 205 | return Object.assign(token, { user: token.User }) |
194 | }) | 206 | }) |
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts index 8c9a7eabf..6021408bf 100644 --- a/server/models/redundancy/video-redundancy.ts +++ b/server/models/redundancy/video-redundancy.ts | |||
@@ -13,13 +13,12 @@ import { | |||
13 | UpdatedAt | 13 | UpdatedAt |
14 | } from 'sequelize-typescript' | 14 | } from 'sequelize-typescript' |
15 | import { ActorModel } from '../activitypub/actor' | 15 | import { ActorModel } from '../activitypub/actor' |
16 | import { getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils' | 16 | import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils' |
17 | import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc' | 17 | import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc' |
18 | import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants' | 18 | import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants' |
19 | import { VideoFileModel } from '../video/video-file' | 19 | import { VideoFileModel } from '../video/video-file' |
20 | import { getServerActor } from '../../helpers/utils' | ||
21 | import { VideoModel } from '../video/video' | 20 | import { VideoModel } from '../video/video' |
22 | import { VideoRedundancyStrategy } from '../../../shared/models/redundancy' | 21 | import { VideoRedundancyStrategy, VideoRedundancyStrategyWithManual } from '../../../shared/models/redundancy' |
23 | import { logger } from '../../helpers/logger' | 22 | import { logger } from '../../helpers/logger' |
24 | import { CacheFileObject, VideoPrivacy } from '../../../shared' | 23 | import { CacheFileObject, VideoPrivacy } from '../../../shared' |
25 | import { VideoChannelModel } from '../video/video-channel' | 24 | import { VideoChannelModel } from '../video/video-channel' |
@@ -27,17 +26,24 @@ import { ServerModel } from '../server/server' | |||
27 | import { sample } from 'lodash' | 26 | import { sample } from 'lodash' |
28 | import { isTestInstance } from '../../helpers/core-utils' | 27 | import { isTestInstance } from '../../helpers/core-utils' |
29 | import * as Bluebird from 'bluebird' | 28 | import * as Bluebird from 'bluebird' |
30 | import { col, FindOptions, fn, literal, Op, Transaction } from 'sequelize' | 29 | import { col, FindOptions, fn, literal, Op, Transaction, WhereOptions } from 'sequelize' |
31 | import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist' | 30 | import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist' |
32 | import { CONFIG } from '../../initializers/config' | 31 | import { CONFIG } from '../../initializers/config' |
33 | import { MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/typings/models' | 32 | import { MVideoForRedundancyAPI, MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/typings/models' |
33 | import { VideoRedundanciesTarget } from '@shared/models/redundancy/video-redundancies-filters.model' | ||
34 | import { | ||
35 | FileRedundancyInformation, | ||
36 | StreamingPlaylistRedundancyInformation, | ||
37 | VideoRedundancy | ||
38 | } from '@shared/models/redundancy/video-redundancy.model' | ||
39 | import { getServerActor } from '@server/models/application/application' | ||
34 | 40 | ||
35 | export enum ScopeNames { | 41 | export enum ScopeNames { |
36 | WITH_VIDEO = 'WITH_VIDEO' | 42 | WITH_VIDEO = 'WITH_VIDEO' |
37 | } | 43 | } |
38 | 44 | ||
39 | @Scopes(() => ({ | 45 | @Scopes(() => ({ |
40 | [ ScopeNames.WITH_VIDEO ]: { | 46 | [ScopeNames.WITH_VIDEO]: { |
41 | include: [ | 47 | include: [ |
42 | { | 48 | { |
43 | model: VideoFileModel, | 49 | model: VideoFileModel, |
@@ -86,7 +92,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
86 | @UpdatedAt | 92 | @UpdatedAt |
87 | updatedAt: Date | 93 | updatedAt: Date |
88 | 94 | ||
89 | @AllowNull(false) | 95 | @AllowNull(true) |
90 | @Column | 96 | @Column |
91 | expiresOn: Date | 97 | expiresOn: Date |
92 | 98 | ||
@@ -161,7 +167,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
161 | logger.info('Removing duplicated video streaming playlist %s.', videoUUID) | 167 | logger.info('Removing duplicated video streaming playlist %s.', videoUUID) |
162 | 168 | ||
163 | videoStreamingPlaylist.Video.removeStreamingPlaylistFiles(videoStreamingPlaylist, true) | 169 | videoStreamingPlaylist.Video.removeStreamingPlaylistFiles(videoStreamingPlaylist, true) |
164 | .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err })) | 170 | .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err })) |
165 | } | 171 | } |
166 | 172 | ||
167 | return undefined | 173 | return undefined |
@@ -193,6 +199,15 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
193 | return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query) | 199 | return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query) |
194 | } | 200 | } |
195 | 201 | ||
202 | static loadByIdWithVideo (id: number, transaction?: Transaction): Bluebird<MVideoRedundancyVideo> { | ||
203 | const query = { | ||
204 | where: { id }, | ||
205 | transaction | ||
206 | } | ||
207 | |||
208 | return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query) | ||
209 | } | ||
210 | |||
196 | static loadByUrl (url: string, transaction?: Transaction): Bluebird<MVideoRedundancy> { | 211 | static loadByUrl (url: string, transaction?: Transaction): Bluebird<MVideoRedundancy> { |
197 | const query = { | 212 | const query = { |
198 | where: { | 213 | where: { |
@@ -215,12 +230,12 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
215 | }, | 230 | }, |
216 | include: [ | 231 | include: [ |
217 | { | 232 | { |
218 | attributes: [ ], | 233 | attributes: [], |
219 | model: VideoFileModel, | 234 | model: VideoFileModel, |
220 | required: true, | 235 | required: true, |
221 | include: [ | 236 | include: [ |
222 | { | 237 | { |
223 | attributes: [ ], | 238 | attributes: [], |
224 | model: VideoModel, | 239 | model: VideoModel, |
225 | required: true, | 240 | required: true, |
226 | where: { | 241 | where: { |
@@ -233,7 +248,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
233 | } | 248 | } |
234 | 249 | ||
235 | return VideoRedundancyModel.findOne(query) | 250 | return VideoRedundancyModel.findOne(query) |
236 | .then(r => !!r) | 251 | .then(r => !!r) |
237 | } | 252 | } |
238 | 253 | ||
239 | static async getVideoSample (p: Bluebird<VideoModel[]>) { | 254 | static async getVideoSample (p: Bluebird<VideoModel[]>) { |
@@ -295,7 +310,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
295 | where: { | 310 | where: { |
296 | privacy: VideoPrivacy.PUBLIC, | 311 | privacy: VideoPrivacy.PUBLIC, |
297 | views: { | 312 | views: { |
298 | [ Op.gte ]: minViews | 313 | [Op.gte]: minViews |
299 | } | 314 | } |
300 | }, | 315 | }, |
301 | include: [ | 316 | include: [ |
@@ -318,7 +333,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
318 | actorId: actor.id, | 333 | actorId: actor.id, |
319 | strategy, | 334 | strategy, |
320 | createdAt: { | 335 | createdAt: { |
321 | [ Op.lt ]: expiredDate | 336 | [Op.lt]: expiredDate |
322 | } | 337 | } |
323 | } | 338 | } |
324 | } | 339 | } |
@@ -377,7 +392,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
377 | where: { | 392 | where: { |
378 | actorId: actor.id, | 393 | actorId: actor.id, |
379 | expiresOn: { | 394 | expiresOn: { |
380 | [ Op.lt ]: new Date() | 395 | [Op.lt]: new Date() |
381 | } | 396 | } |
382 | } | 397 | } |
383 | } | 398 | } |
@@ -394,7 +409,8 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
394 | [Op.ne]: actor.id | 409 | [Op.ne]: actor.id |
395 | }, | 410 | }, |
396 | expiresOn: { | 411 | expiresOn: { |
397 | [ Op.lt ]: new Date() | 412 | [Op.lt]: new Date(), |
413 | [Op.ne]: null | ||
398 | } | 414 | } |
399 | } | 415 | } |
400 | } | 416 | } |
@@ -447,7 +463,112 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
447 | return VideoRedundancyModel.findAll(query) | 463 | return VideoRedundancyModel.findAll(query) |
448 | } | 464 | } |
449 | 465 | ||
450 | static async getStats (strategy: VideoRedundancyStrategy) { | 466 | static listForApi (options: { |
467 | start: number | ||
468 | count: number | ||
469 | sort: string | ||
470 | target: VideoRedundanciesTarget | ||
471 | strategy?: string | ||
472 | }) { | ||
473 | const { start, count, sort, target, strategy } = options | ||
474 | const redundancyWhere: WhereOptions = {} | ||
475 | const videosWhere: WhereOptions = {} | ||
476 | let redundancySqlSuffix = '' | ||
477 | |||
478 | if (target === 'my-videos') { | ||
479 | Object.assign(videosWhere, { remote: false }) | ||
480 | } else if (target === 'remote-videos') { | ||
481 | Object.assign(videosWhere, { remote: true }) | ||
482 | Object.assign(redundancyWhere, { strategy: { [Op.ne]: null } }) | ||
483 | redundancySqlSuffix = ' AND "videoRedundancy"."strategy" IS NOT NULL' | ||
484 | } | ||
485 | |||
486 | if (strategy) { | ||
487 | Object.assign(redundancyWhere, { strategy: strategy }) | ||
488 | } | ||
489 | |||
490 | const videoFilterWhere = { | ||
491 | [Op.and]: [ | ||
492 | { | ||
493 | [Op.or]: [ | ||
494 | { | ||
495 | id: { | ||
496 | [Op.in]: literal( | ||
497 | '(' + | ||
498 | 'SELECT "videoId" FROM "videoFile" ' + | ||
499 | 'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoFileId" = "videoFile".id' + | ||
500 | redundancySqlSuffix + | ||
501 | ')' | ||
502 | ) | ||
503 | } | ||
504 | }, | ||
505 | { | ||
506 | id: { | ||
507 | [Op.in]: literal( | ||
508 | '(' + | ||
509 | 'select "videoId" FROM "videoStreamingPlaylist" ' + | ||
510 | 'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id' + | ||
511 | redundancySqlSuffix + | ||
512 | ')' | ||
513 | ) | ||
514 | } | ||
515 | } | ||
516 | ] | ||
517 | }, | ||
518 | |||
519 | videosWhere | ||
520 | ] | ||
521 | } | ||
522 | |||
523 | // /!\ On video model /!\ | ||
524 | const findOptions = { | ||
525 | offset: start, | ||
526 | limit: count, | ||
527 | order: getSort(sort), | ||
528 | include: [ | ||
529 | { | ||
530 | required: false, | ||
531 | model: VideoFileModel, | ||
532 | include: [ | ||
533 | { | ||
534 | model: VideoRedundancyModel.unscoped(), | ||
535 | required: false, | ||
536 | where: redundancyWhere | ||
537 | } | ||
538 | ] | ||
539 | }, | ||
540 | { | ||
541 | required: false, | ||
542 | model: VideoStreamingPlaylistModel.unscoped(), | ||
543 | include: [ | ||
544 | { | ||
545 | model: VideoRedundancyModel.unscoped(), | ||
546 | required: false, | ||
547 | where: redundancyWhere | ||
548 | }, | ||
549 | { | ||
550 | model: VideoFileModel, | ||
551 | required: false | ||
552 | } | ||
553 | ] | ||
554 | } | ||
555 | ], | ||
556 | where: videoFilterWhere | ||
557 | } | ||
558 | |||
559 | // /!\ On video model /!\ | ||
560 | const countOptions = { | ||
561 | where: videoFilterWhere | ||
562 | } | ||
563 | |||
564 | return Promise.all([ | ||
565 | VideoModel.findAll(findOptions), | ||
566 | |||
567 | VideoModel.count(countOptions) | ||
568 | ]).then(([ data, total ]) => ({ total, data })) | ||
569 | } | ||
570 | |||
571 | static async getStats (strategy: VideoRedundancyStrategyWithManual) { | ||
451 | const actor = await getServerActor() | 572 | const actor = await getServerActor() |
452 | 573 | ||
453 | const query: FindOptions = { | 574 | const query: FindOptions = { |
@@ -471,11 +592,58 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
471 | } | 592 | } |
472 | 593 | ||
473 | return VideoRedundancyModel.findOne(query) | 594 | return VideoRedundancyModel.findOne(query) |
474 | .then((r: any) => ({ | 595 | .then((r: any) => ({ |
475 | totalUsed: parseAggregateResult(r.totalUsed), | 596 | totalUsed: parseAggregateResult(r.totalUsed), |
476 | totalVideos: r.totalVideos, | 597 | totalVideos: r.totalVideos, |
477 | totalVideoFiles: r.totalVideoFiles | 598 | totalVideoFiles: r.totalVideoFiles |
478 | })) | 599 | })) |
600 | } | ||
601 | |||
602 | static toFormattedJSONStatic (video: MVideoForRedundancyAPI): VideoRedundancy { | ||
603 | const filesRedundancies: FileRedundancyInformation[] = [] | ||
604 | const streamingPlaylistsRedundancies: StreamingPlaylistRedundancyInformation[] = [] | ||
605 | |||
606 | for (const file of video.VideoFiles) { | ||
607 | for (const redundancy of file.RedundancyVideos) { | ||
608 | filesRedundancies.push({ | ||
609 | id: redundancy.id, | ||
610 | fileUrl: redundancy.fileUrl, | ||
611 | strategy: redundancy.strategy, | ||
612 | createdAt: redundancy.createdAt, | ||
613 | updatedAt: redundancy.updatedAt, | ||
614 | expiresOn: redundancy.expiresOn, | ||
615 | size: file.size | ||
616 | }) | ||
617 | } | ||
618 | } | ||
619 | |||
620 | for (const playlist of video.VideoStreamingPlaylists) { | ||
621 | const size = playlist.VideoFiles.reduce((a, b) => a + b.size, 0) | ||
622 | |||
623 | for (const redundancy of playlist.RedundancyVideos) { | ||
624 | streamingPlaylistsRedundancies.push({ | ||
625 | id: redundancy.id, | ||
626 | fileUrl: redundancy.fileUrl, | ||
627 | strategy: redundancy.strategy, | ||
628 | createdAt: redundancy.createdAt, | ||
629 | updatedAt: redundancy.updatedAt, | ||
630 | expiresOn: redundancy.expiresOn, | ||
631 | size | ||
632 | }) | ||
633 | } | ||
634 | } | ||
635 | |||
636 | return { | ||
637 | id: video.id, | ||
638 | name: video.name, | ||
639 | url: video.url, | ||
640 | uuid: video.uuid, | ||
641 | |||
642 | redundancies: { | ||
643 | files: filesRedundancies, | ||
644 | streamingPlaylists: streamingPlaylistsRedundancies | ||
645 | } | ||
646 | } | ||
479 | } | 647 | } |
480 | 648 | ||
481 | getVideo () { | 649 | getVideo () { |
@@ -494,7 +662,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
494 | id: this.url, | 662 | id: this.url, |
495 | type: 'CacheFile' as 'CacheFile', | 663 | type: 'CacheFile' as 'CacheFile', |
496 | object: this.VideoStreamingPlaylist.Video.url, | 664 | object: this.VideoStreamingPlaylist.Video.url, |
497 | expires: this.expiresOn.toISOString(), | 665 | expires: this.expiresOn ? this.expiresOn.toISOString() : null, |
498 | url: { | 666 | url: { |
499 | type: 'Link', | 667 | type: 'Link', |
500 | mediaType: 'application/x-mpegURL', | 668 | mediaType: 'application/x-mpegURL', |
@@ -507,10 +675,10 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
507 | id: this.url, | 675 | id: this.url, |
508 | type: 'CacheFile' as 'CacheFile', | 676 | type: 'CacheFile' as 'CacheFile', |
509 | object: this.VideoFile.Video.url, | 677 | object: this.VideoFile.Video.url, |
510 | expires: this.expiresOn.toISOString(), | 678 | expires: this.expiresOn ? this.expiresOn.toISOString() : null, |
511 | url: { | 679 | url: { |
512 | type: 'Link', | 680 | type: 'Link', |
513 | mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ this.VideoFile.extname ] as any, | 681 | mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[this.VideoFile.extname] as any, |
514 | href: this.fileUrl, | 682 | href: this.fileUrl, |
515 | height: this.VideoFile.resolution, | 683 | height: this.VideoFile.resolution, |
516 | size: this.VideoFile.size, | 684 | size: this.VideoFile.size, |
@@ -525,17 +693,17 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
525 | 693 | ||
526 | const notIn = literal( | 694 | const notIn = literal( |
527 | '(' + | 695 | '(' + |
528 | `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id} AND "videoFileId" IS NOT NULL` + | 696 | `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id} AND "videoFileId" IS NOT NULL` + |
529 | ')' | 697 | ')' |
530 | ) | 698 | ) |
531 | 699 | ||
532 | return { | 700 | return { |
533 | attributes: [], | 701 | attributes: [], |
534 | model: VideoFileModel.unscoped(), | 702 | model: VideoFileModel, |
535 | required: true, | 703 | required: true, |
536 | where: { | 704 | where: { |
537 | id: { | 705 | id: { |
538 | [ Op.notIn ]: notIn | 706 | [Op.notIn]: notIn |
539 | } | 707 | } |
540 | } | 708 | } |
541 | } | 709 | } |
diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts index d094da1f5..53b6227d7 100644 --- a/server/models/server/plugin.ts +++ b/server/models/server/plugin.ts | |||
@@ -1,5 +1,10 @@ | |||
1 | import * as Bluebird from 'bluebird' | ||
2 | import { FindAndCountOptions, json, QueryTypes } from 'sequelize' | ||
1 | import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | 3 | import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' |
2 | import { getSort, throwIfNotValid } from '../utils' | 4 | import { MPlugin, MPluginFormattable } from '@server/typings/models' |
5 | import { PeerTubePlugin } from '../../../shared/models/plugins/peertube-plugin.model' | ||
6 | import { PluginType } from '../../../shared/models/plugins/plugin.type' | ||
7 | import { RegisterServerSettingOptions } from '../../../shared/models/plugins/register-server-setting.model' | ||
3 | import { | 8 | import { |
4 | isPluginDescriptionValid, | 9 | isPluginDescriptionValid, |
5 | isPluginHomepage, | 10 | isPluginHomepage, |
@@ -7,12 +12,7 @@ import { | |||
7 | isPluginTypeValid, | 12 | isPluginTypeValid, |
8 | isPluginVersionValid | 13 | isPluginVersionValid |
9 | } from '../../helpers/custom-validators/plugins' | 14 | } from '../../helpers/custom-validators/plugins' |
10 | import { PluginType } from '../../../shared/models/plugins/plugin.type' | 15 | import { getSort, throwIfNotValid } from '../utils' |
11 | import { PeerTubePlugin } from '../../../shared/models/plugins/peertube-plugin.model' | ||
12 | import { FindAndCountOptions, json } from 'sequelize' | ||
13 | import { RegisterServerSettingOptions } from '../../../shared/models/plugins/register-server-setting.model' | ||
14 | import * as Bluebird from 'bluebird' | ||
15 | import { MPlugin, MPluginFormattable } from '@server/typings/models' | ||
16 | 16 | ||
17 | @DefaultScope(() => ({ | 17 | @DefaultScope(() => ({ |
18 | attributes: { | 18 | attributes: { |
@@ -112,7 +112,7 @@ export class PluginModel extends Model<PluginModel> { | |||
112 | return PluginModel.findOne(query) | 112 | return PluginModel.findOne(query) |
113 | } | 113 | } |
114 | 114 | ||
115 | static getSetting (pluginName: string, pluginType: PluginType, settingName: string) { | 115 | static getSetting (pluginName: string, pluginType: PluginType, settingName: string, registeredSettings: RegisterServerSettingOptions[]) { |
116 | const query = { | 116 | const query = { |
117 | attributes: [ 'settings' ], | 117 | attributes: [ 'settings' ], |
118 | where: { | 118 | where: { |
@@ -123,12 +123,51 @@ export class PluginModel extends Model<PluginModel> { | |||
123 | 123 | ||
124 | return PluginModel.findOne(query) | 124 | return PluginModel.findOne(query) |
125 | .then(p => { | 125 | .then(p => { |
126 | if (!p || !p.settings) return undefined | 126 | if (!p || !p.settings || p.settings === undefined) { |
127 | const registered = registeredSettings.find(s => s.name === settingName) | ||
128 | if (!registered || registered.default === undefined) return undefined | ||
129 | |||
130 | return registered.default | ||
131 | } | ||
127 | 132 | ||
128 | return p.settings[settingName] | 133 | return p.settings[settingName] |
129 | }) | 134 | }) |
130 | } | 135 | } |
131 | 136 | ||
137 | static getSettings ( | ||
138 | pluginName: string, | ||
139 | pluginType: PluginType, | ||
140 | settingNames: string[], | ||
141 | registeredSettings: RegisterServerSettingOptions[] | ||
142 | ) { | ||
143 | const query = { | ||
144 | attributes: [ 'settings' ], | ||
145 | where: { | ||
146 | name: pluginName, | ||
147 | type: pluginType | ||
148 | } | ||
149 | } | ||
150 | |||
151 | return PluginModel.findOne(query) | ||
152 | .then(p => { | ||
153 | const result: { [settingName: string ]: string | boolean } = {} | ||
154 | |||
155 | for (const name of settingNames) { | ||
156 | if (!p || !p.settings || p.settings[name] === undefined) { | ||
157 | const registered = registeredSettings.find(s => s.name === name) | ||
158 | |||
159 | if (registered?.default !== undefined) { | ||
160 | result[name] = registered.default | ||
161 | } | ||
162 | } else { | ||
163 | result[name] = p.settings[name] | ||
164 | } | ||
165 | } | ||
166 | |||
167 | return result | ||
168 | }) | ||
169 | } | ||
170 | |||
132 | static setSetting (pluginName: string, pluginType: PluginType, settingName: string, settingValue: string) { | 171 | static setSetting (pluginName: string, pluginType: PluginType, settingName: string, settingValue: string) { |
133 | const query = { | 172 | const query = { |
134 | where: { | 173 | where: { |
@@ -173,26 +212,25 @@ export class PluginModel extends Model<PluginModel> { | |||
173 | } | 212 | } |
174 | 213 | ||
175 | static storeData (pluginName: string, pluginType: PluginType, key: string, data: any) { | 214 | static storeData (pluginName: string, pluginType: PluginType, key: string, data: any) { |
176 | const query = { | 215 | const query = 'UPDATE "plugin" SET "storage" = jsonb_set(coalesce("storage", \'{}\'), :key, :data::jsonb) ' + |
177 | where: { | 216 | 'WHERE "name" = :pluginName AND "type" = :pluginType' |
178 | name: pluginName, | ||
179 | type: pluginType | ||
180 | } | ||
181 | } | ||
182 | 217 | ||
183 | const toSave = { | 218 | const jsonPath = '{' + key + '}' |
184 | [`storage.${key}`]: data | 219 | |
220 | const options = { | ||
221 | replacements: { pluginName, pluginType, key: jsonPath, data: JSON.stringify(data) }, | ||
222 | type: QueryTypes.UPDATE | ||
185 | } | 223 | } |
186 | 224 | ||
187 | return PluginModel.update(toSave, query) | 225 | return PluginModel.sequelize.query(query, options) |
188 | .then(() => undefined) | 226 | .then(() => undefined) |
189 | } | 227 | } |
190 | 228 | ||
191 | static listForApi (options: { | 229 | static listForApi (options: { |
192 | pluginType?: PluginType, | 230 | pluginType?: PluginType |
193 | uninstalled?: boolean, | 231 | uninstalled?: boolean |
194 | start: number, | 232 | start: number |
195 | count: number, | 233 | count: number |
196 | sort: string | 234 | sort: string |
197 | }) { | 235 | }) { |
198 | const { uninstalled = false } = options | 236 | const { uninstalled = false } = options |
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts index b88df4fd5..892024c04 100644 --- a/server/models/server/server-blocklist.ts +++ b/server/models/server/server-blocklist.ts | |||
@@ -2,7 +2,7 @@ import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, Updated | |||
2 | import { AccountModel } from '../account/account' | 2 | import { AccountModel } from '../account/account' |
3 | import { ServerModel } from './server' | 3 | import { ServerModel } from './server' |
4 | import { ServerBlock } from '../../../shared/models/blocklist' | 4 | import { ServerBlock } from '../../../shared/models/blocklist' |
5 | import { getSort } from '../utils' | 5 | import { getSort, searchAttribute } from '../utils' |
6 | import * as Bluebird from 'bluebird' | 6 | import * as Bluebird from 'bluebird' |
7 | import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/typings/models' | 7 | import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/typings/models' |
8 | import { Op } from 'sequelize' | 8 | import { Op } from 'sequelize' |
@@ -81,7 +81,7 @@ export class ServerBlocklistModel extends Model<ServerBlocklistModel> { | |||
81 | attributes: [ 'accountId', 'id' ], | 81 | attributes: [ 'accountId', 'id' ], |
82 | where: { | 82 | where: { |
83 | accountId: { | 83 | accountId: { |
84 | [Op.in]: accountIds // FIXME: sequelize ANY seems broken | 84 | [Op.in]: accountIds |
85 | }, | 85 | }, |
86 | targetServerId | 86 | targetServerId |
87 | }, | 87 | }, |
@@ -120,13 +120,22 @@ export class ServerBlocklistModel extends Model<ServerBlocklistModel> { | |||
120 | return ServerBlocklistModel.findOne(query) | 120 | return ServerBlocklistModel.findOne(query) |
121 | } | 121 | } |
122 | 122 | ||
123 | static listForApi (accountId: number, start: number, count: number, sort: string) { | 123 | static listForApi (parameters: { |
124 | start: number | ||
125 | count: number | ||
126 | sort: string | ||
127 | search?: string | ||
128 | accountId: number | ||
129 | }) { | ||
130 | const { start, count, sort, search, accountId } = parameters | ||
131 | |||
124 | const query = { | 132 | const query = { |
125 | offset: start, | 133 | offset: start, |
126 | limit: count, | 134 | limit: count, |
127 | order: getSort(sort), | 135 | order: getSort(sort), |
128 | where: { | 136 | where: { |
129 | accountId | 137 | accountId, |
138 | ...searchAttribute(search, '$BlockedServer.host$') | ||
130 | } | 139 | } |
131 | } | 140 | } |
132 | 141 | ||
diff --git a/server/models/server/server.ts b/server/models/server/server.ts index 8b07115f1..5131257ec 100644 --- a/server/models/server/server.ts +++ b/server/models/server/server.ts | |||
@@ -71,6 +71,13 @@ export class ServerModel extends Model<ServerModel> { | |||
71 | return ServerModel.findOne(query) | 71 | return ServerModel.findOne(query) |
72 | } | 72 | } |
73 | 73 | ||
74 | static async loadOrCreateByHost (host: string) { | ||
75 | let server = await ServerModel.loadByHost(host) | ||
76 | if (!server) server = await ServerModel.create({ host }) | ||
77 | |||
78 | return server | ||
79 | } | ||
80 | |||
74 | isBlocked () { | 81 | isBlocked () { |
75 | return this.BlockedByAccounts && this.BlockedByAccounts.length !== 0 | 82 | return this.BlockedByAccounts && this.BlockedByAccounts.length !== 0 |
76 | } | 83 | } |
diff --git a/server/models/utils.ts b/server/models/utils.ts index f89b80011..b2573cd35 100644 --- a/server/models/utils.ts +++ b/server/models/utils.ts | |||
@@ -1,7 +1,24 @@ | |||
1 | import { Model, Sequelize } from 'sequelize-typescript' | 1 | import { Model, Sequelize } from 'sequelize-typescript' |
2 | import validator from 'validator' | 2 | import validator from 'validator' |
3 | import { Col } from 'sequelize/types/lib/utils' | 3 | import { Col } from 'sequelize/types/lib/utils' |
4 | import { literal, OrderItem } from 'sequelize' | 4 | import { literal, OrderItem, Op } from 'sequelize' |
5 | |||
6 | type Primitive = string | Function | number | boolean | Symbol | undefined | null | ||
7 | type DeepOmitHelper<T, K extends keyof T> = { | ||
8 | [P in K]: // extra level of indirection needed to trigger homomorhic behavior | ||
9 | T[P] extends infer TP // distribute over unions | ||
10 | ? TP extends Primitive | ||
11 | ? TP // leave primitives and functions alone | ||
12 | : TP extends any[] | ||
13 | ? DeepOmitArray<TP, K> // Array special handling | ||
14 | : DeepOmit<TP, K> | ||
15 | : never | ||
16 | } | ||
17 | type DeepOmit<T, K> = T extends Primitive ? T : DeepOmitHelper<T, Exclude<keyof T, K>> | ||
18 | |||
19 | type DeepOmitArray<T extends any[], K> = { | ||
20 | [P in keyof T]: DeepOmit<T[P], K> | ||
21 | } | ||
5 | 22 | ||
6 | type SortType = { sortModel: string, sortValue: string } | 23 | type SortType = { sortModel: string, sortValue: string } |
7 | 24 | ||
@@ -67,7 +84,7 @@ function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): Or | |||
67 | function getBlacklistSort (model: any, value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] { | 84 | function getBlacklistSort (model: any, value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] { |
68 | const [ firstSort ] = getSort(value) | 85 | const [ firstSort ] = getSort(value) |
69 | 86 | ||
70 | if (model) return [ [ literal(`"${model}.${firstSort[ 0 ]}" ${firstSort[ 1 ]}`) ], lastSort ] as any[] // FIXME: typings | 87 | if (model) return [ [ literal(`"${model}.${firstSort[0]}" ${firstSort[1]}`) ], lastSort ] as any[] // FIXME: typings |
71 | return [ firstSort, lastSort ] | 88 | return [ firstSort, lastSort ] |
72 | } | 89 | } |
73 | 90 | ||
@@ -139,7 +156,7 @@ function buildServerIdsFollowedBy (actorId: any) { | |||
139 | 'SELECT "actor"."serverId" FROM "actorFollow" ' + | 156 | 'SELECT "actor"."serverId" FROM "actorFollow" ' + |
140 | 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' + | 157 | 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' + |
141 | 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + | 158 | 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + |
142 | ')' | 159 | ')' |
143 | } | 160 | } |
144 | 161 | ||
145 | function buildWhereIdOrUUID (id: number | string) { | 162 | function buildWhereIdOrUUID (id: number | string) { |
@@ -156,8 +173,11 @@ function parseAggregateResult (result: any) { | |||
156 | } | 173 | } |
157 | 174 | ||
158 | const createSafeIn = (model: typeof Model, stringArr: (string | number)[]) => { | 175 | const createSafeIn = (model: typeof Model, stringArr: (string | number)[]) => { |
159 | return stringArr.map(t => model.sequelize.escape('' + t)) | 176 | return stringArr.map(t => { |
160 | .join(', ') | 177 | return t === null |
178 | ? null | ||
179 | : model.sequelize.escape('' + t) | ||
180 | }).join(', ') | ||
161 | } | 181 | } |
162 | 182 | ||
163 | function buildLocalAccountIdsIn () { | 183 | function buildLocalAccountIdsIn () { |
@@ -172,9 +192,35 @@ function buildLocalActorIdsIn () { | |||
172 | ) | 192 | ) |
173 | } | 193 | } |
174 | 194 | ||
195 | function buildDirectionAndField (value: string) { | ||
196 | let field: string | ||
197 | let direction: 'ASC' | 'DESC' | ||
198 | |||
199 | if (value.substring(0, 1) === '-') { | ||
200 | direction = 'DESC' | ||
201 | field = value.substring(1) | ||
202 | } else { | ||
203 | direction = 'ASC' | ||
204 | field = value | ||
205 | } | ||
206 | |||
207 | return { direction, field } | ||
208 | } | ||
209 | |||
210 | function searchAttribute (sourceField?: string, targetField?: string) { | ||
211 | if (!sourceField) return {} | ||
212 | |||
213 | return { | ||
214 | [targetField]: { | ||
215 | [Op.iLike]: `%${sourceField}%` | ||
216 | } | ||
217 | } | ||
218 | } | ||
219 | |||
175 | // --------------------------------------------------------------------------- | 220 | // --------------------------------------------------------------------------- |
176 | 221 | ||
177 | export { | 222 | export { |
223 | DeepOmit, | ||
178 | buildBlockedAccountSQL, | 224 | buildBlockedAccountSQL, |
179 | buildLocalActorIdsIn, | 225 | buildLocalActorIdsIn, |
180 | SortType, | 226 | SortType, |
@@ -191,7 +237,9 @@ export { | |||
191 | isOutdated, | 237 | isOutdated, |
192 | parseAggregateResult, | 238 | parseAggregateResult, |
193 | getFollowsSort, | 239 | getFollowsSort, |
194 | createSafeIn | 240 | buildDirectionAndField, |
241 | createSafeIn, | ||
242 | searchAttribute | ||
195 | } | 243 | } |
196 | 244 | ||
197 | // --------------------------------------------------------------------------- | 245 | // --------------------------------------------------------------------------- |
@@ -203,18 +251,3 @@ function searchTrigramNormalizeValue (value: string) { | |||
203 | function searchTrigramNormalizeCol (col: string) { | 251 | function searchTrigramNormalizeCol (col: string) { |
204 | return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col))) | 252 | return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col))) |
205 | } | 253 | } |
206 | |||
207 | function buildDirectionAndField (value: string) { | ||
208 | let field: string | ||
209 | let direction: 'ASC' | 'DESC' | ||
210 | |||
211 | if (value.substring(0, 1) === '-') { | ||
212 | direction = 'DESC' | ||
213 | field = value.substring(1) | ||
214 | } else { | ||
215 | direction = 'ASC' | ||
216 | field = value | ||
217 | } | ||
218 | |||
219 | return { direction, field } | ||
220 | } | ||
diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts index 3b011b1d2..e396784d2 100644 --- a/server/models/video/thumbnail.ts +++ b/server/models/video/thumbnail.ts | |||
@@ -19,6 +19,8 @@ import { CONFIG } from '../../initializers/config' | |||
19 | import { VideoModel } from './video' | 19 | import { VideoModel } from './video' |
20 | import { VideoPlaylistModel } from './video-playlist' | 20 | import { VideoPlaylistModel } from './video-playlist' |
21 | import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' | 21 | import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' |
22 | import { MVideoAccountLight } from '@server/typings/models' | ||
23 | import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub' | ||
22 | 24 | ||
23 | @Table({ | 25 | @Table({ |
24 | tableName: 'thumbnail', | 26 | tableName: 'thumbnail', |
@@ -90,7 +92,7 @@ export class ThumbnailModel extends Model<ThumbnailModel> { | |||
90 | @UpdatedAt | 92 | @UpdatedAt |
91 | updatedAt: Date | 93 | updatedAt: Date |
92 | 94 | ||
93 | private static types: { [ id in ThumbnailType ]: { label: string, directory: string, staticPath: string } } = { | 95 | private static readonly types: { [ id in ThumbnailType ]: { label: string, directory: string, staticPath: string } } = { |
94 | [ThumbnailType.MINIATURE]: { | 96 | [ThumbnailType.MINIATURE]: { |
95 | label: 'miniature', | 97 | label: 'miniature', |
96 | directory: CONFIG.STORAGE.THUMBNAILS_DIR, | 98 | directory: CONFIG.STORAGE.THUMBNAILS_DIR, |
@@ -126,11 +128,14 @@ export class ThumbnailModel extends Model<ThumbnailModel> { | |||
126 | return videoUUID + '.jpg' | 128 | return videoUUID + '.jpg' |
127 | } | 129 | } |
128 | 130 | ||
129 | getFileUrl (isLocal: boolean) { | 131 | getFileUrl (video: MVideoAccountLight) { |
130 | if (isLocal === false) return this.fileUrl | 132 | const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename |
131 | 133 | ||
132 | const staticPath = ThumbnailModel.types[this.type].staticPath | 134 | if (video.isOwned()) return WEBSERVER.URL + staticPath |
133 | return WEBSERVER.URL + staticPath + this.filename | 135 | if (this.fileUrl) return this.fileUrl |
136 | |||
137 | // Fallback if we don't have a file URL | ||
138 | return buildRemoteVideoBaseUrl(video, staticPath) | ||
134 | } | 139 | } |
135 | 140 | ||
136 | getPath () { | 141 | getPath () { |
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts index 3636db18d..0844f702d 100644 --- a/server/models/video/video-abuse.ts +++ b/server/models/video/video-abuse.ts | |||
@@ -1,4 +1,21 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | 1 | import * as Bluebird from 'bluebird' |
2 | import { literal, Op } from 'sequelize' | ||
3 | import { | ||
4 | AllowNull, | ||
5 | BelongsTo, | ||
6 | Column, | ||
7 | CreatedAt, | ||
8 | DataType, | ||
9 | Default, | ||
10 | ForeignKey, | ||
11 | Is, | ||
12 | Model, | ||
13 | Scopes, | ||
14 | Table, | ||
15 | UpdatedAt | ||
16 | } from 'sequelize-typescript' | ||
17 | import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' | ||
18 | import { VideoAbuseState, VideoDetails } from '../../../shared' | ||
2 | import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' | 19 | import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' |
3 | import { VideoAbuse } from '../../../shared/models/videos' | 20 | import { VideoAbuse } from '../../../shared/models/videos' |
4 | import { | 21 | import { |
@@ -6,15 +23,205 @@ import { | |||
6 | isVideoAbuseReasonValid, | 23 | isVideoAbuseReasonValid, |
7 | isVideoAbuseStateValid | 24 | isVideoAbuseStateValid |
8 | } from '../../helpers/custom-validators/video-abuses' | 25 | } from '../../helpers/custom-validators/video-abuses' |
9 | import { AccountModel } from '../account/account' | ||
10 | import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils' | ||
11 | import { VideoModel } from './video' | ||
12 | import { VideoAbuseState } from '../../../shared' | ||
13 | import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants' | 26 | import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants' |
14 | import { MUserAccountId, MVideoAbuse, MVideoAbuseFormattable, MVideoAbuseVideo } from '../../typings/models' | 27 | import { MUserAccountId, MVideoAbuse, MVideoAbuseFormattable, MVideoAbuseVideo } from '../../typings/models' |
15 | import * as Bluebird from 'bluebird' | 28 | import { AccountModel } from '../account/account' |
16 | import { literal, Op } from 'sequelize' | 29 | import { buildBlockedAccountSQL, getSort, searchAttribute, throwIfNotValid } from '../utils' |
30 | import { ThumbnailModel } from './thumbnail' | ||
31 | import { VideoModel } from './video' | ||
32 | import { VideoBlacklistModel } from './video-blacklist' | ||
33 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' | ||
34 | |||
35 | export enum ScopeNames { | ||
36 | FOR_API = 'FOR_API' | ||
37 | } | ||
38 | |||
39 | @Scopes(() => ({ | ||
40 | [ScopeNames.FOR_API]: (options: { | ||
41 | // search | ||
42 | search?: string | ||
43 | searchReporter?: string | ||
44 | searchReportee?: string | ||
45 | searchVideo?: string | ||
46 | searchVideoChannel?: string | ||
47 | |||
48 | // filters | ||
49 | id?: number | ||
50 | |||
51 | state?: VideoAbuseState | ||
52 | videoIs?: VideoAbuseVideoIs | ||
53 | |||
54 | // accountIds | ||
55 | serverAccountId: number | ||
56 | userAccountId: number | ||
57 | }) => { | ||
58 | const where = { | ||
59 | reporterAccountId: { | ||
60 | [Op.notIn]: literal('(' + buildBlockedAccountSQL(options.serverAccountId, options.userAccountId) + ')') | ||
61 | } | ||
62 | } | ||
63 | |||
64 | if (options.search) { | ||
65 | Object.assign(where, { | ||
66 | [Op.or]: [ | ||
67 | { | ||
68 | [Op.and]: [ | ||
69 | { videoId: { [Op.not]: null } }, | ||
70 | searchAttribute(options.search, '$Video.name$') | ||
71 | ] | ||
72 | }, | ||
73 | { | ||
74 | [Op.and]: [ | ||
75 | { videoId: { [Op.not]: null } }, | ||
76 | searchAttribute(options.search, '$Video.VideoChannel.name$') | ||
77 | ] | ||
78 | }, | ||
79 | { | ||
80 | [Op.and]: [ | ||
81 | { deletedVideo: { [Op.not]: null } }, | ||
82 | { deletedVideo: searchAttribute(options.search, 'name') } | ||
83 | ] | ||
84 | }, | ||
85 | { | ||
86 | [Op.and]: [ | ||
87 | { deletedVideo: { [Op.not]: null } }, | ||
88 | { deletedVideo: { channel: searchAttribute(options.search, 'displayName') } } | ||
89 | ] | ||
90 | }, | ||
91 | searchAttribute(options.search, '$Account.name$') | ||
92 | ] | ||
93 | }) | ||
94 | } | ||
17 | 95 | ||
96 | if (options.id) Object.assign(where, { id: options.id }) | ||
97 | if (options.state) Object.assign(where, { state: options.state }) | ||
98 | |||
99 | if (options.videoIs === 'deleted') { | ||
100 | Object.assign(where, { | ||
101 | deletedVideo: { | ||
102 | [Op.not]: null | ||
103 | } | ||
104 | }) | ||
105 | } | ||
106 | |||
107 | const onlyBlacklisted = options.videoIs === 'blacklisted' | ||
108 | |||
109 | return { | ||
110 | attributes: { | ||
111 | include: [ | ||
112 | [ | ||
113 | // we don't care about this count for deleted videos, so there are not included | ||
114 | literal( | ||
115 | '(' + | ||
116 | 'SELECT count(*) ' + | ||
117 | 'FROM "videoAbuse" ' + | ||
118 | 'WHERE "videoId" = "VideoAbuseModel"."videoId" ' + | ||
119 | ')' | ||
120 | ), | ||
121 | 'countReportsForVideo' | ||
122 | ], | ||
123 | [ | ||
124 | // we don't care about this count for deleted videos, so there are not included | ||
125 | literal( | ||
126 | '(' + | ||
127 | 'SELECT t.nth ' + | ||
128 | 'FROM ( ' + | ||
129 | 'SELECT id, ' + | ||
130 | 'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' + | ||
131 | 'FROM "videoAbuse" ' + | ||
132 | ') t ' + | ||
133 | 'WHERE t.id = "VideoAbuseModel".id ' + | ||
134 | ')' | ||
135 | ), | ||
136 | 'nthReportForVideo' | ||
137 | ], | ||
138 | [ | ||
139 | literal( | ||
140 | '(' + | ||
141 | 'SELECT count("videoAbuse"."id") ' + | ||
142 | 'FROM "videoAbuse" ' + | ||
143 | 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' + | ||
144 | 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + | ||
145 | 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' + | ||
146 | 'WHERE "account"."id" = "VideoAbuseModel"."reporterAccountId" ' + | ||
147 | ')' | ||
148 | ), | ||
149 | 'countReportsForReporter__video' | ||
150 | ], | ||
151 | [ | ||
152 | literal( | ||
153 | '(' + | ||
154 | 'SELECT count(DISTINCT "videoAbuse"."id") ' + | ||
155 | 'FROM "videoAbuse" ' + | ||
156 | `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "VideoAbuseModel"."reporterAccountId" ` + | ||
157 | ')' | ||
158 | ), | ||
159 | 'countReportsForReporter__deletedVideo' | ||
160 | ], | ||
161 | [ | ||
162 | literal( | ||
163 | '(' + | ||
164 | 'SELECT count(DISTINCT "videoAbuse"."id") ' + | ||
165 | 'FROM "videoAbuse" ' + | ||
166 | 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' + | ||
167 | 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + | ||
168 | 'INNER JOIN "account" ON ' + | ||
169 | '"videoChannel"."accountId" = "Video->VideoChannel"."accountId" ' + | ||
170 | `OR "videoChannel"."accountId" = CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` + | ||
171 | ')' | ||
172 | ), | ||
173 | 'countReportsForReportee__video' | ||
174 | ], | ||
175 | [ | ||
176 | literal( | ||
177 | '(' + | ||
178 | 'SELECT count(DISTINCT "videoAbuse"."id") ' + | ||
179 | 'FROM "videoAbuse" ' + | ||
180 | `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "Video->VideoChannel"."accountId" ` + | ||
181 | `OR CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = ` + | ||
182 | `CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` + | ||
183 | ')' | ||
184 | ), | ||
185 | 'countReportsForReportee__deletedVideo' | ||
186 | ] | ||
187 | ] | ||
188 | }, | ||
189 | include: [ | ||
190 | { | ||
191 | model: AccountModel, | ||
192 | required: true, | ||
193 | where: searchAttribute(options.searchReporter, 'name') | ||
194 | }, | ||
195 | { | ||
196 | model: VideoModel, | ||
197 | required: !!(onlyBlacklisted || options.searchVideo || options.searchReportee || options.searchVideoChannel), | ||
198 | where: searchAttribute(options.searchVideo, 'name'), | ||
199 | include: [ | ||
200 | { | ||
201 | model: ThumbnailModel | ||
202 | }, | ||
203 | { | ||
204 | model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }), | ||
205 | where: searchAttribute(options.searchVideoChannel, 'name'), | ||
206 | include: [ | ||
207 | { | ||
208 | model: AccountModel, | ||
209 | where: searchAttribute(options.searchReportee, 'name') | ||
210 | } | ||
211 | ] | ||
212 | }, | ||
213 | { | ||
214 | attributes: [ 'id', 'reason', 'unfederated' ], | ||
215 | model: VideoBlacklistModel, | ||
216 | required: onlyBlacklisted | ||
217 | } | ||
218 | ] | ||
219 | } | ||
220 | ], | ||
221 | where | ||
222 | } | ||
223 | } | ||
224 | })) | ||
18 | @Table({ | 225 | @Table({ |
19 | tableName: 'videoAbuse', | 226 | tableName: 'videoAbuse', |
20 | indexes: [ | 227 | indexes: [ |
@@ -46,6 +253,11 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
46 | @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max)) | 253 | @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max)) |
47 | moderationComment: string | 254 | moderationComment: string |
48 | 255 | ||
256 | @AllowNull(true) | ||
257 | @Default(null) | ||
258 | @Column(DataType.JSONB) | ||
259 | deletedVideo: VideoDetails | ||
260 | |||
49 | @CreatedAt | 261 | @CreatedAt |
50 | createdAt: Date | 262 | createdAt: Date |
51 | 263 | ||
@@ -58,9 +270,9 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
58 | 270 | ||
59 | @BelongsTo(() => AccountModel, { | 271 | @BelongsTo(() => AccountModel, { |
60 | foreignKey: { | 272 | foreignKey: { |
61 | allowNull: false | 273 | allowNull: true |
62 | }, | 274 | }, |
63 | onDelete: 'cascade' | 275 | onDelete: 'set null' |
64 | }) | 276 | }) |
65 | Account: AccountModel | 277 | Account: AccountModel |
66 | 278 | ||
@@ -70,60 +282,103 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
70 | 282 | ||
71 | @BelongsTo(() => VideoModel, { | 283 | @BelongsTo(() => VideoModel, { |
72 | foreignKey: { | 284 | foreignKey: { |
73 | allowNull: false | 285 | allowNull: true |
74 | }, | 286 | }, |
75 | onDelete: 'cascade' | 287 | onDelete: 'set null' |
76 | }) | 288 | }) |
77 | Video: VideoModel | 289 | Video: VideoModel |
78 | 290 | ||
79 | static loadByIdAndVideoId (id: number, videoId: number): Bluebird<MVideoAbuse> { | 291 | static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MVideoAbuse> { |
292 | const videoAttributes = {} | ||
293 | if (videoId) videoAttributes['videoId'] = videoId | ||
294 | if (uuid) videoAttributes['deletedVideo'] = { uuid } | ||
295 | |||
80 | const query = { | 296 | const query = { |
81 | where: { | 297 | where: { |
82 | id, | 298 | id, |
83 | videoId | 299 | ...videoAttributes |
84 | } | 300 | } |
85 | } | 301 | } |
86 | return VideoAbuseModel.findOne(query) | 302 | return VideoAbuseModel.findOne(query) |
87 | } | 303 | } |
88 | 304 | ||
89 | static listForApi (parameters: { | 305 | static listForApi (parameters: { |
90 | start: number, | 306 | start: number |
91 | count: number, | 307 | count: number |
92 | sort: string, | 308 | sort: string |
309 | |||
93 | serverAccountId: number | 310 | serverAccountId: number |
94 | user?: MUserAccountId | 311 | user?: MUserAccountId |
312 | |||
313 | id?: number | ||
314 | state?: VideoAbuseState | ||
315 | videoIs?: VideoAbuseVideoIs | ||
316 | |||
317 | search?: string | ||
318 | searchReporter?: string | ||
319 | searchReportee?: string | ||
320 | searchVideo?: string | ||
321 | searchVideoChannel?: string | ||
95 | }) { | 322 | }) { |
96 | const { start, count, sort, user, serverAccountId } = parameters | 323 | const { |
324 | start, | ||
325 | count, | ||
326 | sort, | ||
327 | search, | ||
328 | user, | ||
329 | serverAccountId, | ||
330 | state, | ||
331 | videoIs, | ||
332 | searchReportee, | ||
333 | searchVideo, | ||
334 | searchVideoChannel, | ||
335 | searchReporter, | ||
336 | id | ||
337 | } = parameters | ||
338 | |||
97 | const userAccountId = user ? user.Account.id : undefined | 339 | const userAccountId = user ? user.Account.id : undefined |
98 | 340 | ||
99 | const query = { | 341 | const query = { |
100 | offset: start, | 342 | offset: start, |
101 | limit: count, | 343 | limit: count, |
102 | order: getSort(sort), | 344 | order: getSort(sort), |
103 | where: { | 345 | col: 'VideoAbuseModel.id', |
104 | reporterAccountId: { | 346 | distinct: true |
105 | [Op.notIn]: literal('(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')') | ||
106 | } | ||
107 | }, | ||
108 | include: [ | ||
109 | { | ||
110 | model: AccountModel, | ||
111 | required: true | ||
112 | }, | ||
113 | { | ||
114 | model: VideoModel, | ||
115 | required: true | ||
116 | } | ||
117 | ] | ||
118 | } | 347 | } |
119 | 348 | ||
120 | return VideoAbuseModel.findAndCountAll(query) | 349 | const filters = { |
350 | id, | ||
351 | search, | ||
352 | state, | ||
353 | videoIs, | ||
354 | searchReportee, | ||
355 | searchVideo, | ||
356 | searchVideoChannel, | ||
357 | searchReporter, | ||
358 | serverAccountId, | ||
359 | userAccountId | ||
360 | } | ||
361 | |||
362 | return VideoAbuseModel | ||
363 | .scope({ method: [ ScopeNames.FOR_API, filters ] }) | ||
364 | .findAndCountAll(query) | ||
121 | .then(({ rows, count }) => { | 365 | .then(({ rows, count }) => { |
122 | return { total: count, data: rows } | 366 | return { total: count, data: rows } |
123 | }) | 367 | }) |
124 | } | 368 | } |
125 | 369 | ||
126 | toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse { | 370 | toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse { |
371 | const countReportsForVideo = this.get('countReportsForVideo') as number | ||
372 | const nthReportForVideo = this.get('nthReportForVideo') as number | ||
373 | const countReportsForReporterVideo = this.get('countReportsForReporter__video') as number | ||
374 | const countReportsForReporterDeletedVideo = this.get('countReportsForReporter__deletedVideo') as number | ||
375 | const countReportsForReporteeVideo = this.get('countReportsForReportee__video') as number | ||
376 | const countReportsForReporteeDeletedVideo = this.get('countReportsForReportee__deletedVideo') as number | ||
377 | |||
378 | const video = this.Video | ||
379 | ? this.Video | ||
380 | : this.deletedVideo | ||
381 | |||
127 | return { | 382 | return { |
128 | id: this.id, | 383 | id: this.id, |
129 | reason: this.reason, | 384 | reason: this.reason, |
@@ -134,11 +389,21 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
134 | }, | 389 | }, |
135 | moderationComment: this.moderationComment, | 390 | moderationComment: this.moderationComment, |
136 | video: { | 391 | video: { |
137 | id: this.Video.id, | 392 | id: video.id, |
138 | uuid: this.Video.uuid, | 393 | uuid: video.uuid, |
139 | name: this.Video.name | 394 | name: video.name, |
395 | nsfw: video.nsfw, | ||
396 | deleted: !this.Video, | ||
397 | blacklisted: this.Video && this.Video.isBlacklisted(), | ||
398 | thumbnailPath: this.Video?.getMiniatureStaticPath(), | ||
399 | channel: this.Video?.VideoChannel.toFormattedJSON() || this.deletedVideo?.channel | ||
140 | }, | 400 | }, |
141 | createdAt: this.createdAt | 401 | createdAt: this.createdAt, |
402 | updatedAt: this.updatedAt, | ||
403 | count: countReportsForVideo || 0, | ||
404 | nth: nthReportForVideo || 0, | ||
405 | countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0), | ||
406 | countReportsForReportee: (countReportsForReporteeVideo || 0) + (countReportsForReporteeDeletedVideo || 0) | ||
142 | } | 407 | } |
143 | } | 408 | } |
144 | 409 | ||
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts index 694983cb3..8cbfe362e 100644 --- a/server/models/video/video-blacklist.ts +++ b/server/models/video/video-blacklist.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | 1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' |
2 | import { getBlacklistSort, SortType, throwIfNotValid } from '../utils' | 2 | import { getBlacklistSort, SortType, throwIfNotValid, searchAttribute } from '../utils' |
3 | import { VideoModel } from './video' | 3 | import { VideoModel } from './video' |
4 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' | 4 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' |
5 | import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' | 5 | import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' |
@@ -54,7 +54,15 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> { | |||
54 | }) | 54 | }) |
55 | Video: VideoModel | 55 | Video: VideoModel |
56 | 56 | ||
57 | static listForApi (start: number, count: number, sort: SortType, type?: VideoBlacklistType) { | 57 | static listForApi (parameters: { |
58 | start: number | ||
59 | count: number | ||
60 | sort: SortType | ||
61 | search?: string | ||
62 | type?: VideoBlacklistType | ||
63 | }) { | ||
64 | const { start, count, sort, search, type } = parameters | ||
65 | |||
58 | function buildBaseQuery (): FindOptions { | 66 | function buildBaseQuery (): FindOptions { |
59 | return { | 67 | return { |
60 | offset: start, | 68 | offset: start, |
@@ -70,6 +78,7 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> { | |||
70 | { | 78 | { |
71 | model: VideoModel, | 79 | model: VideoModel, |
72 | required: true, | 80 | required: true, |
81 | where: searchAttribute(search, 'name'), | ||
73 | include: [ | 82 | include: [ |
74 | { | 83 | { |
75 | model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }), | 84 | model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }), |
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts index eeb2a4afd..59d3e1050 100644 --- a/server/models/video/video-caption.ts +++ b/server/models/video/video-caption.ts | |||
@@ -5,6 +5,7 @@ import { | |||
5 | BelongsTo, | 5 | BelongsTo, |
6 | Column, | 6 | Column, |
7 | CreatedAt, | 7 | CreatedAt, |
8 | DataType, | ||
8 | ForeignKey, | 9 | ForeignKey, |
9 | Is, | 10 | Is, |
10 | Model, | 11 | Model, |
@@ -16,13 +17,14 @@ import { buildWhereIdOrUUID, throwIfNotValid } from '../utils' | |||
16 | import { VideoModel } from './video' | 17 | import { VideoModel } from './video' |
17 | import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' | 18 | import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' |
18 | import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' | 19 | import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' |
19 | import { LAZY_STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers/constants' | 20 | import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants' |
20 | import { join } from 'path' | 21 | import { join } from 'path' |
21 | import { logger } from '../../helpers/logger' | 22 | import { logger } from '../../helpers/logger' |
22 | import { remove } from 'fs-extra' | 23 | import { remove } from 'fs-extra' |
23 | import { CONFIG } from '../../initializers/config' | 24 | import { CONFIG } from '../../initializers/config' |
24 | import * as Bluebird from 'bluebird' | 25 | import * as Bluebird from 'bluebird' |
25 | import { MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/typings/models' | 26 | import { MVideoAccountLight, MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/typings/models' |
27 | import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub' | ||
26 | 28 | ||
27 | export enum ScopeNames { | 29 | export enum ScopeNames { |
28 | WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE' | 30 | WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE' |
@@ -64,6 +66,10 @@ export class VideoCaptionModel extends Model<VideoCaptionModel> { | |||
64 | @Column | 66 | @Column |
65 | language: string | 67 | language: string |
66 | 68 | ||
69 | @AllowNull(true) | ||
70 | @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max)) | ||
71 | fileUrl: string | ||
72 | |||
67 | @ForeignKey(() => VideoModel) | 73 | @ForeignKey(() => VideoModel) |
68 | @Column | 74 | @Column |
69 | videoId: number | 75 | videoId: number |
@@ -114,13 +120,14 @@ export class VideoCaptionModel extends Model<VideoCaptionModel> { | |||
114 | return VideoCaptionModel.findOne(query) | 120 | return VideoCaptionModel.findOne(query) |
115 | } | 121 | } |
116 | 122 | ||
117 | static insertOrReplaceLanguage (videoId: number, language: string, transaction: Transaction) { | 123 | static insertOrReplaceLanguage (videoId: number, language: string, fileUrl: string, transaction: Transaction) { |
118 | const values = { | 124 | const values = { |
119 | videoId, | 125 | videoId, |
120 | language | 126 | language, |
127 | fileUrl | ||
121 | } | 128 | } |
122 | 129 | ||
123 | return (VideoCaptionModel.upsert<VideoCaptionModel>(values, { transaction, returning: true }) as any) // FIXME: typings | 130 | return VideoCaptionModel.upsert(values, { transaction, returning: true }) |
124 | .then(([ caption ]) => caption) | 131 | .then(([ caption ]) => caption) |
125 | } | 132 | } |
126 | 133 | ||
@@ -175,4 +182,14 @@ export class VideoCaptionModel extends Model<VideoCaptionModel> { | |||
175 | removeCaptionFile (this: MVideoCaptionFormattable) { | 182 | removeCaptionFile (this: MVideoCaptionFormattable) { |
176 | return remove(CONFIG.STORAGE.CAPTIONS_DIR + this.getCaptionName()) | 183 | return remove(CONFIG.STORAGE.CAPTIONS_DIR + this.getCaptionName()) |
177 | } | 184 | } |
185 | |||
186 | getFileUrl (video: MVideoAccountLight) { | ||
187 | if (!this.Video) this.Video = video as VideoModel | ||
188 | |||
189 | if (video.isOwned()) return WEBSERVER.URL + this.getCaptionStaticPath() | ||
190 | if (this.fileUrl) return this.fileUrl | ||
191 | |||
192 | // Fallback if we don't have a file URL | ||
193 | return buildRemoteVideoBaseUrl(video, this.getCaptionStaticPath()) | ||
194 | } | ||
178 | } | 195 | } |
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts index e10adcb3a..642e129ff 100644 --- a/server/models/video/video-channel.ts +++ b/server/models/video/video-channel.ts | |||
@@ -30,7 +30,7 @@ import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttr | |||
30 | import { VideoModel } from './video' | 30 | import { VideoModel } from './video' |
31 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' | 31 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' |
32 | import { ServerModel } from '../server/server' | 32 | import { ServerModel } from '../server/server' |
33 | import { FindOptions, ModelIndexesOptions, Op } from 'sequelize' | 33 | import { FindOptions, Op, literal, ScopeOptions } from 'sequelize' |
34 | import { AvatarModel } from '../avatar/avatar' | 34 | import { AvatarModel } from '../avatar/avatar' |
35 | import { VideoPlaylistModel } from './video-playlist' | 35 | import { VideoPlaylistModel } from './video-playlist' |
36 | import * as Bluebird from 'bluebird' | 36 | import * as Bluebird from 'bluebird' |
@@ -43,30 +43,23 @@ import { | |||
43 | MChannelSummaryFormattable | 43 | MChannelSummaryFormattable |
44 | } from '../../typings/models/video' | 44 | } from '../../typings/models/video' |
45 | 45 | ||
46 | // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation | ||
47 | const indexes: ModelIndexesOptions[] = [ | ||
48 | buildTrigramSearchIndex('video_channel_name_trigram', 'name'), | ||
49 | |||
50 | { | ||
51 | fields: [ 'accountId' ] | ||
52 | }, | ||
53 | { | ||
54 | fields: [ 'actorId' ] | ||
55 | } | ||
56 | ] | ||
57 | |||
58 | export enum ScopeNames { | 46 | export enum ScopeNames { |
59 | FOR_API = 'FOR_API', | 47 | FOR_API = 'FOR_API', |
48 | SUMMARY = 'SUMMARY', | ||
60 | WITH_ACCOUNT = 'WITH_ACCOUNT', | 49 | WITH_ACCOUNT = 'WITH_ACCOUNT', |
61 | WITH_ACTOR = 'WITH_ACTOR', | 50 | WITH_ACTOR = 'WITH_ACTOR', |
62 | WITH_VIDEOS = 'WITH_VIDEOS', | 51 | WITH_VIDEOS = 'WITH_VIDEOS', |
63 | SUMMARY = 'SUMMARY' | 52 | WITH_STATS = 'WITH_STATS' |
64 | } | 53 | } |
65 | 54 | ||
66 | type AvailableForListOptions = { | 55 | type AvailableForListOptions = { |
67 | actorId: number | 56 | actorId: number |
68 | } | 57 | } |
69 | 58 | ||
59 | type AvailableWithStatsOptions = { | ||
60 | daysPrior: number | ||
61 | } | ||
62 | |||
70 | export type SummaryOptions = { | 63 | export type SummaryOptions = { |
71 | withAccount?: boolean // Default: false | 64 | withAccount?: boolean // Default: false |
72 | withAccountBlockerIds?: number[] | 65 | withAccountBlockerIds?: number[] |
@@ -81,40 +74,6 @@ export type SummaryOptions = { | |||
81 | ] | 74 | ] |
82 | })) | 75 | })) |
83 | @Scopes(() => ({ | 76 | @Scopes(() => ({ |
84 | [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => { | ||
85 | const base: FindOptions = { | ||
86 | attributes: [ 'id', 'name', 'description', 'actorId' ], | ||
87 | include: [ | ||
88 | { | ||
89 | attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ], | ||
90 | model: ActorModel.unscoped(), | ||
91 | required: true, | ||
92 | include: [ | ||
93 | { | ||
94 | attributes: [ 'host' ], | ||
95 | model: ServerModel.unscoped(), | ||
96 | required: false | ||
97 | }, | ||
98 | { | ||
99 | model: AvatarModel.unscoped(), | ||
100 | required: false | ||
101 | } | ||
102 | ] | ||
103 | } | ||
104 | ] | ||
105 | } | ||
106 | |||
107 | if (options.withAccount === true) { | ||
108 | base.include.push({ | ||
109 | model: AccountModel.scope({ | ||
110 | method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ] | ||
111 | }), | ||
112 | required: true | ||
113 | }) | ||
114 | } | ||
115 | |||
116 | return base | ||
117 | }, | ||
118 | [ScopeNames.FOR_API]: (options: AvailableForListOptions) => { | 77 | [ScopeNames.FOR_API]: (options: AvailableForListOptions) => { |
119 | // Only list local channels OR channels that are on an instance followed by actorId | 78 | // Only list local channels OR channels that are on an instance followed by actorId |
120 | const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId) | 79 | const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId) |
@@ -133,7 +92,7 @@ export type SummaryOptions = { | |||
133 | }, | 92 | }, |
134 | { | 93 | { |
135 | serverId: { | 94 | serverId: { |
136 | [ Op.in ]: Sequelize.literal(inQueryInstanceFollow) | 95 | [Op.in]: Sequelize.literal(inQueryInstanceFollow) |
137 | } | 96 | } |
138 | } | 97 | } |
139 | ] | 98 | ] |
@@ -155,6 +114,40 @@ export type SummaryOptions = { | |||
155 | ] | 114 | ] |
156 | } | 115 | } |
157 | }, | 116 | }, |
117 | [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => { | ||
118 | const base: FindOptions = { | ||
119 | attributes: [ 'id', 'name', 'description', 'actorId' ], | ||
120 | include: [ | ||
121 | { | ||
122 | attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ], | ||
123 | model: ActorModel.unscoped(), | ||
124 | required: true, | ||
125 | include: [ | ||
126 | { | ||
127 | attributes: [ 'host' ], | ||
128 | model: ServerModel.unscoped(), | ||
129 | required: false | ||
130 | }, | ||
131 | { | ||
132 | model: AvatarModel.unscoped(), | ||
133 | required: false | ||
134 | } | ||
135 | ] | ||
136 | } | ||
137 | ] | ||
138 | } | ||
139 | |||
140 | if (options.withAccount === true) { | ||
141 | base.include.push({ | ||
142 | model: AccountModel.scope({ | ||
143 | method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ] | ||
144 | }), | ||
145 | required: true | ||
146 | }) | ||
147 | } | ||
148 | |||
149 | return base | ||
150 | }, | ||
158 | [ScopeNames.WITH_ACCOUNT]: { | 151 | [ScopeNames.WITH_ACCOUNT]: { |
159 | include: [ | 152 | include: [ |
160 | { | 153 | { |
@@ -163,20 +156,66 @@ export type SummaryOptions = { | |||
163 | } | 156 | } |
164 | ] | 157 | ] |
165 | }, | 158 | }, |
166 | [ScopeNames.WITH_VIDEOS]: { | 159 | [ScopeNames.WITH_ACTOR]: { |
167 | include: [ | 160 | include: [ |
168 | VideoModel | 161 | ActorModel |
169 | ] | 162 | ] |
170 | }, | 163 | }, |
171 | [ScopeNames.WITH_ACTOR]: { | 164 | [ScopeNames.WITH_VIDEOS]: { |
172 | include: [ | 165 | include: [ |
173 | ActorModel | 166 | VideoModel |
174 | ] | 167 | ] |
168 | }, | ||
169 | [ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => { | ||
170 | const daysPrior = parseInt(options.daysPrior + '', 10) | ||
171 | |||
172 | return { | ||
173 | attributes: { | ||
174 | include: [ | ||
175 | [ | ||
176 | literal( | ||
177 | '(' + | ||
178 | `SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` + | ||
179 | 'FROM ( ' + | ||
180 | 'WITH ' + | ||
181 | 'days AS ( ' + | ||
182 | `SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` + | ||
183 | `date_trunc('day', now()), '1 day'::interval) AS day ` + | ||
184 | '), ' + | ||
185 | 'views AS ( ' + | ||
186 | 'SELECT v.* ' + | ||
187 | 'FROM "videoView" AS v ' + | ||
188 | 'INNER JOIN "video" ON "video"."id" = v."videoId" ' + | ||
189 | 'WHERE "video"."channelId" = "VideoChannelModel"."id" ' + | ||
190 | ') ' + | ||
191 | 'SELECT days.day AS day, ' + | ||
192 | 'COALESCE(SUM(views.views), 0) AS views ' + | ||
193 | 'FROM days ' + | ||
194 | `LEFT JOIN views ON date_trunc('day', "views"."startDate") = date_trunc('day', days.day) ` + | ||
195 | 'GROUP BY day ' + | ||
196 | 'ORDER BY day ' + | ||
197 | ') t' + | ||
198 | ')' | ||
199 | ), | ||
200 | 'viewsPerDay' | ||
201 | ] | ||
202 | ] | ||
203 | } | ||
204 | } | ||
175 | } | 205 | } |
176 | })) | 206 | })) |
177 | @Table({ | 207 | @Table({ |
178 | tableName: 'videoChannel', | 208 | tableName: 'videoChannel', |
179 | indexes | 209 | indexes: [ |
210 | buildTrigramSearchIndex('video_channel_name_trigram', 'name'), | ||
211 | |||
212 | { | ||
213 | fields: [ 'accountId' ] | ||
214 | }, | ||
215 | { | ||
216 | fields: [ 'actorId' ] | ||
217 | } | ||
218 | ] | ||
180 | }) | 219 | }) |
181 | export class VideoChannelModel extends Model<VideoChannelModel> { | 220 | export class VideoChannelModel extends Model<VideoChannelModel> { |
182 | 221 | ||
@@ -351,10 +390,11 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
351 | } | 390 | } |
352 | 391 | ||
353 | static listByAccount (options: { | 392 | static listByAccount (options: { |
354 | accountId: number, | 393 | accountId: number |
355 | start: number, | 394 | start: number |
356 | count: number, | 395 | count: number |
357 | sort: string | 396 | sort: string |
397 | withStats?: boolean | ||
358 | }) { | 398 | }) { |
359 | const query = { | 399 | const query = { |
360 | offset: options.start, | 400 | offset: options.start, |
@@ -371,7 +411,16 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
371 | ] | 411 | ] |
372 | } | 412 | } |
373 | 413 | ||
414 | const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR ] | ||
415 | |||
416 | if (options.withStats) { | ||
417 | scopes.push({ | ||
418 | method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ] | ||
419 | }) | ||
420 | } | ||
421 | |||
374 | return VideoChannelModel | 422 | return VideoChannelModel |
423 | .scope(scopes) | ||
375 | .findAndCountAll(query) | 424 | .findAndCountAll(query) |
376 | .then(({ rows, count }) => { | 425 | .then(({ rows, count }) => { |
377 | return { total: count, data: rows } | 426 | return { total: count, data: rows } |
@@ -499,6 +548,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
499 | } | 548 | } |
500 | 549 | ||
501 | toFormattedJSON (this: MChannelFormattable): VideoChannel { | 550 | toFormattedJSON (this: MChannelFormattable): VideoChannel { |
551 | const viewsPerDay = this.get('viewsPerDay') as string | ||
552 | |||
502 | const actor = this.Actor.toFormattedJSON() | 553 | const actor = this.Actor.toFormattedJSON() |
503 | const videoChannel = { | 554 | const videoChannel = { |
504 | id: this.id, | 555 | id: this.id, |
@@ -508,7 +559,16 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
508 | isLocal: this.Actor.isOwned(), | 559 | isLocal: this.Actor.isOwned(), |
509 | createdAt: this.createdAt, | 560 | createdAt: this.createdAt, |
510 | updatedAt: this.updatedAt, | 561 | updatedAt: this.updatedAt, |
511 | ownerAccount: undefined | 562 | ownerAccount: undefined, |
563 | viewsPerDay: viewsPerDay !== undefined | ||
564 | ? viewsPerDay.split(',').map(v => { | ||
565 | const o = v.split('|') | ||
566 | return { | ||
567 | date: new Date(o[0]), | ||
568 | views: +o[1] | ||
569 | } | ||
570 | }) | ||
571 | : undefined | ||
512 | } | 572 | } |
513 | 573 | ||
514 | if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON() | 574 | if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON() |
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index fb4d16b4d..6d60271e6 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts | |||
@@ -9,7 +9,6 @@ import { ActorModel } from '../activitypub/actor' | |||
9 | import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils' | 9 | import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils' |
10 | import { VideoModel } from './video' | 10 | import { VideoModel } from './video' |
11 | import { VideoChannelModel } from './video-channel' | 11 | import { VideoChannelModel } from './video-channel' |
12 | import { getServerActor } from '../../helpers/utils' | ||
13 | import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' | 12 | import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' |
14 | import { regexpCapture } from '../../helpers/regexp' | 13 | import { regexpCapture } from '../../helpers/regexp' |
15 | import { uniq } from 'lodash' | 14 | import { uniq } from 'lodash' |
@@ -27,6 +26,8 @@ import { | |||
27 | MCommentOwnerVideoReply | 26 | MCommentOwnerVideoReply |
28 | } from '../../typings/models/video' | 27 | } from '../../typings/models/video' |
29 | import { MUserAccountId } from '@server/typings/models' | 28 | import { MUserAccountId } from '@server/typings/models' |
29 | import { VideoPrivacy } from '@shared/models' | ||
30 | import { getServerActor } from '@server/models/application/application' | ||
30 | 31 | ||
31 | enum ScopeNames { | 32 | enum ScopeNames { |
32 | WITH_ACCOUNT = 'WITH_ACCOUNT', | 33 | WITH_ACCOUNT = 'WITH_ACCOUNT', |
@@ -257,10 +258,10 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
257 | } | 258 | } |
258 | 259 | ||
259 | static async listThreadsForApi (parameters: { | 260 | static async listThreadsForApi (parameters: { |
260 | videoId: number, | 261 | videoId: number |
261 | start: number, | 262 | start: number |
262 | count: number, | 263 | count: number |
263 | sort: string, | 264 | sort: string |
264 | user?: MUserAccountId | 265 | user?: MUserAccountId |
265 | }) { | 266 | }) { |
266 | const { videoId, start, count, sort, user } = parameters | 267 | const { videoId, start, count, sort, user } = parameters |
@@ -300,8 +301,8 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
300 | } | 301 | } |
301 | 302 | ||
302 | static async listThreadCommentsForApi (parameters: { | 303 | static async listThreadCommentsForApi (parameters: { |
303 | videoId: number, | 304 | videoId: number |
304 | threadId: number, | 305 | threadId: number |
305 | user?: MUserAccountId | 306 | user?: MUserAccountId |
306 | }) { | 307 | }) { |
307 | const { videoId, threadId, user } = parameters | 308 | const { videoId, threadId, user } = parameters |
@@ -314,7 +315,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
314 | order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order, | 315 | order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order, |
315 | where: { | 316 | where: { |
316 | videoId, | 317 | videoId, |
317 | [ Op.or ]: [ | 318 | [Op.or]: [ |
318 | { id: threadId }, | 319 | { id: threadId }, |
319 | { originCommentId: threadId } | 320 | { originCommentId: threadId } |
320 | ], | 321 | ], |
@@ -346,7 +347,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
346 | order: [ [ 'createdAt', order ] ] as Order, | 347 | order: [ [ 'createdAt', order ] ] as Order, |
347 | where: { | 348 | where: { |
348 | id: { | 349 | id: { |
349 | [ Op.in ]: Sequelize.literal('(' + | 350 | [Op.in]: Sequelize.literal('(' + |
350 | 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' + | 351 | 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' + |
351 | `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` + | 352 | `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` + |
352 | 'UNION ' + | 353 | 'UNION ' + |
@@ -355,7 +356,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
355 | ') ' + | 356 | ') ' + |
356 | 'SELECT id FROM children' + | 357 | 'SELECT id FROM children' + |
357 | ')'), | 358 | ')'), |
358 | [ Op.ne ]: comment.id | 359 | [Op.ne]: comment.id |
359 | } | 360 | } |
360 | }, | 361 | }, |
361 | transaction: t | 362 | transaction: t |
@@ -380,17 +381,29 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
380 | return VideoCommentModel.findAndCountAll<MComment>(query) | 381 | return VideoCommentModel.findAndCountAll<MComment>(query) |
381 | } | 382 | } |
382 | 383 | ||
383 | static listForFeed (start: number, count: number, videoId?: number): Bluebird<MCommentOwnerVideoFeed[]> { | 384 | static async listForFeed (start: number, count: number, videoId?: number): Promise<MCommentOwnerVideoFeed[]> { |
385 | const serverActor = await getServerActor() | ||
386 | |||
384 | const query = { | 387 | const query = { |
385 | order: [ [ 'createdAt', 'DESC' ] ] as Order, | 388 | order: [ [ 'createdAt', 'DESC' ] ] as Order, |
386 | offset: start, | 389 | offset: start, |
387 | limit: count, | 390 | limit: count, |
388 | where: {}, | 391 | where: { |
392 | deletedAt: null, | ||
393 | accountId: { | ||
394 | [Op.notIn]: Sequelize.literal( | ||
395 | '(' + buildBlockedAccountSQL(serverActor.Account.id) + ')' | ||
396 | ) | ||
397 | } | ||
398 | }, | ||
389 | include: [ | 399 | include: [ |
390 | { | 400 | { |
391 | attributes: [ 'name', 'uuid' ], | 401 | attributes: [ 'name', 'uuid' ], |
392 | model: VideoModel.unscoped(), | 402 | model: VideoModel.unscoped(), |
393 | required: true | 403 | required: true, |
404 | where: { | ||
405 | privacy: VideoPrivacy.PUBLIC | ||
406 | } | ||
394 | } | 407 | } |
395 | ] | 408 | ] |
396 | } | 409 | } |
@@ -461,7 +474,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
461 | } | 474 | } |
462 | 475 | ||
463 | isDeleted () { | 476 | isDeleted () { |
464 | return null !== this.deletedAt | 477 | return this.deletedAt !== null |
465 | } | 478 | } |
466 | 479 | ||
467 | extractMentions () { | 480 | extractMentions () { |
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts index e08999385..201f0c0f1 100644 --- a/server/models/video/video-file.ts +++ b/server/models/video/video-file.ts | |||
@@ -10,7 +10,9 @@ import { | |||
10 | Is, | 10 | Is, |
11 | Model, | 11 | Model, |
12 | Table, | 12 | Table, |
13 | UpdatedAt | 13 | UpdatedAt, |
14 | Scopes, | ||
15 | DefaultScope | ||
14 | } from 'sequelize-typescript' | 16 | } from 'sequelize-typescript' |
15 | import { | 17 | import { |
16 | isVideoFileExtnameValid, | 18 | isVideoFileExtnameValid, |
@@ -28,7 +30,33 @@ import { MIMETYPES, MEMOIZE_LENGTH, MEMOIZE_TTL } from '../../initializers/const | |||
28 | import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../typings/models/video/video-file' | 30 | import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../typings/models/video/video-file' |
29 | import { MStreamingPlaylistVideo, MVideo } from '@server/typings/models' | 31 | import { MStreamingPlaylistVideo, MVideo } from '@server/typings/models' |
30 | import * as memoizee from 'memoizee' | 32 | import * as memoizee from 'memoizee' |
33 | import validator from 'validator' | ||
31 | 34 | ||
35 | export enum ScopeNames { | ||
36 | WITH_VIDEO = 'WITH_VIDEO', | ||
37 | WITH_METADATA = 'WITH_METADATA' | ||
38 | } | ||
39 | |||
40 | @DefaultScope(() => ({ | ||
41 | attributes: { | ||
42 | exclude: [ 'metadata' ] | ||
43 | } | ||
44 | })) | ||
45 | @Scopes(() => ({ | ||
46 | [ScopeNames.WITH_VIDEO]: { | ||
47 | include: [ | ||
48 | { | ||
49 | model: VideoModel.unscoped(), | ||
50 | required: true | ||
51 | } | ||
52 | ] | ||
53 | }, | ||
54 | [ScopeNames.WITH_METADATA]: { | ||
55 | attributes: { | ||
56 | include: [ 'metadata' ] | ||
57 | } | ||
58 | } | ||
59 | })) | ||
32 | @Table({ | 60 | @Table({ |
33 | tableName: 'videoFile', | 61 | tableName: 'videoFile', |
34 | indexes: [ | 62 | indexes: [ |
@@ -106,6 +134,14 @@ export class VideoFileModel extends Model<VideoFileModel> { | |||
106 | @Column | 134 | @Column |
107 | fps: number | 135 | fps: number |
108 | 136 | ||
137 | @AllowNull(true) | ||
138 | @Column(DataType.JSONB) | ||
139 | metadata: any | ||
140 | |||
141 | @AllowNull(true) | ||
142 | @Column | ||
143 | metadataUrl: string | ||
144 | |||
109 | @ForeignKey(() => VideoModel) | 145 | @ForeignKey(() => VideoModel) |
110 | @Column | 146 | @Column |
111 | videoId: number | 147 | videoId: number |
@@ -157,17 +193,56 @@ export class VideoFileModel extends Model<VideoFileModel> { | |||
157 | .then(results => results.length === 1) | 193 | .then(results => results.length === 1) |
158 | } | 194 | } |
159 | 195 | ||
196 | static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) { | ||
197 | const videoFile = await VideoFileModel.loadWithVideoOrPlaylist(id, videoIdOrUUID) | ||
198 | |||
199 | return !!videoFile | ||
200 | } | ||
201 | |||
202 | static loadWithMetadata (id: number) { | ||
203 | return VideoFileModel.scope(ScopeNames.WITH_METADATA).findByPk(id) | ||
204 | } | ||
205 | |||
160 | static loadWithVideo (id: number) { | 206 | static loadWithVideo (id: number) { |
207 | return VideoFileModel.scope(ScopeNames.WITH_VIDEO).findByPk(id) | ||
208 | } | ||
209 | |||
210 | static loadWithVideoOrPlaylist (id: number, videoIdOrUUID: number | string) { | ||
211 | const whereVideo = validator.isUUID(videoIdOrUUID + '') | ||
212 | ? { uuid: videoIdOrUUID } | ||
213 | : { id: videoIdOrUUID } | ||
214 | |||
161 | const options = { | 215 | const options = { |
216 | where: { | ||
217 | id | ||
218 | }, | ||
162 | include: [ | 219 | include: [ |
163 | { | 220 | { |
164 | model: VideoModel.unscoped(), | 221 | model: VideoModel.unscoped(), |
165 | required: true | 222 | required: false, |
223 | where: whereVideo | ||
224 | }, | ||
225 | { | ||
226 | model: VideoStreamingPlaylistModel.unscoped(), | ||
227 | required: false, | ||
228 | include: [ | ||
229 | { | ||
230 | model: VideoModel.unscoped(), | ||
231 | required: true, | ||
232 | where: whereVideo | ||
233 | } | ||
234 | ] | ||
166 | } | 235 | } |
167 | ] | 236 | ] |
168 | } | 237 | } |
169 | 238 | ||
170 | return VideoFileModel.findByPk(id, options) | 239 | return VideoFileModel.findOne(options) |
240 | .then(file => { | ||
241 | // We used `required: false` so check we have at least a video or a streaming playlist | ||
242 | if (!file.Video && !file.VideoStreamingPlaylist) return null | ||
243 | |||
244 | return file | ||
245 | }) | ||
171 | } | 246 | } |
172 | 247 | ||
173 | static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) { | 248 | static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) { |
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index 67395e5c0..d71a3a5db 100644 --- a/server/models/video/video-format-utils.ts +++ b/server/models/video/video-format-utils.ts | |||
@@ -8,7 +8,7 @@ import { | |||
8 | getVideoDislikesActivityPubUrl, | 8 | getVideoDislikesActivityPubUrl, |
9 | getVideoLikesActivityPubUrl, | 9 | getVideoLikesActivityPubUrl, |
10 | getVideoSharesActivityPubUrl | 10 | getVideoSharesActivityPubUrl |
11 | } from '../../lib/activitypub' | 11 | } from '../../lib/activitypub/url' |
12 | import { isArray } from '../../helpers/custom-validators/misc' | 12 | import { isArray } from '../../helpers/custom-validators/misc' |
13 | import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model' | 13 | import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model' |
14 | import { | 14 | import { |
@@ -23,16 +23,18 @@ import { | |||
23 | import { MVideoFileRedundanciesOpt } from '../../typings/models/video/video-file' | 23 | import { MVideoFileRedundanciesOpt } from '../../typings/models/video/video-file' |
24 | import { VideoFile } from '@shared/models/videos/video-file.model' | 24 | import { VideoFile } from '@shared/models/videos/video-file.model' |
25 | import { generateMagnetUri } from '@server/helpers/webtorrent' | 25 | import { generateMagnetUri } from '@server/helpers/webtorrent' |
26 | import { extractVideo } from '@server/helpers/video' | ||
26 | 27 | ||
27 | export type VideoFormattingJSONOptions = { | 28 | export type VideoFormattingJSONOptions = { |
28 | completeDescription?: boolean | 29 | completeDescription?: boolean |
29 | additionalAttributes: { | 30 | additionalAttributes: { |
30 | state?: boolean, | 31 | state?: boolean |
31 | waitTranscoding?: boolean, | 32 | waitTranscoding?: boolean |
32 | scheduledUpdate?: boolean, | 33 | scheduledUpdate?: boolean |
33 | blacklistInfo?: boolean | 34 | blacklistInfo?: boolean |
34 | } | 35 | } |
35 | } | 36 | } |
37 | |||
36 | function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFormattingJSONOptions): Video { | 38 | function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFormattingJSONOptions): Video { |
37 | const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined | 39 | const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined |
38 | 40 | ||
@@ -179,14 +181,14 @@ function videoFilesModelToFormattedJSON ( | |||
179 | baseUrlWs: string, | 181 | baseUrlWs: string, |
180 | videoFiles: MVideoFileRedundanciesOpt[] | 182 | videoFiles: MVideoFileRedundanciesOpt[] |
181 | ): VideoFile[] { | 183 | ): VideoFile[] { |
184 | const video = extractVideo(model) | ||
185 | |||
182 | return videoFiles | 186 | return videoFiles |
183 | .map(videoFile => { | 187 | .map(videoFile => { |
184 | let resolutionLabel = videoFile.resolution + 'p' | ||
185 | |||
186 | return { | 188 | return { |
187 | resolution: { | 189 | resolution: { |
188 | id: videoFile.resolution, | 190 | id: videoFile.resolution, |
189 | label: resolutionLabel | 191 | label: videoFile.resolution + 'p' |
190 | }, | 192 | }, |
191 | magnetUri: generateMagnetUri(model, videoFile, baseUrlHttp, baseUrlWs), | 193 | magnetUri: generateMagnetUri(model, videoFile, baseUrlHttp, baseUrlWs), |
192 | size: videoFile.size, | 194 | size: videoFile.size, |
@@ -194,7 +196,8 @@ function videoFilesModelToFormattedJSON ( | |||
194 | torrentUrl: model.getTorrentUrl(videoFile, baseUrlHttp), | 196 | torrentUrl: model.getTorrentUrl(videoFile, baseUrlHttp), |
195 | torrentDownloadUrl: model.getTorrentDownloadUrl(videoFile, baseUrlHttp), | 197 | torrentDownloadUrl: model.getTorrentDownloadUrl(videoFile, baseUrlHttp), |
196 | fileUrl: model.getVideoFileUrl(videoFile, baseUrlHttp), | 198 | fileUrl: model.getVideoFileUrl(videoFile, baseUrlHttp), |
197 | fileDownloadUrl: model.getVideoFileDownloadUrl(videoFile, baseUrlHttp) | 199 | fileDownloadUrl: model.getVideoFileDownloadUrl(videoFile, baseUrlHttp), |
200 | metadataUrl: video.getVideoFileMetadataUrl(videoFile, baseUrlHttp) | ||
198 | } as VideoFile | 201 | } as VideoFile |
199 | }) | 202 | }) |
200 | .sort((a, b) => { | 203 | .sort((a, b) => { |
@@ -214,7 +217,7 @@ function addVideoFilesInAPAcc ( | |||
214 | for (const file of files) { | 217 | for (const file of files) { |
215 | acc.push({ | 218 | acc.push({ |
216 | type: 'Link', | 219 | type: 'Link', |
217 | mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ file.extname ] as any, | 220 | mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] as any, |
218 | href: model.getVideoFileUrl(file, baseUrlHttp), | 221 | href: model.getVideoFileUrl(file, baseUrlHttp), |
219 | height: file.resolution, | 222 | height: file.resolution, |
220 | size: file.size, | 223 | size: file.size, |
@@ -223,6 +226,15 @@ function addVideoFilesInAPAcc ( | |||
223 | 226 | ||
224 | acc.push({ | 227 | acc.push({ |
225 | type: 'Link', | 228 | type: 'Link', |
229 | rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ], | ||
230 | mediaType: 'application/json' as 'application/json', | ||
231 | href: extractVideo(model).getVideoFileMetadataUrl(file, baseUrlHttp), | ||
232 | height: file.resolution, | ||
233 | fps: file.fps | ||
234 | }) | ||
235 | |||
236 | acc.push({ | ||
237 | type: 'Link', | ||
226 | mediaType: 'application/x-bittorrent' as 'application/x-bittorrent', | 238 | mediaType: 'application/x-bittorrent' as 'application/x-bittorrent', |
227 | href: model.getTorrentUrl(file, baseUrlHttp), | 239 | href: model.getTorrentUrl(file, baseUrlHttp), |
228 | height: file.resolution | 240 | height: file.resolution |
@@ -282,10 +294,8 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoTorrentObject { | |||
282 | addVideoFilesInAPAcc(url, video, baseUrlHttp, baseUrlWs, video.VideoFiles || []) | 294 | addVideoFilesInAPAcc(url, video, baseUrlHttp, baseUrlWs, video.VideoFiles || []) |
283 | 295 | ||
284 | for (const playlist of (video.VideoStreamingPlaylists || [])) { | 296 | for (const playlist of (video.VideoStreamingPlaylists || [])) { |
285 | let tag: ActivityTagObject[] | 297 | const tag = playlist.p2pMediaLoaderInfohashes |
286 | 298 | .map(i => ({ type: 'Infohash' as 'Infohash', name: i })) as ActivityTagObject[] | |
287 | tag = playlist.p2pMediaLoaderInfohashes | ||
288 | .map(i => ({ type: 'Infohash' as 'Infohash', name: i })) | ||
289 | tag.push({ | 299 | tag.push({ |
290 | type: 'Link', | 300 | type: 'Link', |
291 | name: 'sha256', | 301 | name: 'sha256', |
@@ -308,10 +318,14 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoTorrentObject { | |||
308 | for (const caption of video.VideoCaptions) { | 318 | for (const caption of video.VideoCaptions) { |
309 | subtitleLanguage.push({ | 319 | subtitleLanguage.push({ |
310 | identifier: caption.language, | 320 | identifier: caption.language, |
311 | name: VideoCaptionModel.getLanguageLabel(caption.language) | 321 | name: VideoCaptionModel.getLanguageLabel(caption.language), |
322 | url: caption.getFileUrl(video) | ||
312 | }) | 323 | }) |
313 | } | 324 | } |
314 | 325 | ||
326 | // FIXME: remove and uncomment in PT 2.3 | ||
327 | // Breaks compatibility with PT <= 2.1 | ||
328 | // const icons = [ video.getMiniature(), video.getPreview() ] | ||
315 | const miniature = video.getMiniature() | 329 | const miniature = video.getMiniature() |
316 | 330 | ||
317 | return { | 331 | return { |
@@ -339,11 +353,18 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoTorrentObject { | |||
339 | subtitleLanguage, | 353 | subtitleLanguage, |
340 | icon: { | 354 | icon: { |
341 | type: 'Image', | 355 | type: 'Image', |
342 | url: miniature.getFileUrl(video.isOwned()), | 356 | url: miniature.getFileUrl(video), |
343 | mediaType: 'image/jpeg', | 357 | mediaType: 'image/jpeg', |
344 | width: miniature.width, | 358 | width: miniature.width, |
345 | height: miniature.height | 359 | height: miniature.height |
346 | }, | 360 | } as any, |
361 | // icon: icons.map(i => ({ | ||
362 | // type: 'Image', | ||
363 | // url: i.getFileUrl(video), | ||
364 | // mediaType: 'image/jpeg', | ||
365 | // width: i.width, | ||
366 | // height: i.height | ||
367 | // })), | ||
347 | url, | 368 | url, |
348 | likes: getVideoLikesActivityPubUrl(video), | 369 | likes: getVideoLikesActivityPubUrl(video), |
349 | dislikes: getVideoDislikesActivityPubUrl(video), | 370 | dislikes: getVideoDislikesActivityPubUrl(video), |
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts index af5314ce9..fbe0ee0a7 100644 --- a/server/models/video/video-import.ts +++ b/server/models/video/video-import.ts | |||
@@ -129,6 +129,7 @@ export class VideoImportModel extends Model<VideoImportModel> { | |||
129 | distinct: true, | 129 | distinct: true, |
130 | include: [ | 130 | include: [ |
131 | { | 131 | { |
132 | attributes: [ 'id' ], | ||
132 | model: UserModel.unscoped(), // FIXME: Without this, sequelize try to COUNT(DISTINCT(*)) which is an invalid SQL query | 133 | model: UserModel.unscoped(), // FIXME: Without this, sequelize try to COUNT(DISTINCT(*)) which is an invalid SQL query |
133 | required: true | 134 | required: true |
134 | } | 135 | } |
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts index f2d71357f..9ea73e82e 100644 --- a/server/models/video/video-playlist-element.ts +++ b/server/models/video/video-playlist-element.ts | |||
@@ -120,10 +120,10 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel> | |||
120 | } | 120 | } |
121 | 121 | ||
122 | static listForApi (options: { | 122 | static listForApi (options: { |
123 | start: number, | 123 | start: number |
124 | count: number, | 124 | count: number |
125 | videoPlaylistId: number, | 125 | videoPlaylistId: number |
126 | serverAccount: AccountModel, | 126 | serverAccount: AccountModel |
127 | user?: MUserAccountId | 127 | user?: MUserAccountId |
128 | }) { | 128 | }) { |
129 | const accountIds = [ options.serverAccount.id ] | 129 | const accountIds = [ options.serverAccount.id ] |
@@ -309,7 +309,10 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel> | |||
309 | // Owned video, don't filter it | 309 | // Owned video, don't filter it |
310 | if (accountId && video.VideoChannel.Account.id === accountId) return VideoPlaylistElementType.REGULAR | 310 | if (accountId && video.VideoChannel.Account.id === accountId) return VideoPlaylistElementType.REGULAR |
311 | 311 | ||
312 | if (video.privacy === VideoPrivacy.PRIVATE) return VideoPlaylistElementType.PRIVATE | 312 | // Internal video? |
313 | if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR | ||
314 | |||
315 | if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE | ||
313 | 316 | ||
314 | if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE | 317 | if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE |
315 | if (video.nsfw === true && displayNSFW === false) return VideoPlaylistElementType.UNAVAILABLE | 318 | if (video.nsfw === true && displayNSFW === false) return VideoPlaylistElementType.UNAVAILABLE |
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index bcdda36e5..b9b95e067 100644 --- a/server/models/video/video-playlist.ts +++ b/server/models/video/video-playlist.ts | |||
@@ -68,12 +68,12 @@ type AvailableForListOptions = { | |||
68 | type?: VideoPlaylistType | 68 | type?: VideoPlaylistType |
69 | accountId?: number | 69 | accountId?: number |
70 | videoChannelId?: number | 70 | videoChannelId?: number |
71 | listMyPlaylists?: boolean, | 71 | listMyPlaylists?: boolean |
72 | search?: string | 72 | search?: string |
73 | } | 73 | } |
74 | 74 | ||
75 | @Scopes(() => ({ | 75 | @Scopes(() => ({ |
76 | [ ScopeNames.WITH_THUMBNAIL ]: { | 76 | [ScopeNames.WITH_THUMBNAIL]: { |
77 | include: [ | 77 | include: [ |
78 | { | 78 | { |
79 | model: ThumbnailModel, | 79 | model: ThumbnailModel, |
@@ -81,7 +81,7 @@ type AvailableForListOptions = { | |||
81 | } | 81 | } |
82 | ] | 82 | ] |
83 | }, | 83 | }, |
84 | [ ScopeNames.WITH_VIDEOS_LENGTH ]: { | 84 | [ScopeNames.WITH_VIDEOS_LENGTH]: { |
85 | attributes: { | 85 | attributes: { |
86 | include: [ | 86 | include: [ |
87 | [ | 87 | [ |
@@ -91,7 +91,7 @@ type AvailableForListOptions = { | |||
91 | ] | 91 | ] |
92 | } | 92 | } |
93 | } as FindOptions, | 93 | } as FindOptions, |
94 | [ ScopeNames.WITH_ACCOUNT ]: { | 94 | [ScopeNames.WITH_ACCOUNT]: { |
95 | include: [ | 95 | include: [ |
96 | { | 96 | { |
97 | model: AccountModel, | 97 | model: AccountModel, |
@@ -99,7 +99,7 @@ type AvailableForListOptions = { | |||
99 | } | 99 | } |
100 | ] | 100 | ] |
101 | }, | 101 | }, |
102 | [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY ]: { | 102 | [ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY]: { |
103 | include: [ | 103 | include: [ |
104 | { | 104 | { |
105 | model: AccountModel.scope(AccountScopeNames.SUMMARY), | 105 | model: AccountModel.scope(AccountScopeNames.SUMMARY), |
@@ -111,7 +111,7 @@ type AvailableForListOptions = { | |||
111 | } | 111 | } |
112 | ] | 112 | ] |
113 | }, | 113 | }, |
114 | [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL ]: { | 114 | [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: { |
115 | include: [ | 115 | include: [ |
116 | { | 116 | { |
117 | model: AccountModel, | 117 | model: AccountModel, |
@@ -123,7 +123,7 @@ type AvailableForListOptions = { | |||
123 | } | 123 | } |
124 | ] | 124 | ] |
125 | }, | 125 | }, |
126 | [ ScopeNames.AVAILABLE_FOR_LIST ]: (options: AvailableForListOptions) => { | 126 | [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { |
127 | 127 | ||
128 | let whereActor: WhereOptions = {} | 128 | let whereActor: WhereOptions = {} |
129 | 129 | ||
@@ -138,13 +138,13 @@ type AvailableForListOptions = { | |||
138 | const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId) | 138 | const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId) |
139 | 139 | ||
140 | whereActor = { | 140 | whereActor = { |
141 | [ Op.or ]: [ | 141 | [Op.or]: [ |
142 | { | 142 | { |
143 | serverId: null | 143 | serverId: null |
144 | }, | 144 | }, |
145 | { | 145 | { |
146 | serverId: { | 146 | serverId: { |
147 | [ Op.in ]: literal(inQueryInstanceFollow) | 147 | [Op.in]: literal(inQueryInstanceFollow) |
148 | } | 148 | } |
149 | } | 149 | } |
150 | ] | 150 | ] |
@@ -172,7 +172,7 @@ type AvailableForListOptions = { | |||
172 | if (options.search) { | 172 | if (options.search) { |
173 | whereAnd.push({ | 173 | whereAnd.push({ |
174 | name: { | 174 | name: { |
175 | [ Op.iLike ]: '%' + options.search + '%' | 175 | [Op.iLike]: '%' + options.search + '%' |
176 | } | 176 | } |
177 | }) | 177 | }) |
178 | } | 178 | } |
@@ -230,7 +230,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> { | |||
230 | 230 | ||
231 | @AllowNull(true) | 231 | @AllowNull(true) |
232 | @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true)) | 232 | @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true)) |
233 | @Column | 233 | @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.DESCRIPTION.max)) |
234 | description: string | 234 | description: string |
235 | 235 | ||
236 | @AllowNull(false) | 236 | @AllowNull(false) |
@@ -299,13 +299,13 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> { | |||
299 | 299 | ||
300 | static listForApi (options: { | 300 | static listForApi (options: { |
301 | followerActorId: number | 301 | followerActorId: number |
302 | start: number, | 302 | start: number |
303 | count: number, | 303 | count: number |
304 | sort: string, | 304 | sort: string |
305 | type?: VideoPlaylistType, | 305 | type?: VideoPlaylistType |
306 | accountId?: number, | 306 | accountId?: number |
307 | videoChannelId?: number, | 307 | videoChannelId?: number |
308 | listMyPlaylists?: boolean, | 308 | listMyPlaylists?: boolean |
309 | search?: string | 309 | search?: string |
310 | }) { | 310 | }) { |
311 | const query = { | 311 | const query = { |
@@ -369,7 +369,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> { | |||
369 | model: VideoPlaylistElementModel.unscoped(), | 369 | model: VideoPlaylistElementModel.unscoped(), |
370 | where: { | 370 | where: { |
371 | videoId: { | 371 | videoId: { |
372 | [Op.in]: videoIds // FIXME: sequelize ANY seems broken | 372 | [Op.in]: videoIds |
373 | } | 373 | } |
374 | }, | 374 | }, |
375 | required: true | 375 | required: true |
@@ -522,7 +522,9 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> { | |||
522 | updatedAt: this.updatedAt, | 522 | updatedAt: this.updatedAt, |
523 | 523 | ||
524 | ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(), | 524 | ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(), |
525 | videoChannel: this.VideoChannel ? this.VideoChannel.toFormattedSummaryJSON() : null | 525 | videoChannel: this.VideoChannel |
526 | ? this.VideoChannel.toFormattedSummaryJSON() | ||
527 | : null | ||
526 | } | 528 | } |
527 | } | 529 | } |
528 | 530 | ||
diff --git a/server/models/video/video-query-builder.ts b/server/models/video/video-query-builder.ts new file mode 100644 index 000000000..455f9f30f --- /dev/null +++ b/server/models/video/video-query-builder.ts | |||
@@ -0,0 +1,503 @@ | |||
1 | import { VideoFilter, VideoPrivacy, VideoState } from '@shared/models' | ||
2 | import { buildDirectionAndField, createSafeIn } from '@server/models/utils' | ||
3 | import { Model } from 'sequelize-typescript' | ||
4 | import { MUserAccountId, MUserId } from '@server/typings/models' | ||
5 | import validator from 'validator' | ||
6 | import { exists } from '@server/helpers/custom-validators/misc' | ||
7 | |||
8 | export type BuildVideosQueryOptions = { | ||
9 | attributes?: string[] | ||
10 | |||
11 | serverAccountId: number | ||
12 | followerActorId: number | ||
13 | includeLocalVideos: boolean | ||
14 | |||
15 | count: number | ||
16 | start: number | ||
17 | sort: string | ||
18 | |||
19 | filter?: VideoFilter | ||
20 | categoryOneOf?: number[] | ||
21 | nsfw?: boolean | ||
22 | licenceOneOf?: number[] | ||
23 | languageOneOf?: string[] | ||
24 | tagsOneOf?: string[] | ||
25 | tagsAllOf?: string[] | ||
26 | |||
27 | withFiles?: boolean | ||
28 | |||
29 | accountId?: number | ||
30 | videoChannelId?: number | ||
31 | |||
32 | videoPlaylistId?: number | ||
33 | |||
34 | trendingDays?: number | ||
35 | user?: MUserAccountId | ||
36 | historyOfUser?: MUserId | ||
37 | |||
38 | startDate?: string // ISO 8601 | ||
39 | endDate?: string // ISO 8601 | ||
40 | originallyPublishedStartDate?: string | ||
41 | originallyPublishedEndDate?: string | ||
42 | |||
43 | durationMin?: number // seconds | ||
44 | durationMax?: number // seconds | ||
45 | |||
46 | search?: string | ||
47 | |||
48 | isCount?: boolean | ||
49 | |||
50 | group?: string | ||
51 | having?: string | ||
52 | } | ||
53 | |||
54 | function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions) { | ||
55 | const and: string[] = [] | ||
56 | const joins: string[] = [] | ||
57 | const replacements: any = {} | ||
58 | const cte: string[] = [] | ||
59 | |||
60 | let attributes: string[] = options.attributes || [ '"video"."id"' ] | ||
61 | let group = options.group || '' | ||
62 | const having = options.having || '' | ||
63 | |||
64 | joins.push( | ||
65 | 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId"' + | ||
66 | 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId"' + | ||
67 | 'INNER JOIN "actor" "accountActor" ON "account"."actorId" = "accountActor"."id"' | ||
68 | ) | ||
69 | |||
70 | and.push('"video"."id" NOT IN (SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")') | ||
71 | |||
72 | if (options.serverAccountId) { | ||
73 | const blockerIds = [ options.serverAccountId ] | ||
74 | if (options.user) blockerIds.push(options.user.Account.id) | ||
75 | |||
76 | const inClause = createSafeIn(model, blockerIds) | ||
77 | |||
78 | and.push( | ||
79 | 'NOT EXISTS (' + | ||
80 | ' SELECT 1 FROM "accountBlocklist" ' + | ||
81 | ' WHERE "accountBlocklist"."accountId" IN (' + inClause + ') ' + | ||
82 | ' AND "accountBlocklist"."targetAccountId" = "account"."id" ' + | ||
83 | ')' + | ||
84 | 'AND NOT EXISTS (' + | ||
85 | ' SELECT 1 FROM "serverBlocklist" WHERE "serverBlocklist"."accountId" IN (' + inClause + ') ' + | ||
86 | ' AND "serverBlocklist"."targetServerId" = "accountActor"."serverId"' + | ||
87 | ')' | ||
88 | ) | ||
89 | } | ||
90 | |||
91 | // Only list public/published videos | ||
92 | if (!options.filter || options.filter !== 'all-local') { | ||
93 | and.push( | ||
94 | `("video"."state" = ${VideoState.PUBLISHED} OR ` + | ||
95 | `("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))` | ||
96 | ) | ||
97 | |||
98 | if (options.user) { | ||
99 | and.push( | ||
100 | `("video"."privacy" = ${VideoPrivacy.PUBLIC} OR "video"."privacy" = ${VideoPrivacy.INTERNAL})` | ||
101 | ) | ||
102 | } else { // Or only public videos | ||
103 | and.push( | ||
104 | `"video"."privacy" = ${VideoPrivacy.PUBLIC}` | ||
105 | ) | ||
106 | } | ||
107 | } | ||
108 | |||
109 | if (options.videoPlaylistId) { | ||
110 | joins.push( | ||
111 | 'INNER JOIN "videoPlaylistElement" "video"."id" = "videoPlaylistElement"."videoId" ' + | ||
112 | 'AND "videoPlaylistElement"."videoPlaylistId" = :videoPlaylistId' | ||
113 | ) | ||
114 | |||
115 | replacements.videoPlaylistId = options.videoPlaylistId | ||
116 | } | ||
117 | |||
118 | if (options.filter && (options.filter === 'local' || options.filter === 'all-local')) { | ||
119 | and.push('"video"."remote" IS FALSE') | ||
120 | } | ||
121 | |||
122 | if (options.accountId) { | ||
123 | and.push('"account"."id" = :accountId') | ||
124 | replacements.accountId = options.accountId | ||
125 | } | ||
126 | |||
127 | if (options.videoChannelId) { | ||
128 | and.push('"videoChannel"."id" = :videoChannelId') | ||
129 | replacements.videoChannelId = options.videoChannelId | ||
130 | } | ||
131 | |||
132 | if (options.followerActorId) { | ||
133 | let query = | ||
134 | '(' + | ||
135 | ' EXISTS (' + | ||
136 | ' SELECT 1 FROM "videoShare" ' + | ||
137 | ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' + | ||
138 | ' AND "actorFollowShare"."actorId" = :followerActorId WHERE "videoShare"."videoId" = "video"."id"' + | ||
139 | ' )' + | ||
140 | ' OR' + | ||
141 | ' EXISTS (' + | ||
142 | ' SELECT 1 from "actorFollow" ' + | ||
143 | ' WHERE "actorFollow"."targetActorId" = "videoChannel"."actorId" AND "actorFollow"."actorId" = :followerActorId' + | ||
144 | ' )' | ||
145 | |||
146 | if (options.includeLocalVideos) { | ||
147 | query += ' OR "video"."remote" IS FALSE' | ||
148 | } | ||
149 | |||
150 | query += ')' | ||
151 | |||
152 | and.push(query) | ||
153 | replacements.followerActorId = options.followerActorId | ||
154 | } | ||
155 | |||
156 | if (options.withFiles === true) { | ||
157 | and.push('EXISTS (SELECT 1 FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id")') | ||
158 | } | ||
159 | |||
160 | if (options.tagsOneOf) { | ||
161 | const tagsOneOfLower = options.tagsOneOf.map(t => t.toLowerCase()) | ||
162 | |||
163 | and.push( | ||
164 | 'EXISTS (' + | ||
165 | ' SELECT 1 FROM "videoTag" ' + | ||
166 | ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + | ||
167 | ' WHERE lower("tag"."name") IN (' + createSafeIn(model, tagsOneOfLower) + ') ' + | ||
168 | ' AND "video"."id" = "videoTag"."videoId"' + | ||
169 | ')' | ||
170 | ) | ||
171 | } | ||
172 | |||
173 | if (options.tagsAllOf) { | ||
174 | const tagsAllOfLower = options.tagsAllOf.map(t => t.toLowerCase()) | ||
175 | |||
176 | and.push( | ||
177 | 'EXISTS (' + | ||
178 | ' SELECT 1 FROM "videoTag" ' + | ||
179 | ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + | ||
180 | ' WHERE lower("tag"."name") IN (' + createSafeIn(model, tagsAllOfLower) + ') ' + | ||
181 | ' AND "video"."id" = "videoTag"."videoId" ' + | ||
182 | ' GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length + | ||
183 | ')' | ||
184 | ) | ||
185 | } | ||
186 | |||
187 | if (options.nsfw === true) { | ||
188 | and.push('"video"."nsfw" IS TRUE') | ||
189 | } | ||
190 | |||
191 | if (options.nsfw === false) { | ||
192 | and.push('"video"."nsfw" IS FALSE') | ||
193 | } | ||
194 | |||
195 | if (options.categoryOneOf) { | ||
196 | and.push('"video"."category" IN (:categoryOneOf)') | ||
197 | replacements.categoryOneOf = options.categoryOneOf | ||
198 | } | ||
199 | |||
200 | if (options.licenceOneOf) { | ||
201 | and.push('"video"."licence" IN (:licenceOneOf)') | ||
202 | replacements.licenceOneOf = options.licenceOneOf | ||
203 | } | ||
204 | |||
205 | if (options.languageOneOf) { | ||
206 | const languages = options.languageOneOf.filter(l => l && l !== '_unknown') | ||
207 | const languagesQueryParts: string[] = [] | ||
208 | |||
209 | if (languages.length !== 0) { | ||
210 | languagesQueryParts.push('"video"."language" IN (:languageOneOf)') | ||
211 | replacements.languageOneOf = languages | ||
212 | |||
213 | languagesQueryParts.push( | ||
214 | 'EXISTS (' + | ||
215 | ' SELECT 1 FROM "videoCaption" WHERE "videoCaption"."language" ' + | ||
216 | ' IN (' + createSafeIn(model, languages) + ') AND ' + | ||
217 | ' "videoCaption"."videoId" = "video"."id"' + | ||
218 | ')' | ||
219 | ) | ||
220 | } | ||
221 | |||
222 | if (options.languageOneOf.includes('_unknown')) { | ||
223 | languagesQueryParts.push('"video"."language" IS NULL') | ||
224 | } | ||
225 | |||
226 | if (languagesQueryParts.length !== 0) { | ||
227 | and.push('(' + languagesQueryParts.join(' OR ') + ')') | ||
228 | } | ||
229 | } | ||
230 | |||
231 | // We don't exclude results in this if so if we do a count we don't need to add this complex clauses | ||
232 | if (options.trendingDays && options.isCount !== true) { | ||
233 | const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * options.trendingDays) | ||
234 | |||
235 | joins.push('LEFT JOIN "videoView" ON "video"."id" = "videoView"."videoId" AND "videoView"."startDate" >= :viewsGteDate') | ||
236 | replacements.viewsGteDate = viewsGteDate | ||
237 | |||
238 | attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "videoViewsSum"') | ||
239 | |||
240 | group = 'GROUP BY "video"."id"' | ||
241 | } | ||
242 | |||
243 | if (options.historyOfUser) { | ||
244 | joins.push('INNER JOIN "userVideoHistory" on "video"."id" = "userVideoHistory"."videoId"') | ||
245 | |||
246 | and.push('"userVideoHistory"."userId" = :historyOfUser') | ||
247 | replacements.historyOfUser = options.historyOfUser.id | ||
248 | } | ||
249 | |||
250 | if (options.startDate) { | ||
251 | and.push('"video"."publishedAt" >= :startDate') | ||
252 | replacements.startDate = options.startDate | ||
253 | } | ||
254 | |||
255 | if (options.endDate) { | ||
256 | and.push('"video"."publishedAt" <= :endDate') | ||
257 | replacements.endDate = options.endDate | ||
258 | } | ||
259 | |||
260 | if (options.originallyPublishedStartDate) { | ||
261 | and.push('"video"."originallyPublishedAt" >= :originallyPublishedStartDate') | ||
262 | replacements.originallyPublishedStartDate = options.originallyPublishedStartDate | ||
263 | } | ||
264 | |||
265 | if (options.originallyPublishedEndDate) { | ||
266 | and.push('"video"."originallyPublishedAt" <= :originallyPublishedEndDate') | ||
267 | replacements.originallyPublishedEndDate = options.originallyPublishedEndDate | ||
268 | } | ||
269 | |||
270 | if (options.durationMin) { | ||
271 | and.push('"video"."duration" >= :durationMin') | ||
272 | replacements.durationMin = options.durationMin | ||
273 | } | ||
274 | |||
275 | if (options.durationMax) { | ||
276 | and.push('"video"."duration" <= :durationMax') | ||
277 | replacements.durationMax = options.durationMax | ||
278 | } | ||
279 | |||
280 | if (options.search) { | ||
281 | const escapedSearch = model.sequelize.escape(options.search) | ||
282 | const escapedLikeSearch = model.sequelize.escape('%' + options.search + '%') | ||
283 | |||
284 | cte.push( | ||
285 | '"trigramSearch" AS (' + | ||
286 | ' SELECT "video"."id", ' + | ||
287 | ` similarity(lower(immutable_unaccent("video"."name")), lower(immutable_unaccent(${escapedSearch}))) as similarity ` + | ||
288 | ' FROM "video" ' + | ||
289 | ' WHERE lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' + | ||
290 | ' lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' + | ||
291 | ')' | ||
292 | ) | ||
293 | |||
294 | joins.push('LEFT JOIN "trigramSearch" ON "video"."id" = "trigramSearch"."id"') | ||
295 | |||
296 | let base = '(' + | ||
297 | ' "trigramSearch"."id" IS NOT NULL OR ' + | ||
298 | ' EXISTS (' + | ||
299 | ' SELECT 1 FROM "videoTag" ' + | ||
300 | ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + | ||
301 | ` WHERE lower("tag"."name") = ${escapedSearch} ` + | ||
302 | ' AND "video"."id" = "videoTag"."videoId"' + | ||
303 | ' )' | ||
304 | |||
305 | if (validator.isUUID(options.search)) { | ||
306 | base += ` OR "video"."uuid" = ${escapedSearch}` | ||
307 | } | ||
308 | |||
309 | base += ')' | ||
310 | and.push(base) | ||
311 | |||
312 | attributes.push(`COALESCE("trigramSearch"."similarity", 0) as similarity`) | ||
313 | } else { | ||
314 | attributes.push('0 as similarity') | ||
315 | } | ||
316 | |||
317 | if (options.isCount === true) attributes = [ 'COUNT(*) as "total"' ] | ||
318 | |||
319 | let suffix = '' | ||
320 | let order = '' | ||
321 | if (options.isCount !== true) { | ||
322 | |||
323 | if (exists(options.sort)) { | ||
324 | if (options.sort === '-originallyPublishedAt' || options.sort === 'originallyPublishedAt') { | ||
325 | attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"') | ||
326 | } | ||
327 | |||
328 | order = buildOrder(model, options.sort) | ||
329 | suffix += `${order} ` | ||
330 | } | ||
331 | |||
332 | if (exists(options.count)) { | ||
333 | const count = parseInt(options.count + '', 10) | ||
334 | suffix += `LIMIT ${count} ` | ||
335 | } | ||
336 | |||
337 | if (exists(options.start)) { | ||
338 | const start = parseInt(options.start + '', 10) | ||
339 | suffix += `OFFSET ${start} ` | ||
340 | } | ||
341 | } | ||
342 | |||
343 | const cteString = cte.length !== 0 | ||
344 | ? `WITH ${cte.join(', ')} ` | ||
345 | : '' | ||
346 | |||
347 | const query = cteString + | ||
348 | 'SELECT ' + attributes.join(', ') + ' ' + | ||
349 | 'FROM "video" ' + joins.join(' ') + ' ' + | ||
350 | 'WHERE ' + and.join(' AND ') + ' ' + | ||
351 | group + ' ' + | ||
352 | having + ' ' + | ||
353 | suffix | ||
354 | |||
355 | return { query, replacements, order } | ||
356 | } | ||
357 | |||
358 | function buildOrder (model: typeof Model, value: string) { | ||
359 | const { direction, field } = buildDirectionAndField(value) | ||
360 | if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field) | ||
361 | |||
362 | if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()' | ||
363 | |||
364 | if (field.toLowerCase() === 'trending') { // Sort by aggregation | ||
365 | return `ORDER BY "videoViewsSum" ${direction}, "video"."views" ${direction}` | ||
366 | } | ||
367 | |||
368 | let firstSort: string | ||
369 | |||
370 | if (field.toLowerCase() === 'match') { // Search | ||
371 | firstSort = '"similarity"' | ||
372 | } else if (field === 'originallyPublishedAt') { | ||
373 | firstSort = '"publishedAtForOrder"' | ||
374 | } else if (field.includes('.')) { | ||
375 | firstSort = field | ||
376 | } else { | ||
377 | firstSort = `"video"."${field}"` | ||
378 | } | ||
379 | |||
380 | return `ORDER BY ${firstSort} ${direction}, "video"."id" ASC` | ||
381 | } | ||
382 | |||
383 | function wrapForAPIResults (baseQuery: string, replacements: any, options: BuildVideosQueryOptions, order: string) { | ||
384 | const attributes = { | ||
385 | '"video".*': '', | ||
386 | '"VideoChannel"."id"': '"VideoChannel.id"', | ||
387 | '"VideoChannel"."name"': '"VideoChannel.name"', | ||
388 | '"VideoChannel"."description"': '"VideoChannel.description"', | ||
389 | '"VideoChannel"."actorId"': '"VideoChannel.actorId"', | ||
390 | '"VideoChannel->Actor"."id"': '"VideoChannel.Actor.id"', | ||
391 | '"VideoChannel->Actor"."preferredUsername"': '"VideoChannel.Actor.preferredUsername"', | ||
392 | '"VideoChannel->Actor"."url"': '"VideoChannel.Actor.url"', | ||
393 | '"VideoChannel->Actor"."serverId"': '"VideoChannel.Actor.serverId"', | ||
394 | '"VideoChannel->Actor"."avatarId"': '"VideoChannel.Actor.avatarId"', | ||
395 | '"VideoChannel->Account"."id"': '"VideoChannel.Account.id"', | ||
396 | '"VideoChannel->Account"."name"': '"VideoChannel.Account.name"', | ||
397 | '"VideoChannel->Account->Actor"."id"': '"VideoChannel.Account.Actor.id"', | ||
398 | '"VideoChannel->Account->Actor"."preferredUsername"': '"VideoChannel.Account.Actor.preferredUsername"', | ||
399 | '"VideoChannel->Account->Actor"."url"': '"VideoChannel.Account.Actor.url"', | ||
400 | '"VideoChannel->Account->Actor"."serverId"': '"VideoChannel.Account.Actor.serverId"', | ||
401 | '"VideoChannel->Account->Actor"."avatarId"': '"VideoChannel.Account.Actor.avatarId"', | ||
402 | '"VideoChannel->Actor->Server"."id"': '"VideoChannel.Actor.Server.id"', | ||
403 | '"VideoChannel->Actor->Server"."host"': '"VideoChannel.Actor.Server.host"', | ||
404 | '"VideoChannel->Actor->Avatar"."id"': '"VideoChannel.Actor.Avatar.id"', | ||
405 | '"VideoChannel->Actor->Avatar"."filename"': '"VideoChannel.Actor.Avatar.filename"', | ||
406 | '"VideoChannel->Actor->Avatar"."fileUrl"': '"VideoChannel.Actor.Avatar.fileUrl"', | ||
407 | '"VideoChannel->Actor->Avatar"."onDisk"': '"VideoChannel.Actor.Avatar.onDisk"', | ||
408 | '"VideoChannel->Actor->Avatar"."createdAt"': '"VideoChannel.Actor.Avatar.createdAt"', | ||
409 | '"VideoChannel->Actor->Avatar"."updatedAt"': '"VideoChannel.Actor.Avatar.updatedAt"', | ||
410 | '"VideoChannel->Account->Actor->Server"."id"': '"VideoChannel.Account.Actor.Server.id"', | ||
411 | '"VideoChannel->Account->Actor->Server"."host"': '"VideoChannel.Account.Actor.Server.host"', | ||
412 | '"VideoChannel->Account->Actor->Avatar"."id"': '"VideoChannel.Account.Actor.Avatar.id"', | ||
413 | '"VideoChannel->Account->Actor->Avatar"."filename"': '"VideoChannel.Account.Actor.Avatar.filename"', | ||
414 | '"VideoChannel->Account->Actor->Avatar"."fileUrl"': '"VideoChannel.Account.Actor.Avatar.fileUrl"', | ||
415 | '"VideoChannel->Account->Actor->Avatar"."onDisk"': '"VideoChannel.Account.Actor.Avatar.onDisk"', | ||
416 | '"VideoChannel->Account->Actor->Avatar"."createdAt"': '"VideoChannel.Account.Actor.Avatar.createdAt"', | ||
417 | '"VideoChannel->Account->Actor->Avatar"."updatedAt"': '"VideoChannel.Account.Actor.Avatar.updatedAt"', | ||
418 | '"Thumbnails"."id"': '"Thumbnails.id"', | ||
419 | '"Thumbnails"."type"': '"Thumbnails.type"', | ||
420 | '"Thumbnails"."filename"': '"Thumbnails.filename"' | ||
421 | } | ||
422 | |||
423 | const joins = [ | ||
424 | 'INNER JOIN "video" ON "tmp"."id" = "video"."id"', | ||
425 | |||
426 | 'INNER JOIN "videoChannel" AS "VideoChannel" ON "video"."channelId" = "VideoChannel"."id"', | ||
427 | 'INNER JOIN "actor" AS "VideoChannel->Actor" ON "VideoChannel"."actorId" = "VideoChannel->Actor"."id"', | ||
428 | 'INNER JOIN "account" AS "VideoChannel->Account" ON "VideoChannel"."accountId" = "VideoChannel->Account"."id"', | ||
429 | 'INNER JOIN "actor" AS "VideoChannel->Account->Actor" ON "VideoChannel->Account"."actorId" = "VideoChannel->Account->Actor"."id"', | ||
430 | |||
431 | 'LEFT OUTER JOIN "server" AS "VideoChannel->Actor->Server" ON "VideoChannel->Actor"."serverId" = "VideoChannel->Actor->Server"."id"', | ||
432 | 'LEFT OUTER JOIN "avatar" AS "VideoChannel->Actor->Avatar" ON "VideoChannel->Actor"."avatarId" = "VideoChannel->Actor->Avatar"."id"', | ||
433 | |||
434 | 'LEFT OUTER JOIN "server" AS "VideoChannel->Account->Actor->Server" ' + | ||
435 | 'ON "VideoChannel->Account->Actor"."serverId" = "VideoChannel->Account->Actor->Server"."id"', | ||
436 | |||
437 | 'LEFT OUTER JOIN "avatar" AS "VideoChannel->Account->Actor->Avatar" ' + | ||
438 | 'ON "VideoChannel->Account->Actor"."avatarId" = "VideoChannel->Account->Actor->Avatar"."id"', | ||
439 | |||
440 | 'LEFT OUTER JOIN "thumbnail" AS "Thumbnails" ON "video"."id" = "Thumbnails"."videoId"' | ||
441 | ] | ||
442 | |||
443 | if (options.withFiles) { | ||
444 | joins.push('INNER JOIN "videoFile" AS "VideoFiles" ON "VideoFiles"."videoId" = "video"."id"') | ||
445 | |||
446 | Object.assign(attributes, { | ||
447 | '"VideoFiles"."id"': '"VideoFiles.id"', | ||
448 | '"VideoFiles"."createdAt"': '"VideoFiles.createdAt"', | ||
449 | '"VideoFiles"."updatedAt"': '"VideoFiles.updatedAt"', | ||
450 | '"VideoFiles"."resolution"': '"VideoFiles.resolution"', | ||
451 | '"VideoFiles"."size"': '"VideoFiles.size"', | ||
452 | '"VideoFiles"."extname"': '"VideoFiles.extname"', | ||
453 | '"VideoFiles"."infoHash"': '"VideoFiles.infoHash"', | ||
454 | '"VideoFiles"."fps"': '"VideoFiles.fps"', | ||
455 | '"VideoFiles"."videoId"': '"VideoFiles.videoId"' | ||
456 | }) | ||
457 | } | ||
458 | |||
459 | if (options.user) { | ||
460 | joins.push( | ||
461 | 'LEFT OUTER JOIN "userVideoHistory" ' + | ||
462 | 'ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :userVideoHistoryId' | ||
463 | ) | ||
464 | replacements.userVideoHistoryId = options.user.id | ||
465 | |||
466 | Object.assign(attributes, { | ||
467 | '"userVideoHistory"."id"': '"userVideoHistory.id"', | ||
468 | '"userVideoHistory"."currentTime"': '"userVideoHistory.currentTime"' | ||
469 | }) | ||
470 | } | ||
471 | |||
472 | if (options.videoPlaylistId) { | ||
473 | joins.push( | ||
474 | 'INNER JOIN "videoPlaylistElement" as "VideoPlaylistElement" ON "videoPlaylistElement"."videoId" = "video"."id" ' + | ||
475 | 'AND "VideoPlaylistElement"."videoPlaylistId" = :videoPlaylistId' | ||
476 | ) | ||
477 | replacements.videoPlaylistId = options.videoPlaylistId | ||
478 | |||
479 | Object.assign(attributes, { | ||
480 | '"VideoPlaylistElement"."createdAt"': '"VideoPlaylistElement.createdAt"', | ||
481 | '"VideoPlaylistElement"."updatedAt"': '"VideoPlaylistElement.updatedAt"', | ||
482 | '"VideoPlaylistElement"."url"': '"VideoPlaylistElement.url"', | ||
483 | '"VideoPlaylistElement"."position"': '"VideoPlaylistElement.position"', | ||
484 | '"VideoPlaylistElement"."startTimestamp"': '"VideoPlaylistElement.startTimestamp"', | ||
485 | '"VideoPlaylistElement"."stopTimestamp"': '"VideoPlaylistElement.stopTimestamp"', | ||
486 | '"VideoPlaylistElement"."videoPlaylistId"': '"VideoPlaylistElement.videoPlaylistId"' | ||
487 | }) | ||
488 | } | ||
489 | |||
490 | const select = 'SELECT ' + Object.keys(attributes).map(key => { | ||
491 | const value = attributes[key] | ||
492 | if (value) return `${key} AS ${value}` | ||
493 | |||
494 | return key | ||
495 | }).join(', ') | ||
496 | |||
497 | return `${select} FROM (${baseQuery}) AS "tmp" ${joins.join(' ')} ${order}` | ||
498 | } | ||
499 | |||
500 | export { | ||
501 | buildListQuery, | ||
502 | wrapForAPIResults | ||
503 | } | ||
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts index 50525b4c2..4bbef75e6 100644 --- a/server/models/video/video-share.ts +++ b/server/models/video/video-share.ts | |||
@@ -2,12 +2,10 @@ import * as Bluebird from 'bluebird' | |||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | 2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' |
3 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | 3 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' |
4 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' | 4 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' |
5 | import { AccountModel } from '../account/account' | ||
6 | import { ActorModel } from '../activitypub/actor' | 5 | import { ActorModel } from '../activitypub/actor' |
7 | import { buildLocalActorIdsIn, throwIfNotValid } from '../utils' | 6 | import { buildLocalActorIdsIn, throwIfNotValid } from '../utils' |
8 | import { VideoModel } from './video' | 7 | import { VideoModel } from './video' |
9 | import { VideoChannelModel } from './video-channel' | 8 | import { literal, Op, Transaction } from 'sequelize' |
10 | import { Op, Transaction } from 'sequelize' | ||
11 | import { MVideoShareActor, MVideoShareFull } from '../../typings/models/video' | 9 | import { MVideoShareActor, MVideoShareFull } from '../../typings/models/video' |
12 | import { MActorDefault } from '../../typings/models' | 10 | import { MActorDefault } from '../../typings/models' |
13 | 11 | ||
@@ -124,70 +122,55 @@ export class VideoShareModel extends Model<VideoShareModel> { | |||
124 | } | 122 | } |
125 | 123 | ||
126 | return VideoShareModel.scope(ScopeNames.FULL).findAll(query) | 124 | return VideoShareModel.scope(ScopeNames.FULL).findAll(query) |
127 | .then((res: MVideoShareFull[]) => res.map(r => r.Actor)) | 125 | .then((res: MVideoShareFull[]) => res.map(r => r.Actor)) |
128 | } | 126 | } |
129 | 127 | ||
130 | static loadActorsWhoSharedVideosOf (actorOwnerId: number, t: Transaction): Bluebird<MActorDefault[]> { | 128 | static loadActorsWhoSharedVideosOf (actorOwnerId: number, t: Transaction): Bluebird<MActorDefault[]> { |
129 | const safeOwnerId = parseInt(actorOwnerId + '', 10) | ||
130 | |||
131 | // /!\ On actor model | ||
131 | const query = { | 132 | const query = { |
132 | attributes: [], | 133 | where: { |
133 | include: [ | 134 | [Op.and]: [ |
134 | { | 135 | literal( |
135 | model: ActorModel, | 136 | `EXISTS (` + |
136 | required: true | 137 | ` SELECT 1 FROM "videoShare" ` + |
137 | }, | 138 | ` INNER JOIN "video" ON "videoShare"."videoId" = "video"."id" ` + |
138 | { | 139 | ` INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` + |
139 | attributes: [], | 140 | ` INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ` + |
140 | model: VideoModel, | 141 | ` WHERE "videoShare"."actorId" = "ActorModel"."id" AND "account"."actorId" = ${safeOwnerId} ` + |
141 | required: true, | 142 | ` LIMIT 1` + |
142 | include: [ | 143 | `)` |
143 | { | 144 | ) |
144 | attributes: [], | 145 | ] |
145 | model: VideoChannelModel.unscoped(), | 146 | }, |
146 | required: true, | ||
147 | include: [ | ||
148 | { | ||
149 | attributes: [], | ||
150 | model: AccountModel.unscoped(), | ||
151 | required: true, | ||
152 | where: { | ||
153 | actorId: actorOwnerId | ||
154 | } | ||
155 | } | ||
156 | ] | ||
157 | } | ||
158 | ] | ||
159 | } | ||
160 | ], | ||
161 | transaction: t | 147 | transaction: t |
162 | } | 148 | } |
163 | 149 | ||
164 | return VideoShareModel.scope(ScopeNames.FULL).findAll(query) | 150 | return ActorModel.findAll(query) |
165 | .then(res => res.map(r => r.Actor)) | ||
166 | } | 151 | } |
167 | 152 | ||
168 | static loadActorsByVideoChannel (videoChannelId: number, t: Transaction): Bluebird<MActorDefault[]> { | 153 | static loadActorsByVideoChannel (videoChannelId: number, t: Transaction): Bluebird<MActorDefault[]> { |
154 | const safeChannelId = parseInt(videoChannelId + '', 10) | ||
155 | |||
156 | // /!\ On actor model | ||
169 | const query = { | 157 | const query = { |
170 | attributes: [], | 158 | where: { |
171 | include: [ | 159 | [Op.and]: [ |
172 | { | 160 | literal( |
173 | model: ActorModel, | 161 | `EXISTS (` + |
174 | required: true | 162 | ` SELECT 1 FROM "videoShare" ` + |
175 | }, | 163 | ` INNER JOIN "video" ON "videoShare"."videoId" = "video"."id" ` + |
176 | { | 164 | ` WHERE "videoShare"."actorId" = "ActorModel"."id" AND "video"."channelId" = ${safeChannelId} ` + |
177 | attributes: [], | 165 | ` LIMIT 1` + |
178 | model: VideoModel, | 166 | `)` |
179 | required: true, | 167 | ) |
180 | where: { | 168 | ] |
181 | channelId: videoChannelId | 169 | }, |
182 | } | ||
183 | } | ||
184 | ], | ||
185 | transaction: t | 170 | transaction: t |
186 | } | 171 | } |
187 | 172 | ||
188 | return VideoShareModel.scope(ScopeNames.FULL) | 173 | return ActorModel.findAll(query) |
189 | .findAll(query) | ||
190 | .then(res => res.map(r => r.Actor)) | ||
191 | } | 174 | } |
192 | 175 | ||
193 | static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Transaction) { | 176 | static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Transaction) { |
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index a91a7663d..f5194e259 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -1,18 +1,7 @@ | |||
1 | import * as Bluebird from 'bluebird' | 1 | import * as Bluebird from 'bluebird' |
2 | import { maxBy, minBy } from 'lodash' | 2 | import { maxBy, minBy, pick } from 'lodash' |
3 | import { join } from 'path' | 3 | import { join } from 'path' |
4 | import { | 4 | import { FindOptions, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' |
5 | CountOptions, | ||
6 | FindOptions, | ||
7 | IncludeOptions, | ||
8 | ModelIndexesOptions, | ||
9 | Op, | ||
10 | QueryTypes, | ||
11 | ScopeOptions, | ||
12 | Sequelize, | ||
13 | Transaction, | ||
14 | WhereOptions | ||
15 | } from 'sequelize' | ||
16 | import { | 5 | import { |
17 | AllowNull, | 6 | AllowNull, |
18 | BeforeDestroy, | 7 | BeforeDestroy, |
@@ -54,7 +43,6 @@ import { | |||
54 | } from '../../helpers/custom-validators/videos' | 43 | } from '../../helpers/custom-validators/videos' |
55 | import { getVideoFileResolution } from '../../helpers/ffmpeg-utils' | 44 | import { getVideoFileResolution } from '../../helpers/ffmpeg-utils' |
56 | import { logger } from '../../helpers/logger' | 45 | import { logger } from '../../helpers/logger' |
57 | import { getServerActor } from '../../helpers/utils' | ||
58 | import { | 46 | import { |
59 | ACTIVITY_PUB, | 47 | ACTIVITY_PUB, |
60 | API_VERSION, | 48 | API_VERSION, |
@@ -76,16 +64,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate' | |||
76 | import { ActorModel } from '../activitypub/actor' | 64 | import { ActorModel } from '../activitypub/actor' |
77 | import { AvatarModel } from '../avatar/avatar' | 65 | import { AvatarModel } from '../avatar/avatar' |
78 | import { ServerModel } from '../server/server' | 66 | import { ServerModel } from '../server/server' |
79 | import { | 67 | import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils' |
80 | buildBlockedAccountSQL, | ||
81 | buildTrigramSearchIndex, | ||
82 | buildWhereIdOrUUID, | ||
83 | createSafeIn, | ||
84 | createSimilarityAttribute, | ||
85 | getVideoSort, | ||
86 | isOutdated, | ||
87 | throwIfNotValid | ||
88 | } from '../utils' | ||
89 | import { TagModel } from './tag' | 68 | import { TagModel } from './tag' |
90 | import { VideoAbuseModel } from './video-abuse' | 69 | import { VideoAbuseModel } from './video-abuse' |
91 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' | 70 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' |
@@ -132,6 +111,7 @@ import { | |||
132 | MVideoForUser, | 111 | MVideoForUser, |
133 | MVideoFullLight, | 112 | MVideoFullLight, |
134 | MVideoIdThumbnail, | 113 | MVideoIdThumbnail, |
114 | MVideoImmutable, | ||
135 | MVideoThumbnail, | 115 | MVideoThumbnail, |
136 | MVideoThumbnailBlacklist, | 116 | MVideoThumbnailBlacklist, |
137 | MVideoWithAllFiles, | 117 | MVideoWithAllFiles, |
@@ -142,75 +122,10 @@ import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../typings/mode | |||
142 | import { MThumbnail } from '../../typings/models/video/thumbnail' | 122 | import { MThumbnail } from '../../typings/models/video/thumbnail' |
143 | import { VideoFile } from '@shared/models/videos/video-file.model' | 123 | import { VideoFile } from '@shared/models/videos/video-file.model' |
144 | import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths' | 124 | import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths' |
145 | import validator from 'validator' | 125 | import { ModelCache } from '@server/models/model-cache' |
146 | 126 | import { buildListQuery, BuildVideosQueryOptions, wrapForAPIResults } from './video-query-builder' | |
147 | // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation | 127 | import { buildNSFWFilter } from '@server/helpers/express-utils' |
148 | const indexes: (ModelIndexesOptions & { where?: WhereOptions })[] = [ | 128 | import { getServerActor } from '@server/models/application/application' |
149 | buildTrigramSearchIndex('video_name_trigram', 'name'), | ||
150 | |||
151 | { fields: [ 'createdAt' ] }, | ||
152 | { | ||
153 | fields: [ | ||
154 | { name: 'publishedAt', order: 'DESC' }, | ||
155 | { name: 'id', order: 'ASC' } | ||
156 | ] | ||
157 | }, | ||
158 | { fields: [ 'duration' ] }, | ||
159 | { fields: [ 'views' ] }, | ||
160 | { fields: [ 'channelId' ] }, | ||
161 | { | ||
162 | fields: [ 'originallyPublishedAt' ], | ||
163 | where: { | ||
164 | originallyPublishedAt: { | ||
165 | [Op.ne]: null | ||
166 | } | ||
167 | } | ||
168 | }, | ||
169 | { | ||
170 | fields: [ 'category' ], // We don't care videos with an unknown category | ||
171 | where: { | ||
172 | category: { | ||
173 | [Op.ne]: null | ||
174 | } | ||
175 | } | ||
176 | }, | ||
177 | { | ||
178 | fields: [ 'licence' ], // We don't care videos with an unknown licence | ||
179 | where: { | ||
180 | licence: { | ||
181 | [Op.ne]: null | ||
182 | } | ||
183 | } | ||
184 | }, | ||
185 | { | ||
186 | fields: [ 'language' ], // We don't care videos with an unknown language | ||
187 | where: { | ||
188 | language: { | ||
189 | [Op.ne]: null | ||
190 | } | ||
191 | } | ||
192 | }, | ||
193 | { | ||
194 | fields: [ 'nsfw' ], // Most of the videos are not NSFW | ||
195 | where: { | ||
196 | nsfw: true | ||
197 | } | ||
198 | }, | ||
199 | { | ||
200 | fields: [ 'remote' ], // Only index local videos | ||
201 | where: { | ||
202 | remote: false | ||
203 | } | ||
204 | }, | ||
205 | { | ||
206 | fields: [ 'uuid' ], | ||
207 | unique: true | ||
208 | }, | ||
209 | { | ||
210 | fields: [ 'url' ], | ||
211 | unique: true | ||
212 | } | ||
213 | ] | ||
214 | 129 | ||
215 | export enum ScopeNames { | 130 | export enum ScopeNames { |
216 | AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS', | 131 | AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS', |
@@ -223,6 +138,7 @@ export enum ScopeNames { | |||
223 | WITH_USER_HISTORY = 'WITH_USER_HISTORY', | 138 | WITH_USER_HISTORY = 'WITH_USER_HISTORY', |
224 | WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS', | 139 | WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS', |
225 | WITH_USER_ID = 'WITH_USER_ID', | 140 | WITH_USER_ID = 'WITH_USER_ID', |
141 | WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES', | ||
226 | WITH_THUMBNAILS = 'WITH_THUMBNAILS' | 142 | WITH_THUMBNAILS = 'WITH_THUMBNAILS' |
227 | } | 143 | } |
228 | 144 | ||
@@ -266,7 +182,10 @@ export type AvailableForListIDsOptions = { | |||
266 | } | 182 | } |
267 | 183 | ||
268 | @Scopes(() => ({ | 184 | @Scopes(() => ({ |
269 | [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => { | 185 | [ScopeNames.WITH_IMMUTABLE_ATTRIBUTES]: { |
186 | attributes: [ 'id', 'url', 'uuid', 'remote' ] | ||
187 | }, | ||
188 | [ScopeNames.FOR_API]: (options: ForAPIOptions) => { | ||
270 | const query: FindOptions = { | 189 | const query: FindOptions = { |
271 | include: [ | 190 | include: [ |
272 | { | 191 | { |
@@ -291,14 +210,14 @@ export type AvailableForListIDsOptions = { | |||
291 | if (options.ids) { | 210 | if (options.ids) { |
292 | query.where = { | 211 | query.where = { |
293 | id: { | 212 | id: { |
294 | [ Op.in ]: options.ids // FIXME: sequelize ANY seems broken | 213 | [Op.in]: options.ids |
295 | } | 214 | } |
296 | } | 215 | } |
297 | } | 216 | } |
298 | 217 | ||
299 | if (options.withFiles === true) { | 218 | if (options.withFiles === true) { |
300 | query.include.push({ | 219 | query.include.push({ |
301 | model: VideoFileModel.unscoped(), | 220 | model: VideoFileModel, |
302 | required: true | 221 | required: true |
303 | }) | 222 | }) |
304 | } | 223 | } |
@@ -315,276 +234,7 @@ export type AvailableForListIDsOptions = { | |||
315 | 234 | ||
316 | return query | 235 | return query |
317 | }, | 236 | }, |
318 | [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => { | 237 | [ScopeNames.WITH_THUMBNAILS]: { |
319 | const whereAnd = options.baseWhere ? [].concat(options.baseWhere) : [] | ||
320 | |||
321 | const query: FindOptions = { | ||
322 | raw: true, | ||
323 | include: [] | ||
324 | } | ||
325 | |||
326 | const attributesType = options.attributesType || 'id' | ||
327 | |||
328 | if (attributesType === 'id') query.attributes = [ 'id' ] | ||
329 | else if (attributesType === 'none') query.attributes = [ ] | ||
330 | |||
331 | whereAnd.push({ | ||
332 | id: { | ||
333 | [ Op.notIn ]: Sequelize.literal( | ||
334 | '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")' | ||
335 | ) | ||
336 | } | ||
337 | }) | ||
338 | |||
339 | if (options.serverAccountId) { | ||
340 | whereAnd.push({ | ||
341 | channelId: { | ||
342 | [ Op.notIn ]: Sequelize.literal( | ||
343 | '(' + | ||
344 | 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' + | ||
345 | buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) + | ||
346 | ')' + | ||
347 | ')' | ||
348 | ) | ||
349 | } | ||
350 | }) | ||
351 | } | ||
352 | |||
353 | // Only list public/published videos | ||
354 | if (!options.filter || options.filter !== 'all-local') { | ||
355 | |||
356 | const publishWhere = { | ||
357 | // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding | ||
358 | [ Op.or ]: [ | ||
359 | { | ||
360 | state: VideoState.PUBLISHED | ||
361 | }, | ||
362 | { | ||
363 | [ Op.and ]: { | ||
364 | state: VideoState.TO_TRANSCODE, | ||
365 | waitTranscoding: false | ||
366 | } | ||
367 | } | ||
368 | ] | ||
369 | } | ||
370 | whereAnd.push(publishWhere) | ||
371 | |||
372 | // List internal videos if the user is logged in | ||
373 | if (options.user) { | ||
374 | const privacyWhere = { | ||
375 | [Op.or]: [ | ||
376 | { | ||
377 | privacy: VideoPrivacy.INTERNAL | ||
378 | }, | ||
379 | { | ||
380 | privacy: VideoPrivacy.PUBLIC | ||
381 | } | ||
382 | ] | ||
383 | } | ||
384 | |||
385 | whereAnd.push(privacyWhere) | ||
386 | } else { // Or only public videos | ||
387 | const privacyWhere = { privacy: VideoPrivacy.PUBLIC } | ||
388 | whereAnd.push(privacyWhere) | ||
389 | } | ||
390 | } | ||
391 | |||
392 | if (options.videoPlaylistId) { | ||
393 | query.include.push({ | ||
394 | attributes: [], | ||
395 | model: VideoPlaylistElementModel.unscoped(), | ||
396 | required: true, | ||
397 | where: { | ||
398 | videoPlaylistId: options.videoPlaylistId | ||
399 | } | ||
400 | }) | ||
401 | |||
402 | query.subQuery = false | ||
403 | } | ||
404 | |||
405 | if (options.filter && (options.filter === 'local' || options.filter === 'all-local')) { | ||
406 | whereAnd.push({ | ||
407 | remote: false | ||
408 | }) | ||
409 | } | ||
410 | |||
411 | if (options.accountId || options.videoChannelId) { | ||
412 | const videoChannelInclude: IncludeOptions = { | ||
413 | attributes: [], | ||
414 | model: VideoChannelModel.unscoped(), | ||
415 | required: true | ||
416 | } | ||
417 | |||
418 | if (options.videoChannelId) { | ||
419 | videoChannelInclude.where = { | ||
420 | id: options.videoChannelId | ||
421 | } | ||
422 | } | ||
423 | |||
424 | if (options.accountId) { | ||
425 | const accountInclude: IncludeOptions = { | ||
426 | attributes: [], | ||
427 | model: AccountModel.unscoped(), | ||
428 | required: true | ||
429 | } | ||
430 | |||
431 | accountInclude.where = { id: options.accountId } | ||
432 | videoChannelInclude.include = [ accountInclude ] | ||
433 | } | ||
434 | |||
435 | query.include.push(videoChannelInclude) | ||
436 | } | ||
437 | |||
438 | if (options.followerActorId) { | ||
439 | let localVideosReq = '' | ||
440 | if (options.includeLocalVideos === true) { | ||
441 | localVideosReq = ' UNION ALL SELECT "video"."id" FROM "video" WHERE remote IS FALSE' | ||
442 | } | ||
443 | |||
444 | // Force actorId to be a number to avoid SQL injections | ||
445 | const actorIdNumber = parseInt(options.followerActorId.toString(), 10) | ||
446 | whereAnd.push({ | ||
447 | id: { | ||
448 | [Op.in]: Sequelize.literal( | ||
449 | '(' + | ||
450 | 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' + | ||
451 | 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + | ||
452 | 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + | ||
453 | ' UNION ALL ' + | ||
454 | 'SELECT "video"."id" AS "id" FROM "video" ' + | ||
455 | 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + | ||
456 | 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' + | ||
457 | 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' + | ||
458 | 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' + | ||
459 | 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + | ||
460 | localVideosReq + | ||
461 | ')' | ||
462 | ) | ||
463 | } | ||
464 | }) | ||
465 | } | ||
466 | |||
467 | if (options.withFiles === true) { | ||
468 | whereAnd.push({ | ||
469 | id: { | ||
470 | [ Op.in ]: Sequelize.literal( | ||
471 | '(SELECT "videoId" FROM "videoFile")' | ||
472 | ) | ||
473 | } | ||
474 | }) | ||
475 | } | ||
476 | |||
477 | // FIXME: issues with sequelize count when making a join on n:m relation, so we just make a IN() | ||
478 | if (options.tagsAllOf || options.tagsOneOf) { | ||
479 | if (options.tagsOneOf) { | ||
480 | const tagsOneOfLower = options.tagsOneOf.map(t => t.toLowerCase()) | ||
481 | |||
482 | whereAnd.push({ | ||
483 | id: { | ||
484 | [ Op.in ]: Sequelize.literal( | ||
485 | '(' + | ||
486 | 'SELECT "videoId" FROM "videoTag" ' + | ||
487 | 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + | ||
488 | 'WHERE lower("tag"."name") IN (' + createSafeIn(VideoModel, tagsOneOfLower) + ')' + | ||
489 | ')' | ||
490 | ) | ||
491 | } | ||
492 | }) | ||
493 | } | ||
494 | |||
495 | if (options.tagsAllOf) { | ||
496 | const tagsAllOfLower = options.tagsAllOf.map(t => t.toLowerCase()) | ||
497 | |||
498 | whereAnd.push({ | ||
499 | id: { | ||
500 | [ Op.in ]: Sequelize.literal( | ||
501 | '(' + | ||
502 | 'SELECT "videoId" FROM "videoTag" ' + | ||
503 | 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + | ||
504 | 'WHERE lower("tag"."name") IN (' + createSafeIn(VideoModel, tagsAllOfLower) + ')' + | ||
505 | 'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length + | ||
506 | ')' | ||
507 | ) | ||
508 | } | ||
509 | }) | ||
510 | } | ||
511 | } | ||
512 | |||
513 | if (options.nsfw === true || options.nsfw === false) { | ||
514 | whereAnd.push({ nsfw: options.nsfw }) | ||
515 | } | ||
516 | |||
517 | if (options.categoryOneOf) { | ||
518 | whereAnd.push({ | ||
519 | category: { | ||
520 | [ Op.or ]: options.categoryOneOf | ||
521 | } | ||
522 | }) | ||
523 | } | ||
524 | |||
525 | if (options.licenceOneOf) { | ||
526 | whereAnd.push({ | ||
527 | licence: { | ||
528 | [ Op.or ]: options.licenceOneOf | ||
529 | } | ||
530 | }) | ||
531 | } | ||
532 | |||
533 | if (options.languageOneOf) { | ||
534 | let videoLanguages = options.languageOneOf | ||
535 | if (options.languageOneOf.find(l => l === '_unknown')) { | ||
536 | videoLanguages = videoLanguages.concat([ null ]) | ||
537 | } | ||
538 | |||
539 | whereAnd.push({ | ||
540 | [Op.or]: [ | ||
541 | { | ||
542 | language: { | ||
543 | [ Op.or ]: videoLanguages | ||
544 | } | ||
545 | }, | ||
546 | { | ||
547 | id: { | ||
548 | [ Op.in ]: Sequelize.literal( | ||
549 | '(' + | ||
550 | 'SELECT "videoId" FROM "videoCaption" ' + | ||
551 | 'WHERE "language" IN (' + createSafeIn(VideoModel, options.languageOneOf) + ') ' + | ||
552 | ')' | ||
553 | ) | ||
554 | } | ||
555 | } | ||
556 | ] | ||
557 | }) | ||
558 | } | ||
559 | |||
560 | if (options.trendingDays) { | ||
561 | query.include.push(VideoModel.buildTrendingQuery(options.trendingDays)) | ||
562 | |||
563 | query.subQuery = false | ||
564 | } | ||
565 | |||
566 | if (options.historyOfUser) { | ||
567 | query.include.push({ | ||
568 | model: UserVideoHistoryModel, | ||
569 | required: true, | ||
570 | where: { | ||
571 | userId: options.historyOfUser.id | ||
572 | } | ||
573 | }) | ||
574 | |||
575 | // Even if the relation is n:m, we know that a user only have 0..1 video history | ||
576 | // So we won't have multiple rows for the same video | ||
577 | // Without this, we would not be able to sort on "updatedAt" column of UserVideoHistoryModel | ||
578 | query.subQuery = false | ||
579 | } | ||
580 | |||
581 | query.where = { | ||
582 | [ Op.and ]: whereAnd | ||
583 | } | ||
584 | |||
585 | return query | ||
586 | }, | ||
587 | [ ScopeNames.WITH_THUMBNAILS ]: { | ||
588 | include: [ | 238 | include: [ |
589 | { | 239 | { |
590 | model: ThumbnailModel, | 240 | model: ThumbnailModel, |
@@ -592,7 +242,7 @@ export type AvailableForListIDsOptions = { | |||
592 | } | 242 | } |
593 | ] | 243 | ] |
594 | }, | 244 | }, |
595 | [ ScopeNames.WITH_USER_ID ]: { | 245 | [ScopeNames.WITH_USER_ID]: { |
596 | include: [ | 246 | include: [ |
597 | { | 247 | { |
598 | attributes: [ 'accountId' ], | 248 | attributes: [ 'accountId' ], |
@@ -608,7 +258,7 @@ export type AvailableForListIDsOptions = { | |||
608 | } | 258 | } |
609 | ] | 259 | ] |
610 | }, | 260 | }, |
611 | [ ScopeNames.WITH_ACCOUNT_DETAILS ]: { | 261 | [ScopeNames.WITH_ACCOUNT_DETAILS]: { |
612 | include: [ | 262 | include: [ |
613 | { | 263 | { |
614 | model: VideoChannelModel.unscoped(), | 264 | model: VideoChannelModel.unscoped(), |
@@ -660,10 +310,10 @@ export type AvailableForListIDsOptions = { | |||
660 | } | 310 | } |
661 | ] | 311 | ] |
662 | }, | 312 | }, |
663 | [ ScopeNames.WITH_TAGS ]: { | 313 | [ScopeNames.WITH_TAGS]: { |
664 | include: [ TagModel ] | 314 | include: [ TagModel ] |
665 | }, | 315 | }, |
666 | [ ScopeNames.WITH_BLACKLISTED ]: { | 316 | [ScopeNames.WITH_BLACKLISTED]: { |
667 | include: [ | 317 | include: [ |
668 | { | 318 | { |
669 | attributes: [ 'id', 'reason', 'unfederated' ], | 319 | attributes: [ 'id', 'reason', 'unfederated' ], |
@@ -672,7 +322,7 @@ export type AvailableForListIDsOptions = { | |||
672 | } | 322 | } |
673 | ] | 323 | ] |
674 | }, | 324 | }, |
675 | [ ScopeNames.WITH_WEBTORRENT_FILES ]: (withRedundancies = false) => { | 325 | [ScopeNames.WITH_WEBTORRENT_FILES]: (withRedundancies = false) => { |
676 | let subInclude: any[] = [] | 326 | let subInclude: any[] = [] |
677 | 327 | ||
678 | if (withRedundancies === true) { | 328 | if (withRedundancies === true) { |
@@ -688,7 +338,7 @@ export type AvailableForListIDsOptions = { | |||
688 | return { | 338 | return { |
689 | include: [ | 339 | include: [ |
690 | { | 340 | { |
691 | model: VideoFileModel.unscoped(), | 341 | model: VideoFileModel, |
692 | separate: true, // We may have multiple files, having multiple redundancies so let's separate this join | 342 | separate: true, // We may have multiple files, having multiple redundancies so let's separate this join |
693 | required: false, | 343 | required: false, |
694 | include: subInclude | 344 | include: subInclude |
@@ -696,10 +346,10 @@ export type AvailableForListIDsOptions = { | |||
696 | ] | 346 | ] |
697 | } | 347 | } |
698 | }, | 348 | }, |
699 | [ ScopeNames.WITH_STREAMING_PLAYLISTS ]: (withRedundancies = false) => { | 349 | [ScopeNames.WITH_STREAMING_PLAYLISTS]: (withRedundancies = false) => { |
700 | const subInclude: IncludeOptions[] = [ | 350 | const subInclude: IncludeOptions[] = [ |
701 | { | 351 | { |
702 | model: VideoFileModel.unscoped(), | 352 | model: VideoFileModel, |
703 | required: false | 353 | required: false |
704 | } | 354 | } |
705 | ] | 355 | ] |
@@ -723,7 +373,7 @@ export type AvailableForListIDsOptions = { | |||
723 | ] | 373 | ] |
724 | } | 374 | } |
725 | }, | 375 | }, |
726 | [ ScopeNames.WITH_SCHEDULED_UPDATE ]: { | 376 | [ScopeNames.WITH_SCHEDULED_UPDATE]: { |
727 | include: [ | 377 | include: [ |
728 | { | 378 | { |
729 | model: ScheduleVideoUpdateModel.unscoped(), | 379 | model: ScheduleVideoUpdateModel.unscoped(), |
@@ -731,7 +381,7 @@ export type AvailableForListIDsOptions = { | |||
731 | } | 381 | } |
732 | ] | 382 | ] |
733 | }, | 383 | }, |
734 | [ ScopeNames.WITH_USER_HISTORY ]: (userId: number) => { | 384 | [ScopeNames.WITH_USER_HISTORY]: (userId: number) => { |
735 | return { | 385 | return { |
736 | include: [ | 386 | include: [ |
737 | { | 387 | { |
@@ -748,7 +398,72 @@ export type AvailableForListIDsOptions = { | |||
748 | })) | 398 | })) |
749 | @Table({ | 399 | @Table({ |
750 | tableName: 'video', | 400 | tableName: 'video', |
751 | indexes | 401 | indexes: [ |
402 | buildTrigramSearchIndex('video_name_trigram', 'name'), | ||
403 | |||
404 | { fields: [ 'createdAt' ] }, | ||
405 | { | ||
406 | fields: [ | ||
407 | { name: 'publishedAt', order: 'DESC' }, | ||
408 | { name: 'id', order: 'ASC' } | ||
409 | ] | ||
410 | }, | ||
411 | { fields: [ 'duration' ] }, | ||
412 | { fields: [ 'views' ] }, | ||
413 | { fields: [ 'channelId' ] }, | ||
414 | { | ||
415 | fields: [ 'originallyPublishedAt' ], | ||
416 | where: { | ||
417 | originallyPublishedAt: { | ||
418 | [Op.ne]: null | ||
419 | } | ||
420 | } | ||
421 | }, | ||
422 | { | ||
423 | fields: [ 'category' ], // We don't care videos with an unknown category | ||
424 | where: { | ||
425 | category: { | ||
426 | [Op.ne]: null | ||
427 | } | ||
428 | } | ||
429 | }, | ||
430 | { | ||
431 | fields: [ 'licence' ], // We don't care videos with an unknown licence | ||
432 | where: { | ||
433 | licence: { | ||
434 | [Op.ne]: null | ||
435 | } | ||
436 | } | ||
437 | }, | ||
438 | { | ||
439 | fields: [ 'language' ], // We don't care videos with an unknown language | ||
440 | where: { | ||
441 | language: { | ||
442 | [Op.ne]: null | ||
443 | } | ||
444 | } | ||
445 | }, | ||
446 | { | ||
447 | fields: [ 'nsfw' ], // Most of the videos are not NSFW | ||
448 | where: { | ||
449 | nsfw: true | ||
450 | } | ||
451 | }, | ||
452 | { | ||
453 | fields: [ 'remote' ], // Only index local videos | ||
454 | where: { | ||
455 | remote: false | ||
456 | } | ||
457 | }, | ||
458 | { | ||
459 | fields: [ 'uuid' ], | ||
460 | unique: true | ||
461 | }, | ||
462 | { | ||
463 | fields: [ 'url' ], | ||
464 | unique: true | ||
465 | } | ||
466 | ] | ||
752 | }) | 467 | }) |
753 | export class VideoModel extends Model<VideoModel> { | 468 | export class VideoModel extends Model<VideoModel> { |
754 | 469 | ||
@@ -913,9 +628,9 @@ export class VideoModel extends Model<VideoModel> { | |||
913 | @HasMany(() => VideoAbuseModel, { | 628 | @HasMany(() => VideoAbuseModel, { |
914 | foreignKey: { | 629 | foreignKey: { |
915 | name: 'videoId', | 630 | name: 'videoId', |
916 | allowNull: false | 631 | allowNull: true |
917 | }, | 632 | }, |
918 | onDelete: 'cascade' | 633 | onDelete: 'set null' |
919 | }) | 634 | }) |
920 | VideoAbuses: VideoAbuseModel[] | 635 | VideoAbuses: VideoAbuseModel[] |
921 | 636 | ||
@@ -1019,7 +734,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1019 | }, | 734 | }, |
1020 | onDelete: 'cascade', | 735 | onDelete: 'cascade', |
1021 | hooks: true, | 736 | hooks: true, |
1022 | [ 'separate' as any ]: true | 737 | ['separate' as any]: true |
1023 | }) | 738 | }) |
1024 | VideoCaptions: VideoCaptionModel[] | 739 | VideoCaptions: VideoCaptionModel[] |
1025 | 740 | ||
@@ -1078,6 +793,38 @@ export class VideoModel extends Model<VideoModel> { | |||
1078 | return undefined | 793 | return undefined |
1079 | } | 794 | } |
1080 | 795 | ||
796 | @BeforeDestroy | ||
797 | static invalidateCache (instance: VideoModel) { | ||
798 | ModelCache.Instance.invalidateCache('video', instance.id) | ||
799 | } | ||
800 | |||
801 | @BeforeDestroy | ||
802 | static async saveEssentialDataToAbuses (instance: VideoModel, options) { | ||
803 | const tasks: Promise<any>[] = [] | ||
804 | |||
805 | logger.info('Saving video abuses details of video %s.', instance.url) | ||
806 | |||
807 | if (!Array.isArray(instance.VideoAbuses)) { | ||
808 | instance.VideoAbuses = await instance.$get('VideoAbuses') | ||
809 | |||
810 | if (instance.VideoAbuses.length === 0) return undefined | ||
811 | } | ||
812 | |||
813 | const details = instance.toFormattedDetailsJSON() | ||
814 | |||
815 | for (const abuse of instance.VideoAbuses) { | ||
816 | abuse.deletedVideo = details | ||
817 | tasks.push(abuse.save({ transaction: options.transaction })) | ||
818 | } | ||
819 | |||
820 | Promise.all(tasks) | ||
821 | .catch(err => { | ||
822 | logger.error('Some errors when saving details of video %s in its abuses before destroy hook.', instance.uuid, { err }) | ||
823 | }) | ||
824 | |||
825 | return undefined | ||
826 | } | ||
827 | |||
1081 | static listLocal (): Bluebird<MVideoWithAllFiles[]> { | 828 | static listLocal (): Bluebird<MVideoWithAllFiles[]> { |
1082 | const query = { | 829 | const query = { |
1083 | where: { | 830 | where: { |
@@ -1112,19 +859,19 @@ export class VideoModel extends Model<VideoModel> { | |||
1112 | distinct: true, | 859 | distinct: true, |
1113 | offset: start, | 860 | offset: start, |
1114 | limit: count, | 861 | limit: count, |
1115 | order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ] as any), // FIXME: sequelize typings | 862 | order: getVideoSort('-createdAt', [ 'Tags', 'name', 'ASC' ] as any), // FIXME: sequelize typings |
1116 | where: { | 863 | where: { |
1117 | id: { | 864 | id: { |
1118 | [ Op.in ]: Sequelize.literal('(' + rawQuery + ')') | 865 | [Op.in]: Sequelize.literal('(' + rawQuery + ')') |
1119 | }, | 866 | }, |
1120 | [ Op.or ]: [ | 867 | [Op.or]: [ |
1121 | { privacy: VideoPrivacy.PUBLIC }, | 868 | { privacy: VideoPrivacy.PUBLIC }, |
1122 | { privacy: VideoPrivacy.UNLISTED } | 869 | { privacy: VideoPrivacy.UNLISTED } |
1123 | ] | 870 | ] |
1124 | }, | 871 | }, |
1125 | include: [ | 872 | include: [ |
1126 | { | 873 | { |
1127 | attributes: [ 'language' ], | 874 | attributes: [ 'language', 'fileUrl' ], |
1128 | model: VideoCaptionModel.unscoped(), | 875 | model: VideoCaptionModel.unscoped(), |
1129 | required: false | 876 | required: false |
1130 | }, | 877 | }, |
@@ -1134,10 +881,10 @@ export class VideoModel extends Model<VideoModel> { | |||
1134 | required: false, | 881 | required: false, |
1135 | // We only want videos shared by this actor | 882 | // We only want videos shared by this actor |
1136 | where: { | 883 | where: { |
1137 | [ Op.and ]: [ | 884 | [Op.and]: [ |
1138 | { | 885 | { |
1139 | id: { | 886 | id: { |
1140 | [ Op.not ]: null | 887 | [Op.not]: null |
1141 | } | 888 | } |
1142 | }, | 889 | }, |
1143 | { | 890 | { |
@@ -1187,8 +934,8 @@ export class VideoModel extends Model<VideoModel> { | |||
1187 | // totals: totalVideos + totalVideoShares | 934 | // totals: totalVideos + totalVideoShares |
1188 | let totalVideos = 0 | 935 | let totalVideos = 0 |
1189 | let totalVideoShares = 0 | 936 | let totalVideoShares = 0 |
1190 | if (totals[ 0 ]) totalVideos = parseInt(totals[ 0 ].total, 10) | 937 | if (totals[0]) totalVideos = parseInt(totals[0].total, 10) |
1191 | if (totals[ 1 ]) totalVideoShares = parseInt(totals[ 1 ].total, 10) | 938 | if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10) |
1192 | 939 | ||
1193 | const total = totalVideos + totalVideoShares | 940 | const total = totalVideos + totalVideoShares |
1194 | return { | 941 | return { |
@@ -1231,7 +978,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1231 | baseQuery = Object.assign(baseQuery, { | 978 | baseQuery = Object.assign(baseQuery, { |
1232 | where: { | 979 | where: { |
1233 | name: { | 980 | name: { |
1234 | [ Op.iLike ]: '%' + search + '%' | 981 | [Op.iLike]: '%' + search + '%' |
1235 | } | 982 | } |
1236 | } | 983 | } |
1237 | }) | 984 | }) |
@@ -1261,50 +1008,46 @@ export class VideoModel extends Model<VideoModel> { | |||
1261 | } | 1008 | } |
1262 | 1009 | ||
1263 | static async listForApi (options: { | 1010 | static async listForApi (options: { |
1264 | start: number, | 1011 | start: number |
1265 | count: number, | 1012 | count: number |
1266 | sort: string, | 1013 | sort: string |
1267 | nsfw: boolean, | 1014 | nsfw: boolean |
1268 | includeLocalVideos: boolean, | 1015 | includeLocalVideos: boolean |
1269 | withFiles: boolean, | 1016 | withFiles: boolean |
1270 | categoryOneOf?: number[], | 1017 | categoryOneOf?: number[] |
1271 | licenceOneOf?: number[], | 1018 | licenceOneOf?: number[] |
1272 | languageOneOf?: string[], | 1019 | languageOneOf?: string[] |
1273 | tagsOneOf?: string[], | 1020 | tagsOneOf?: string[] |
1274 | tagsAllOf?: string[], | 1021 | tagsAllOf?: string[] |
1275 | filter?: VideoFilter, | 1022 | filter?: VideoFilter |
1276 | accountId?: number, | 1023 | accountId?: number |
1277 | videoChannelId?: number, | 1024 | videoChannelId?: number |
1278 | followerActorId?: number | 1025 | followerActorId?: number |
1279 | videoPlaylistId?: number, | 1026 | videoPlaylistId?: number |
1280 | trendingDays?: number, | 1027 | trendingDays?: number |
1281 | user?: MUserAccountId, | 1028 | user?: MUserAccountId |
1282 | historyOfUser?: MUserId, | 1029 | historyOfUser?: MUserId |
1283 | countVideos?: boolean | 1030 | countVideos?: boolean |
1284 | }) { | 1031 | }) { |
1285 | if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) { | 1032 | if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) { |
1286 | throw new Error('Try to filter all-local but no user has not the see all videos right') | 1033 | throw new Error('Try to filter all-local but no user has not the see all videos right') |
1287 | } | 1034 | } |
1288 | 1035 | ||
1289 | const query: FindOptions & { where?: null } = { | 1036 | const trendingDays = options.sort.endsWith('trending') |
1290 | offset: options.start, | 1037 | ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS |
1291 | limit: options.count, | 1038 | : undefined |
1292 | order: getVideoSort(options.sort) | ||
1293 | } | ||
1294 | |||
1295 | let trendingDays: number | ||
1296 | if (options.sort.endsWith('trending')) { | ||
1297 | trendingDays = CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS | ||
1298 | |||
1299 | query.group = 'VideoModel.id' | ||
1300 | } | ||
1301 | 1039 | ||
1302 | const serverActor = await getServerActor() | 1040 | const serverActor = await getServerActor() |
1303 | 1041 | ||
1304 | // followerActorId === null has a meaning, so just check undefined | 1042 | // followerActorId === null has a meaning, so just check undefined |
1305 | const followerActorId = options.followerActorId !== undefined ? options.followerActorId : serverActor.id | 1043 | const followerActorId = options.followerActorId !== undefined |
1044 | ? options.followerActorId | ||
1045 | : serverActor.id | ||
1306 | 1046 | ||
1307 | const queryOptions = { | 1047 | const queryOptions = { |
1048 | start: options.start, | ||
1049 | count: options.count, | ||
1050 | sort: options.sort, | ||
1308 | followerActorId, | 1051 | followerActorId, |
1309 | serverAccountId: serverActor.Account.id, | 1052 | serverAccountId: serverActor.Account.id, |
1310 | nsfw: options.nsfw, | 1053 | nsfw: options.nsfw, |
@@ -1324,7 +1067,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1324 | trendingDays | 1067 | trendingDays |
1325 | } | 1068 | } |
1326 | 1069 | ||
1327 | return VideoModel.getAvailableForApi(query, queryOptions, options.countVideos) | 1070 | return VideoModel.getAvailableForApi(queryOptions, options.countVideos) |
1328 | } | 1071 | } |
1329 | 1072 | ||
1330 | static async searchAndPopulateAccountAndServer (options: { | 1073 | static async searchAndPopulateAccountAndServer (options: { |
@@ -1345,91 +1088,9 @@ export class VideoModel extends Model<VideoModel> { | |||
1345 | tagsAllOf?: string[] | 1088 | tagsAllOf?: string[] |
1346 | durationMin?: number // seconds | 1089 | durationMin?: number // seconds |
1347 | durationMax?: number // seconds | 1090 | durationMax?: number // seconds |
1348 | user?: MUserAccountId, | 1091 | user?: MUserAccountId |
1349 | filter?: VideoFilter | 1092 | filter?: VideoFilter |
1350 | }) { | 1093 | }) { |
1351 | const whereAnd = [] | ||
1352 | |||
1353 | if (options.startDate || options.endDate) { | ||
1354 | const publishedAtRange = {} | ||
1355 | |||
1356 | if (options.startDate) publishedAtRange[ Op.gte ] = options.startDate | ||
1357 | if (options.endDate) publishedAtRange[ Op.lte ] = options.endDate | ||
1358 | |||
1359 | whereAnd.push({ publishedAt: publishedAtRange }) | ||
1360 | } | ||
1361 | |||
1362 | if (options.originallyPublishedStartDate || options.originallyPublishedEndDate) { | ||
1363 | const originallyPublishedAtRange = {} | ||
1364 | |||
1365 | if (options.originallyPublishedStartDate) originallyPublishedAtRange[ Op.gte ] = options.originallyPublishedStartDate | ||
1366 | if (options.originallyPublishedEndDate) originallyPublishedAtRange[ Op.lte ] = options.originallyPublishedEndDate | ||
1367 | |||
1368 | whereAnd.push({ originallyPublishedAt: originallyPublishedAtRange }) | ||
1369 | } | ||
1370 | |||
1371 | if (options.durationMin || options.durationMax) { | ||
1372 | const durationRange = {} | ||
1373 | |||
1374 | if (options.durationMin) durationRange[ Op.gte ] = options.durationMin | ||
1375 | if (options.durationMax) durationRange[ Op.lte ] = options.durationMax | ||
1376 | |||
1377 | whereAnd.push({ duration: durationRange }) | ||
1378 | } | ||
1379 | |||
1380 | const attributesInclude = [] | ||
1381 | const escapedSearch = VideoModel.sequelize.escape(options.search) | ||
1382 | const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%') | ||
1383 | if (options.search) { | ||
1384 | const trigramSearch = { | ||
1385 | id: { | ||
1386 | [ Op.in ]: Sequelize.literal( | ||
1387 | '(' + | ||
1388 | 'SELECT "video"."id" FROM "video" ' + | ||
1389 | 'WHERE ' + | ||
1390 | 'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' + | ||
1391 | 'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' + | ||
1392 | 'UNION ALL ' + | ||
1393 | 'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' + | ||
1394 | 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + | ||
1395 | 'WHERE lower("tag"."name") = lower(' + escapedSearch + ')' + | ||
1396 | ')' | ||
1397 | ) | ||
1398 | } | ||
1399 | } | ||
1400 | |||
1401 | if (validator.isUUID(options.search)) { | ||
1402 | whereAnd.push({ | ||
1403 | [Op.or]: [ | ||
1404 | trigramSearch, | ||
1405 | { | ||
1406 | uuid: options.search | ||
1407 | } | ||
1408 | ] | ||
1409 | }) | ||
1410 | } else { | ||
1411 | whereAnd.push(trigramSearch) | ||
1412 | } | ||
1413 | |||
1414 | attributesInclude.push(createSimilarityAttribute('VideoModel.name', options.search)) | ||
1415 | } | ||
1416 | |||
1417 | // Cannot search on similarity if we don't have a search | ||
1418 | if (!options.search) { | ||
1419 | attributesInclude.push( | ||
1420 | Sequelize.literal('0 as similarity') | ||
1421 | ) | ||
1422 | } | ||
1423 | |||
1424 | const query = { | ||
1425 | attributes: { | ||
1426 | include: attributesInclude | ||
1427 | }, | ||
1428 | offset: options.start, | ||
1429 | limit: options.count, | ||
1430 | order: getVideoSort(options.sort) | ||
1431 | } | ||
1432 | |||
1433 | const serverActor = await getServerActor() | 1094 | const serverActor = await getServerActor() |
1434 | const queryOptions = { | 1095 | const queryOptions = { |
1435 | followerActorId: serverActor.id, | 1096 | followerActorId: serverActor.id, |
@@ -1443,10 +1104,21 @@ export class VideoModel extends Model<VideoModel> { | |||
1443 | tagsAllOf: options.tagsAllOf, | 1104 | tagsAllOf: options.tagsAllOf, |
1444 | user: options.user, | 1105 | user: options.user, |
1445 | filter: options.filter, | 1106 | filter: options.filter, |
1446 | baseWhere: whereAnd | 1107 | start: options.start, |
1108 | count: options.count, | ||
1109 | sort: options.sort, | ||
1110 | startDate: options.startDate, | ||
1111 | endDate: options.endDate, | ||
1112 | originallyPublishedStartDate: options.originallyPublishedStartDate, | ||
1113 | originallyPublishedEndDate: options.originallyPublishedEndDate, | ||
1114 | |||
1115 | durationMin: options.durationMin, | ||
1116 | durationMax: options.durationMax, | ||
1117 | |||
1118 | search: options.search | ||
1447 | } | 1119 | } |
1448 | 1120 | ||
1449 | return VideoModel.getAvailableForApi(query, queryOptions) | 1121 | return VideoModel.getAvailableForApi(queryOptions) |
1450 | } | 1122 | } |
1451 | 1123 | ||
1452 | static load (id: number | string, t?: Transaction): Bluebird<MVideoThumbnail> { | 1124 | static load (id: number | string, t?: Transaction): Bluebird<MVideoThumbnail> { |
@@ -1472,6 +1144,24 @@ export class VideoModel extends Model<VideoModel> { | |||
1472 | ]).findOne(options) | 1144 | ]).findOne(options) |
1473 | } | 1145 | } |
1474 | 1146 | ||
1147 | static loadImmutableAttributes (id: number | string, t?: Transaction): Bluebird<MVideoImmutable> { | ||
1148 | const fun = () => { | ||
1149 | const query = { | ||
1150 | where: buildWhereIdOrUUID(id), | ||
1151 | transaction: t | ||
1152 | } | ||
1153 | |||
1154 | return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query) | ||
1155 | } | ||
1156 | |||
1157 | return ModelCache.Instance.doCache({ | ||
1158 | cacheType: 'load-video-immutable-id', | ||
1159 | key: '' + id, | ||
1160 | deleteKey: 'video', | ||
1161 | fun | ||
1162 | }) | ||
1163 | } | ||
1164 | |||
1475 | static loadWithRights (id: number | string, t?: Transaction): Bluebird<MVideoWithRights> { | 1165 | static loadWithRights (id: number | string, t?: Transaction): Bluebird<MVideoWithRights> { |
1476 | const where = buildWhereIdOrUUID(id) | 1166 | const where = buildWhereIdOrUUID(id) |
1477 | const options = { | 1167 | const options = { |
@@ -1535,6 +1225,26 @@ export class VideoModel extends Model<VideoModel> { | |||
1535 | return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query) | 1225 | return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query) |
1536 | } | 1226 | } |
1537 | 1227 | ||
1228 | static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Bluebird<MVideoImmutable> { | ||
1229 | const fun = () => { | ||
1230 | const query: FindOptions = { | ||
1231 | where: { | ||
1232 | url | ||
1233 | }, | ||
1234 | transaction | ||
1235 | } | ||
1236 | |||
1237 | return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query) | ||
1238 | } | ||
1239 | |||
1240 | return ModelCache.Instance.doCache({ | ||
1241 | cacheType: 'load-video-immutable-url', | ||
1242 | key: url, | ||
1243 | deleteKey: 'video', | ||
1244 | fun | ||
1245 | }) | ||
1246 | } | ||
1247 | |||
1538 | static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Bluebird<MVideoAccountLightBlacklistAllFiles> { | 1248 | static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Bluebird<MVideoAccountLightBlacklistAllFiles> { |
1539 | const query: FindOptions = { | 1249 | const query: FindOptions = { |
1540 | where: { | 1250 | where: { |
@@ -1581,8 +1291,8 @@ export class VideoModel extends Model<VideoModel> { | |||
1581 | } | 1291 | } |
1582 | 1292 | ||
1583 | static loadForGetAPI (parameters: { | 1293 | static loadForGetAPI (parameters: { |
1584 | id: number | string, | 1294 | id: number | string |
1585 | t?: Transaction, | 1295 | t?: Transaction |
1586 | userId?: number | 1296 | userId?: number |
1587 | }): Bluebird<MVideoDetails> { | 1297 | }): Bluebird<MVideoDetails> { |
1588 | const { id, t, userId } = parameters | 1298 | const { id, t, userId } = parameters |
@@ -1619,16 +1329,25 @@ export class VideoModel extends Model<VideoModel> { | |||
1619 | remote: false | 1329 | remote: false |
1620 | } | 1330 | } |
1621 | }) | 1331 | }) |
1622 | const totalVideos = await VideoModel.count() | ||
1623 | 1332 | ||
1624 | let totalLocalVideoViews = await VideoModel.sum('views', { | 1333 | let totalLocalVideoViews = await VideoModel.sum('views', { |
1625 | where: { | 1334 | where: { |
1626 | remote: false | 1335 | remote: false |
1627 | } | 1336 | } |
1628 | }) | 1337 | }) |
1338 | |||
1629 | // Sequelize could return null... | 1339 | // Sequelize could return null... |
1630 | if (!totalLocalVideoViews) totalLocalVideoViews = 0 | 1340 | if (!totalLocalVideoViews) totalLocalVideoViews = 0 |
1631 | 1341 | ||
1342 | const { total: totalVideos } = await VideoModel.listForApi({ | ||
1343 | start: 0, | ||
1344 | count: 0, | ||
1345 | sort: '-publishedAt', | ||
1346 | nsfw: buildNSFWFilter(), | ||
1347 | includeLocalVideos: true, | ||
1348 | withFiles: false | ||
1349 | }) | ||
1350 | |||
1632 | return { | 1351 | return { |
1633 | totalLocalVideos, | 1352 | totalLocalVideos, |
1634 | totalLocalVideoViews, | 1353 | totalLocalVideoViews, |
@@ -1648,9 +1367,9 @@ export class VideoModel extends Model<VideoModel> { | |||
1648 | static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) { | 1367 | static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) { |
1649 | // Instances only share videos | 1368 | // Instances only share videos |
1650 | const query = 'SELECT 1 FROM "videoShare" ' + | 1369 | const query = 'SELECT 1 FROM "videoShare" ' + |
1651 | 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + | 1370 | 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + |
1652 | 'WHERE "actorFollow"."actorId" = $followerActorId AND "videoShare"."videoId" = $videoId ' + | 1371 | 'WHERE "actorFollow"."actorId" = $followerActorId AND "videoShare"."videoId" = $videoId ' + |
1653 | 'LIMIT 1' | 1372 | 'LIMIT 1' |
1654 | 1373 | ||
1655 | const options = { | 1374 | const options = { |
1656 | type: QueryTypes.SELECT as QueryTypes.SELECT, | 1375 | type: QueryTypes.SELECT as QueryTypes.SELECT, |
@@ -1682,7 +1401,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1682 | } | 1401 | } |
1683 | 1402 | ||
1684 | return VideoModel.findAll(query) | 1403 | return VideoModel.findAll(query) |
1685 | .then(videos => videos.map(v => v.id)) | 1404 | .then(videos => videos.map(v => v.id)) |
1686 | } | 1405 | } |
1687 | 1406 | ||
1688 | // threshold corresponds to how many video the field should have to be returned | 1407 | // threshold corresponds to how many video the field should have to be returned |
@@ -1690,26 +1409,22 @@ export class VideoModel extends Model<VideoModel> { | |||
1690 | const serverActor = await getServerActor() | 1409 | const serverActor = await getServerActor() |
1691 | const followerActorId = serverActor.id | 1410 | const followerActorId = serverActor.id |
1692 | 1411 | ||
1693 | const scopeOptions: AvailableForListIDsOptions = { | 1412 | const queryOptions: BuildVideosQueryOptions = { |
1413 | attributes: [ `"${field}"` ], | ||
1414 | group: `GROUP BY "${field}"`, | ||
1415 | having: `HAVING COUNT("${field}") >= ${threshold}`, | ||
1416 | start: 0, | ||
1417 | sort: 'random', | ||
1418 | count, | ||
1694 | serverAccountId: serverActor.Account.id, | 1419 | serverAccountId: serverActor.Account.id, |
1695 | followerActorId, | 1420 | followerActorId, |
1696 | includeLocalVideos: true, | 1421 | includeLocalVideos: true |
1697 | attributesType: 'none' // Don't break aggregation | ||
1698 | } | 1422 | } |
1699 | 1423 | ||
1700 | const query: FindOptions = { | 1424 | const { query, replacements } = buildListQuery(VideoModel, queryOptions) |
1701 | attributes: [ field ], | ||
1702 | limit: count, | ||
1703 | group: field, | ||
1704 | having: Sequelize.where( | ||
1705 | Sequelize.fn('COUNT', Sequelize.col(field)), { [ Op.gte ]: threshold } | ||
1706 | ), | ||
1707 | order: [ (this.sequelize as any).random() ] | ||
1708 | } | ||
1709 | 1425 | ||
1710 | return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] }) | 1426 | return this.sequelize.query<any>(query, { replacements, type: QueryTypes.SELECT }) |
1711 | .findAll(query) | 1427 | .then(rows => rows.map(r => r[field])) |
1712 | .then(rows => rows.map(r => r[ field ])) | ||
1713 | } | 1428 | } |
1714 | 1429 | ||
1715 | static buildTrendingQuery (trendingDays: number) { | 1430 | static buildTrendingQuery (trendingDays: number) { |
@@ -1720,42 +1435,37 @@ export class VideoModel extends Model<VideoModel> { | |||
1720 | required: false, | 1435 | required: false, |
1721 | where: { | 1436 | where: { |
1722 | startDate: { | 1437 | startDate: { |
1723 | [ Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays) | 1438 | [Op.gte]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays) |
1724 | } | 1439 | } |
1725 | } | 1440 | } |
1726 | } | 1441 | } |
1727 | } | 1442 | } |
1728 | 1443 | ||
1729 | private static async getAvailableForApi ( | 1444 | private static async getAvailableForApi ( |
1730 | query: FindOptions & { where?: null }, // Forbid where field in query | 1445 | options: BuildVideosQueryOptions, |
1731 | options: AvailableForListIDsOptions, | ||
1732 | countVideos = true | 1446 | countVideos = true |
1733 | ) { | 1447 | ) { |
1734 | const idsScope: ScopeOptions = { | 1448 | function getCount () { |
1735 | method: [ | 1449 | if (countVideos !== true) return Promise.resolve(undefined) |
1736 | ScopeNames.AVAILABLE_FOR_LIST_IDS, options | ||
1737 | ] | ||
1738 | } | ||
1739 | 1450 | ||
1740 | // Remove trending sort on count, because it uses a group by | 1451 | const countOptions = Object.assign({}, options, { isCount: true }) |
1741 | const countOptions = Object.assign({}, options, { trendingDays: undefined }) | 1452 | const { query: queryCount, replacements: replacementsCount } = buildListQuery(VideoModel, countOptions) |
1742 | const countQuery: CountOptions = Object.assign({}, query, { attributes: undefined, group: undefined }) | 1453 | |
1743 | const countScope: ScopeOptions = { | 1454 | return VideoModel.sequelize.query<any>(queryCount, { replacements: replacementsCount, type: QueryTypes.SELECT }) |
1744 | method: [ | 1455 | .then(rows => rows.length !== 0 ? rows[0].total : 0) |
1745 | ScopeNames.AVAILABLE_FOR_LIST_IDS, countOptions | ||
1746 | ] | ||
1747 | } | 1456 | } |
1748 | 1457 | ||
1749 | const [ count, rows ] = await Promise.all([ | 1458 | function getModels () { |
1750 | countVideos | 1459 | if (options.count === 0) return Promise.resolve([]) |
1751 | ? VideoModel.scope(countScope).count(countQuery) | 1460 | |
1752 | : Promise.resolve<number>(undefined), | 1461 | const { query, replacements, order } = buildListQuery(VideoModel, options) |
1462 | const queryModels = wrapForAPIResults(query, replacements, options, order) | ||
1753 | 1463 | ||
1754 | VideoModel.scope(idsScope) | 1464 | return VideoModel.sequelize.query<any>(queryModels, { replacements, type: QueryTypes.SELECT, nest: true }) |
1755 | .findAll(Object.assign({}, query, { raw: true })) | 1465 | .then(rows => VideoModel.buildAPIResult(rows)) |
1756 | .then(rows => rows.map(r => r.id)) | 1466 | } |
1757 | .then(ids => VideoModel.loadCompleteVideosForApi(ids, query, options)) | 1467 | |
1758 | ]) | 1468 | const [ count, rows ] = await Promise.all([ getCount(), getModels() ]) |
1759 | 1469 | ||
1760 | return { | 1470 | return { |
1761 | data: rows, | 1471 | data: rows, |
@@ -1763,37 +1473,113 @@ export class VideoModel extends Model<VideoModel> { | |||
1763 | } | 1473 | } |
1764 | } | 1474 | } |
1765 | 1475 | ||
1766 | private static loadCompleteVideosForApi (ids: number[], query: FindOptions, options: AvailableForListIDsOptions) { | 1476 | private static buildAPIResult (rows: any[]) { |
1767 | if (ids.length === 0) return [] | 1477 | const memo: { [ id: number ]: VideoModel } = {} |
1478 | |||
1479 | const thumbnailsDone = new Set<number>() | ||
1480 | const historyDone = new Set<number>() | ||
1481 | const videoFilesDone = new Set<number>() | ||
1482 | |||
1483 | const videos: VideoModel[] = [] | ||
1484 | |||
1485 | const avatarKeys = [ 'id', 'filename', 'fileUrl', 'onDisk', 'createdAt', 'updatedAt' ] | ||
1486 | const actorKeys = [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ] | ||
1487 | const serverKeys = [ 'id', 'host' ] | ||
1488 | const videoFileKeys = [ 'id', 'createdAt', 'updatedAt', 'resolution', 'size', 'extname', 'infoHash', 'fps', 'videoId' ] | ||
1489 | const videoKeys = [ | ||
1490 | 'id', | ||
1491 | 'uuid', | ||
1492 | 'name', | ||
1493 | 'category', | ||
1494 | 'licence', | ||
1495 | 'language', | ||
1496 | 'privacy', | ||
1497 | 'nsfw', | ||
1498 | 'description', | ||
1499 | 'support', | ||
1500 | 'duration', | ||
1501 | 'views', | ||
1502 | 'likes', | ||
1503 | 'dislikes', | ||
1504 | 'remote', | ||
1505 | 'url', | ||
1506 | 'commentsEnabled', | ||
1507 | 'downloadEnabled', | ||
1508 | 'waitTranscoding', | ||
1509 | 'state', | ||
1510 | 'publishedAt', | ||
1511 | 'originallyPublishedAt', | ||
1512 | 'channelId', | ||
1513 | 'createdAt', | ||
1514 | 'updatedAt' | ||
1515 | ] | ||
1768 | 1516 | ||
1769 | const secondQuery: FindOptions = { | 1517 | function buildActor (rowActor: any) { |
1770 | offset: 0, | 1518 | const avatarModel = rowActor.Avatar.id !== null |
1771 | limit: query.limit, | 1519 | ? new AvatarModel(pick(rowActor.Avatar, avatarKeys)) |
1772 | attributes: query.attributes, | 1520 | : null |
1773 | order: [ // Keep original order | 1521 | |
1774 | Sequelize.literal( | 1522 | const serverModel = rowActor.Server.id !== null |
1775 | ids.map(id => `"VideoModel".id = ${id} DESC`).join(', ') | 1523 | ? new ServerModel(pick(rowActor.Server, serverKeys)) |
1776 | ) | 1524 | : null |
1777 | ] | ||
1778 | } | ||
1779 | 1525 | ||
1780 | const apiScope: (string | ScopeOptions)[] = [] | 1526 | const actorModel = new ActorModel(pick(rowActor, actorKeys)) |
1527 | actorModel.Avatar = avatarModel | ||
1528 | actorModel.Server = serverModel | ||
1781 | 1529 | ||
1782 | if (options.user) { | 1530 | return actorModel |
1783 | apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] }) | ||
1784 | } | 1531 | } |
1785 | 1532 | ||
1786 | apiScope.push({ | 1533 | for (const row of rows) { |
1787 | method: [ | 1534 | if (!memo[row.id]) { |
1788 | ScopeNames.FOR_API, { | 1535 | // Build Channel |
1789 | ids, | 1536 | const channel = row.VideoChannel |
1790 | withFiles: options.withFiles, | 1537 | const channelModel = new VideoChannelModel(pick(channel, [ 'id', 'name', 'description', 'actorId' ])) |
1791 | videoPlaylistId: options.videoPlaylistId | 1538 | channelModel.Actor = buildActor(channel.Actor) |
1792 | } as ForAPIOptions | 1539 | |
1793 | ] | 1540 | const account = row.VideoChannel.Account |
1794 | }) | 1541 | const accountModel = new AccountModel(pick(account, [ 'id', 'name' ])) |
1542 | accountModel.Actor = buildActor(account.Actor) | ||
1543 | |||
1544 | channelModel.Account = accountModel | ||
1545 | |||
1546 | const videoModel = new VideoModel(pick(row, videoKeys)) | ||
1547 | videoModel.VideoChannel = channelModel | ||
1795 | 1548 | ||
1796 | return VideoModel.scope(apiScope).findAll(secondQuery) | 1549 | videoModel.UserVideoHistories = [] |
1550 | videoModel.Thumbnails = [] | ||
1551 | videoModel.VideoFiles = [] | ||
1552 | |||
1553 | memo[row.id] = videoModel | ||
1554 | // Don't take object value to have a sorted array | ||
1555 | videos.push(videoModel) | ||
1556 | } | ||
1557 | |||
1558 | const videoModel = memo[row.id] | ||
1559 | |||
1560 | if (row.userVideoHistory?.id && !historyDone.has(row.userVideoHistory.id)) { | ||
1561 | const historyModel = new UserVideoHistoryModel(pick(row.userVideoHistory, [ 'id', 'currentTime' ])) | ||
1562 | videoModel.UserVideoHistories.push(historyModel) | ||
1563 | |||
1564 | historyDone.add(row.userVideoHistory.id) | ||
1565 | } | ||
1566 | |||
1567 | if (row.Thumbnails?.id && !thumbnailsDone.has(row.Thumbnails.id)) { | ||
1568 | const thumbnailModel = new ThumbnailModel(pick(row.Thumbnails, [ 'id', 'type', 'filename' ])) | ||
1569 | videoModel.Thumbnails.push(thumbnailModel) | ||
1570 | |||
1571 | thumbnailsDone.add(row.Thumbnails.id) | ||
1572 | } | ||
1573 | |||
1574 | if (row.VideoFiles?.id && !videoFilesDone.has(row.VideoFiles.id)) { | ||
1575 | const videoFileModel = new VideoFileModel(pick(row.VideoFiles, videoFileKeys)) | ||
1576 | videoModel.VideoFiles.push(videoFileModel) | ||
1577 | |||
1578 | videoFilesDone.add(row.VideoFiles.id) | ||
1579 | } | ||
1580 | } | ||
1581 | |||
1582 | return videos | ||
1797 | } | 1583 | } |
1798 | 1584 | ||
1799 | private static isPrivacyForFederation (privacy: VideoPrivacy) { | 1585 | private static isPrivacyForFederation (privacy: VideoPrivacy) { |
@@ -1803,23 +1589,23 @@ export class VideoModel extends Model<VideoModel> { | |||
1803 | } | 1589 | } |
1804 | 1590 | ||
1805 | static getCategoryLabel (id: number) { | 1591 | static getCategoryLabel (id: number) { |
1806 | return VIDEO_CATEGORIES[ id ] || 'Misc' | 1592 | return VIDEO_CATEGORIES[id] || 'Misc' |
1807 | } | 1593 | } |
1808 | 1594 | ||
1809 | static getLicenceLabel (id: number) { | 1595 | static getLicenceLabel (id: number) { |
1810 | return VIDEO_LICENCES[ id ] || 'Unknown' | 1596 | return VIDEO_LICENCES[id] || 'Unknown' |
1811 | } | 1597 | } |
1812 | 1598 | ||
1813 | static getLanguageLabel (id: string) { | 1599 | static getLanguageLabel (id: string) { |
1814 | return VIDEO_LANGUAGES[ id ] || 'Unknown' | 1600 | return VIDEO_LANGUAGES[id] || 'Unknown' |
1815 | } | 1601 | } |
1816 | 1602 | ||
1817 | static getPrivacyLabel (id: number) { | 1603 | static getPrivacyLabel (id: number) { |
1818 | return VIDEO_PRIVACIES[ id ] || 'Unknown' | 1604 | return VIDEO_PRIVACIES[id] || 'Unknown' |
1819 | } | 1605 | } |
1820 | 1606 | ||
1821 | static getStateLabel (id: number) { | 1607 | static getStateLabel (id: number) { |
1822 | return VIDEO_STATES[ id ] || 'Unknown' | 1608 | return VIDEO_STATES[id] || 'Unknown' |
1823 | } | 1609 | } |
1824 | 1610 | ||
1825 | isBlacklisted () { | 1611 | isBlacklisted () { |
@@ -1831,7 +1617,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1831 | this.VideoChannel.Account.isBlocked() | 1617 | this.VideoChannel.Account.isBlocked() |
1832 | } | 1618 | } |
1833 | 1619 | ||
1834 | getQualityFileBy <T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) { | 1620 | getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) { |
1835 | if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) { | 1621 | if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) { |
1836 | const file = fun(this.VideoFiles, file => file.resolution) | 1622 | const file = fun(this.VideoFiles, file => file.resolution) |
1837 | 1623 | ||
@@ -1849,15 +1635,15 @@ export class VideoModel extends Model<VideoModel> { | |||
1849 | return undefined | 1635 | return undefined |
1850 | } | 1636 | } |
1851 | 1637 | ||
1852 | getMaxQualityFile <T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo { | 1638 | getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo { |
1853 | return this.getQualityFileBy(maxBy) | 1639 | return this.getQualityFileBy(maxBy) |
1854 | } | 1640 | } |
1855 | 1641 | ||
1856 | getMinQualityFile <T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo { | 1642 | getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo { |
1857 | return this.getQualityFileBy(minBy) | 1643 | return this.getQualityFileBy(minBy) |
1858 | } | 1644 | } |
1859 | 1645 | ||
1860 | getWebTorrentFile <T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo { | 1646 | getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo { |
1861 | if (Array.isArray(this.VideoFiles) === false) return undefined | 1647 | if (Array.isArray(this.VideoFiles) === false) return undefined |
1862 | 1648 | ||
1863 | const file = this.VideoFiles.find(f => f.resolution === resolution) | 1649 | const file = this.VideoFiles.find(f => f.resolution === resolution) |
@@ -1893,6 +1679,10 @@ export class VideoModel extends Model<VideoModel> { | |||
1893 | return this.uuid + '.jpg' | 1679 | return this.uuid + '.jpg' |
1894 | } | 1680 | } |
1895 | 1681 | ||
1682 | hasPreview () { | ||
1683 | return !!this.getPreview() | ||
1684 | } | ||
1685 | |||
1896 | getPreview () { | 1686 | getPreview () { |
1897 | if (Array.isArray(this.Thumbnails) === false) return undefined | 1687 | if (Array.isArray(this.Thumbnails) === false) return undefined |
1898 | 1688 | ||
@@ -1980,8 +1770,8 @@ export class VideoModel extends Model<VideoModel> { | |||
1980 | } | 1770 | } |
1981 | 1771 | ||
1982 | this.VideoStreamingPlaylists = this.VideoStreamingPlaylists | 1772 | this.VideoStreamingPlaylists = this.VideoStreamingPlaylists |
1983 | .filter(s => s.type !== VideoStreamingPlaylistType.HLS) | 1773 | .filter(s => s.type !== VideoStreamingPlaylistType.HLS) |
1984 | .concat(toAdd) | 1774 | .concat(toAdd) |
1985 | } | 1775 | } |
1986 | 1776 | ||
1987 | removeFile (videoFile: MVideoFile, isRedundancy = false) { | 1777 | removeFile (videoFile: MVideoFile, isRedundancy = false) { |
@@ -2002,7 +1792,7 @@ export class VideoModel extends Model<VideoModel> { | |||
2002 | await remove(directoryPath) | 1792 | await remove(directoryPath) |
2003 | 1793 | ||
2004 | if (isRedundancy !== true) { | 1794 | if (isRedundancy !== true) { |
2005 | let streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo | 1795 | const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo |
2006 | streamingPlaylistWithFiles.Video = this | 1796 | streamingPlaylistWithFiles.Video = this |
2007 | 1797 | ||
2008 | if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) { | 1798 | if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) { |
@@ -2096,6 +1886,14 @@ export class VideoModel extends Model<VideoModel> { | |||
2096 | return baseUrlHttp + STATIC_PATHS.WEBSEED + getVideoFilename(this, videoFile) | 1886 | return baseUrlHttp + STATIC_PATHS.WEBSEED + getVideoFilename(this, videoFile) |
2097 | } | 1887 | } |
2098 | 1888 | ||
1889 | getVideoFileMetadataUrl (videoFile: MVideoFile, baseUrlHttp: string) { | ||
1890 | const path = '/api/v1/videos/' | ||
1891 | |||
1892 | return this.isOwned() | ||
1893 | ? baseUrlHttp + path + this.uuid + '/metadata/' + videoFile.id | ||
1894 | : videoFile.metadataUrl | ||
1895 | } | ||
1896 | |||
2099 | getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) { | 1897 | getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) { |
2100 | return baseUrlHttp + STATIC_PATHS.REDUNDANCY + getVideoFilename(this, videoFile) | 1898 | return baseUrlHttp + STATIC_PATHS.REDUNDANCY + getVideoFilename(this, videoFile) |
2101 | } | 1899 | } |