aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models
diff options
context:
space:
mode:
Diffstat (limited to 'server/models')
-rw-r--r--server/models/account/account-blocklist.ts34
-rw-r--r--server/models/account/account-video-rate.ts12
-rw-r--r--server/models/account/account.ts69
-rw-r--r--server/models/account/user-notification.ts4
-rw-r--r--server/models/account/user-video-history.ts2
-rw-r--r--server/models/account/user.ts293
-rw-r--r--server/models/activitypub/actor-follow.ts68
-rw-r--r--server/models/activitypub/actor.ts115
-rw-r--r--server/models/application/application.ts11
-rw-r--r--server/models/model-cache.ts91
-rw-r--r--server/models/oauth/oauth-token.ts60
-rw-r--r--server/models/redundancy/video-redundancy.ts222
-rw-r--r--server/models/server/plugin.ts82
-rw-r--r--server/models/server/server-blocklist.ts17
-rw-r--r--server/models/server/server.ts7
-rw-r--r--server/models/utils.ts75
-rw-r--r--server/models/video/thumbnail.ts15
-rw-r--r--server/models/video/video-abuse.ts339
-rw-r--r--server/models/video/video-blacklist.ts13
-rw-r--r--server/models/video/video-caption.ts27
-rw-r--r--server/models/video/video-channel.ts176
-rw-r--r--server/models/video/video-comment.ts41
-rw-r--r--server/models/video/video-file.ts81
-rw-r--r--server/models/video/video-format-utils.ts53
-rw-r--r--server/models/video/video-import.ts1
-rw-r--r--server/models/video/video-playlist-element.ts13
-rw-r--r--server/models/video/video-playlist.ts42
-rw-r--r--server/models/video/video-query-builder.ts503
-rw-r--r--server/models/video/video-share.ts89
-rw-r--r--server/models/video/video.ts976
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 @@
1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { AccountModel } from './account' 2import { AccountModel } from './account'
3import { getSort } from '../utils' 3import { getSort, searchAttribute } from '../utils'
4import { AccountBlock } from '../../../shared/models/blocklist' 4import { AccountBlock } from '../../../shared/models/blocklist'
5import { Op } from 'sequelize' 5import { Op } from 'sequelize'
6import * as Bluebird from 'bluebird' 6import * 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
32import { AccountBlocklistModel } from './account-blocklist' 32import { AccountBlocklistModel } from './account-blocklist'
33import { ServerBlocklistModel } from '../server/server-blocklist' 33import { ServerBlocklistModel } from '../server/server-blocklist'
34import { ActorFollowModel } from '../activitypub/actor-follow' 34import { ActorFollowModel } from '../activitypub/actor-follow'
35import { MAccountActor, MAccountDefault, MAccountSummaryFormattable, MAccountFormattable, MAccountAP } from '../../typings/models' 35import { MAccountActor, MAccountAP, MAccountDefault, MAccountFormattable, MAccountSummaryFormattable } from '../../typings/models'
36import * as Bluebird from 'bluebird' 36import * as Bluebird from 'bluebird'
37import { ModelCache } from '@server/models/model-cache'
37 38
38export enum ScopeNames { 39export 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 @@
1import { FindOptions, literal, Op, QueryTypes, where, fn, col } from 'sequelize' 1import { col, FindOptions, fn, literal, Op, QueryTypes, where, WhereOptions } from 'sequelize'
2import { 2import {
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'
22import { hasUserRight, MyUser, USER_ROLE_LABELS, UserRight, VideoPlaylistType, VideoPrivacy } from '../../../shared' 22import { hasUserRight, MyUser, USER_ROLE_LABELS, UserRight, VideoAbuseState, VideoPlaylistType, VideoPrivacy } from '../../../shared'
23import { User, UserRole } from '../../../shared/models/users' 23import { User, UserRole } from '../../../shared/models/users'
24import { 24import {
25 isNoInstanceConfigWarningModal, 25 isNoInstanceConfigWarningModal,
@@ -49,7 +49,7 @@ import { VideoPlaylistModel } from '../video/video-playlist'
49import { AccountModel } from './account' 49import { AccountModel } from './account'
50import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type' 50import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
51import { values } from 'lodash' 51import { values } from 'lodash'
52import { DEFAULT_THEME_NAME, DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants' 52import { DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants'
53import { clearCacheByUserId } from '../../lib/oauth-model' 53import { clearCacheByUserId } from '../../lib/oauth-model'
54import { UserNotificationSettingModel } from './user-notification-setting' 54import { UserNotificationSettingModel } from './user-notification-setting'
55import { VideoModel } from '../video/video' 55import { VideoModel } from '../video/video'
@@ -71,7 +71,9 @@ import {
71} from '@server/typings/models' 71} from '@server/typings/models'
72 72
73enum ScopeNames { 73enum 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})
130export class UserModel extends Model<UserModel> { 222export 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 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { values, difference } from 'lodash' 2import { difference, values } from 'lodash'
3import { 3import {
4 AfterCreate, 4 AfterCreate,
5 AfterDestroy, 5 AfterDestroy,
@@ -20,10 +20,9 @@ import {
20import { FollowState } from '../../../shared/models/actors' 20import { FollowState } from '../../../shared/models/actors'
21import { ActorFollow } from '../../../shared/models/actors/follow.model' 21import { ActorFollow } from '../../../shared/models/actors/follow.model'
22import { logger } from '../../helpers/logger' 22import { logger } from '../../helpers/logger'
23import { getServerActor } from '../../helpers/utils'
24import { ACTOR_FOLLOW_SCORE, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants' 23import { ACTOR_FOLLOW_SCORE, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants'
25import { ServerModel } from '../server/server' 24import { ServerModel } from '../server/server'
26import { createSafeIn, getSort, getFollowsSort } from '../utils' 25import { createSafeIn, getFollowsSort, getSort } from '../utils'
27import { ActorModel, unusedActorAttributesForAPI } from './actor' 26import { ActorModel, unusedActorAttributesForAPI } from './actor'
28import { VideoChannelModel } from '../video/video-channel' 27import { VideoChannelModel } from '../video/video-channel'
29import { AccountModel } from '../account/account' 28import { AccountModel } from '../account/account'
@@ -36,7 +35,8 @@ import {
36 MActorFollowSubscriptions 35 MActorFollowSubscriptions
37} from '@server/typings/models' 36} from '@server/typings/models'
38import { ActivityPubActorType } from '@shared/models' 37import { ActivityPubActorType } from '@shared/models'
39import { afterCommitIfTransaction } from '@server/helpers/database-utils' 38import { VideoModel } from '@server/models/video/video'
39import { 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'
19import { ActivityPubActorType } from '../../../shared/models/activitypub' 19import { ActivityIconObject, ActivityPubActorType } from '../../../shared/models/activitypub'
20import { Avatar } from '../../../shared/models/avatars/avatar.model' 20import { Avatar } from '../../../shared/models/avatars/avatar.model'
21import { activityPubContextify } from '../../helpers/activitypub' 21import { activityPubContextify } from '../../helpers/activitypub'
22import { 22import {
@@ -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'
49import * as Bluebird from 'bluebird' 49import * as Bluebird from 'bluebird'
50import { Op, Transaction, literal } from 'sequelize' 50import { Op, Transaction, literal } from 'sequelize'
51import { ModelCache } from '@server/models/model-cache'
51 52
52enum ScopeNames { 53enum 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 @@
1import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Model, Table } from 'sequelize-typescript' 1import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Model, Table } from 'sequelize-typescript'
2import { AccountModel } from '../account/account' 2import { AccountModel } from '../account/account'
3import * as memoizee from 'memoizee'
4
5export 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 @@
1import { Model } from 'sequelize-typescript'
2import * as Bluebird from 'bluebird'
3import { logger } from '@server/helpers/logger'
4
5type ModelCacheType =
6 'local-account-name'
7 | 'local-actor-name'
8 | 'local-actor-url'
9 | 'load-video-immutable-id'
10 | 'load-video-immutable-url'
11
12type DeleteKey =
13 'video'
14
15class 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
89export {
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
24export type OAuthTokenInfo = { 24export 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
35enum ScopeNames { 36enum 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'
15import { ActorModel } from '../activitypub/actor' 15import { ActorModel } from '../activitypub/actor'
16import { getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils' 16import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils'
17import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc' 17import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc'
18import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants' 18import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
19import { VideoFileModel } from '../video/video-file' 19import { VideoFileModel } from '../video/video-file'
20import { getServerActor } from '../../helpers/utils'
21import { VideoModel } from '../video/video' 20import { VideoModel } from '../video/video'
22import { VideoRedundancyStrategy } from '../../../shared/models/redundancy' 21import { VideoRedundancyStrategy, VideoRedundancyStrategyWithManual } from '../../../shared/models/redundancy'
23import { logger } from '../../helpers/logger' 22import { logger } from '../../helpers/logger'
24import { CacheFileObject, VideoPrivacy } from '../../../shared' 23import { CacheFileObject, VideoPrivacy } from '../../../shared'
25import { VideoChannelModel } from '../video/video-channel' 24import { VideoChannelModel } from '../video/video-channel'
@@ -27,17 +26,24 @@ import { ServerModel } from '../server/server'
27import { sample } from 'lodash' 26import { sample } from 'lodash'
28import { isTestInstance } from '../../helpers/core-utils' 27import { isTestInstance } from '../../helpers/core-utils'
29import * as Bluebird from 'bluebird' 28import * as Bluebird from 'bluebird'
30import { col, FindOptions, fn, literal, Op, Transaction } from 'sequelize' 29import { col, FindOptions, fn, literal, Op, Transaction, WhereOptions } from 'sequelize'
31import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist' 30import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist'
32import { CONFIG } from '../../initializers/config' 31import { CONFIG } from '../../initializers/config'
33import { MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/typings/models' 32import { MVideoForRedundancyAPI, MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/typings/models'
33import { VideoRedundanciesTarget } from '@shared/models/redundancy/video-redundancies-filters.model'
34import {
35 FileRedundancyInformation,
36 StreamingPlaylistRedundancyInformation,
37 VideoRedundancy
38} from '@shared/models/redundancy/video-redundancy.model'
39import { getServerActor } from '@server/models/application/application'
34 40
35export enum ScopeNames { 41export 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 @@
1import * as Bluebird from 'bluebird'
2import { FindAndCountOptions, json, QueryTypes } from 'sequelize'
1import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 3import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { getSort, throwIfNotValid } from '../utils' 4import { MPlugin, MPluginFormattable } from '@server/typings/models'
5import { PeerTubePlugin } from '../../../shared/models/plugins/peertube-plugin.model'
6import { PluginType } from '../../../shared/models/plugins/plugin.type'
7import { RegisterServerSettingOptions } from '../../../shared/models/plugins/register-server-setting.model'
3import { 8import {
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'
10import { PluginType } from '../../../shared/models/plugins/plugin.type' 15import { getSort, throwIfNotValid } from '../utils'
11import { PeerTubePlugin } from '../../../shared/models/plugins/peertube-plugin.model'
12import { FindAndCountOptions, json } from 'sequelize'
13import { RegisterServerSettingOptions } from '../../../shared/models/plugins/register-server-setting.model'
14import * as Bluebird from 'bluebird'
15import { 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
2import { AccountModel } from '../account/account' 2import { AccountModel } from '../account/account'
3import { ServerModel } from './server' 3import { ServerModel } from './server'
4import { ServerBlock } from '../../../shared/models/blocklist' 4import { ServerBlock } from '../../../shared/models/blocklist'
5import { getSort } from '../utils' 5import { getSort, searchAttribute } from '../utils'
6import * as Bluebird from 'bluebird' 6import * as Bluebird from 'bluebird'
7import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/typings/models' 7import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/typings/models'
8import { Op } from 'sequelize' 8import { 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 @@
1import { Model, Sequelize } from 'sequelize-typescript' 1import { Model, Sequelize } from 'sequelize-typescript'
2import validator from 'validator' 2import validator from 'validator'
3import { Col } from 'sequelize/types/lib/utils' 3import { Col } from 'sequelize/types/lib/utils'
4import { literal, OrderItem } from 'sequelize' 4import { literal, OrderItem, Op } from 'sequelize'
5
6type Primitive = string | Function | number | boolean | Symbol | undefined | null
7type 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}
17type DeepOmit<T, K> = T extends Primitive ? T : DeepOmitHelper<T, Exclude<keyof T, K>>
18
19type DeepOmitArray<T extends any[], K> = {
20 [P in keyof T]: DeepOmit<T[P], K>
21}
5 22
6type SortType = { sortModel: string, sortValue: string } 23type SortType = { sortModel: string, sortValue: string }
7 24
@@ -67,7 +84,7 @@ function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): Or
67function getBlacklistSort (model: any, value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] { 84function 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
145function buildWhereIdOrUUID (id: number | string) { 162function buildWhereIdOrUUID (id: number | string) {
@@ -156,8 +173,11 @@ function parseAggregateResult (result: any) {
156} 173}
157 174
158const createSafeIn = (model: typeof Model, stringArr: (string | number)[]) => { 175const 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
163function buildLocalAccountIdsIn () { 183function buildLocalAccountIdsIn () {
@@ -172,9 +192,35 @@ function buildLocalActorIdsIn () {
172 ) 192 )
173} 193}
174 194
195function 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
210function 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
177export { 222export {
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) {
203function searchTrigramNormalizeCol (col: string) { 251function 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
207function 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'
19import { VideoModel } from './video' 19import { VideoModel } from './video'
20import { VideoPlaylistModel } from './video-playlist' 20import { VideoPlaylistModel } from './video-playlist'
21import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' 21import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
22import { MVideoAccountLight } from '@server/typings/models'
23import { 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 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import * as Bluebird from 'bluebird'
2import { literal, Op } from 'sequelize'
3import {
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'
17import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
18import { VideoAbuseState, VideoDetails } from '../../../shared'
2import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' 19import { VideoAbuseObject } from '../../../shared/models/activitypub/objects'
3import { VideoAbuse } from '../../../shared/models/videos' 20import { VideoAbuse } from '../../../shared/models/videos'
4import { 21import {
@@ -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'
9import { AccountModel } from '../account/account'
10import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils'
11import { VideoModel } from './video'
12import { VideoAbuseState } from '../../../shared'
13import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants' 26import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
14import { MUserAccountId, MVideoAbuse, MVideoAbuseFormattable, MVideoAbuseVideo } from '../../typings/models' 27import { MUserAccountId, MVideoAbuse, MVideoAbuseFormattable, MVideoAbuseVideo } from '../../typings/models'
15import * as Bluebird from 'bluebird' 28import { AccountModel } from '../account/account'
16import { literal, Op } from 'sequelize' 29import { buildBlockedAccountSQL, getSort, searchAttribute, throwIfNotValid } from '../utils'
30import { ThumbnailModel } from './thumbnail'
31import { VideoModel } from './video'
32import { VideoBlacklistModel } from './video-blacklist'
33import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
34
35export 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 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { getBlacklistSort, SortType, throwIfNotValid } from '../utils' 2import { getBlacklistSort, SortType, throwIfNotValid, searchAttribute } from '../utils'
3import { VideoModel } from './video' 3import { VideoModel } from './video'
4import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' 4import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
5import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' 5import { 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'
16import { VideoModel } from './video' 17import { VideoModel } from './video'
17import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' 18import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
18import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' 19import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model'
19import { LAZY_STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers/constants' 20import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants'
20import { join } from 'path' 21import { join } from 'path'
21import { logger } from '../../helpers/logger' 22import { logger } from '../../helpers/logger'
22import { remove } from 'fs-extra' 23import { remove } from 'fs-extra'
23import { CONFIG } from '../../initializers/config' 24import { CONFIG } from '../../initializers/config'
24import * as Bluebird from 'bluebird' 25import * as Bluebird from 'bluebird'
25import { MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/typings/models' 26import { MVideoAccountLight, MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/typings/models'
27import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub'
26 28
27export enum ScopeNames { 29export 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
30import { VideoModel } from './video' 30import { VideoModel } from './video'
31import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' 31import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
32import { ServerModel } from '../server/server' 32import { ServerModel } from '../server/server'
33import { FindOptions, ModelIndexesOptions, Op } from 'sequelize' 33import { FindOptions, Op, literal, ScopeOptions } from 'sequelize'
34import { AvatarModel } from '../avatar/avatar' 34import { AvatarModel } from '../avatar/avatar'
35import { VideoPlaylistModel } from './video-playlist' 35import { VideoPlaylistModel } from './video-playlist'
36import * as Bluebird from 'bluebird' 36import * 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
47const indexes: ModelIndexesOptions[] = [
48 buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
49
50 {
51 fields: [ 'accountId' ]
52 },
53 {
54 fields: [ 'actorId' ]
55 }
56]
57
58export enum ScopeNames { 46export 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
66type AvailableForListOptions = { 55type AvailableForListOptions = {
67 actorId: number 56 actorId: number
68} 57}
69 58
59type AvailableWithStatsOptions = {
60 daysPrior: number
61}
62
70export type SummaryOptions = { 63export 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})
181export class VideoChannelModel extends Model<VideoChannelModel> { 220export 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'
9import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils' 9import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils'
10import { VideoModel } from './video' 10import { VideoModel } from './video'
11import { VideoChannelModel } from './video-channel' 11import { VideoChannelModel } from './video-channel'
12import { getServerActor } from '../../helpers/utils'
13import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' 12import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
14import { regexpCapture } from '../../helpers/regexp' 13import { regexpCapture } from '../../helpers/regexp'
15import { uniq } from 'lodash' 14import { uniq } from 'lodash'
@@ -27,6 +26,8 @@ import {
27 MCommentOwnerVideoReply 26 MCommentOwnerVideoReply
28} from '../../typings/models/video' 27} from '../../typings/models/video'
29import { MUserAccountId } from '@server/typings/models' 28import { MUserAccountId } from '@server/typings/models'
29import { VideoPrivacy } from '@shared/models'
30import { getServerActor } from '@server/models/application/application'
30 31
31enum ScopeNames { 32enum 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'
15import { 17import {
16 isVideoFileExtnameValid, 18 isVideoFileExtnameValid,
@@ -28,7 +30,33 @@ import { MIMETYPES, MEMOIZE_LENGTH, MEMOIZE_TTL } from '../../initializers/const
28import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../typings/models/video/video-file' 30import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../typings/models/video/video-file'
29import { MStreamingPlaylistVideo, MVideo } from '@server/typings/models' 31import { MStreamingPlaylistVideo, MVideo } from '@server/typings/models'
30import * as memoizee from 'memoizee' 32import * as memoizee from 'memoizee'
33import validator from 'validator'
31 34
35export 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'
12import { isArray } from '../../helpers/custom-validators/misc' 12import { isArray } from '../../helpers/custom-validators/misc'
13import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model' 13import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
14import { 14import {
@@ -23,16 +23,18 @@ import {
23import { MVideoFileRedundanciesOpt } from '../../typings/models/video/video-file' 23import { MVideoFileRedundanciesOpt } from '../../typings/models/video/video-file'
24import { VideoFile } from '@shared/models/videos/video-file.model' 24import { VideoFile } from '@shared/models/videos/video-file.model'
25import { generateMagnetUri } from '@server/helpers/webtorrent' 25import { generateMagnetUri } from '@server/helpers/webtorrent'
26import { extractVideo } from '@server/helpers/video'
26 27
27export type VideoFormattingJSONOptions = { 28export 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
36function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFormattingJSONOptions): Video { 38function 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 @@
1import { VideoFilter, VideoPrivacy, VideoState } from '@shared/models'
2import { buildDirectionAndField, createSafeIn } from '@server/models/utils'
3import { Model } from 'sequelize-typescript'
4import { MUserAccountId, MUserId } from '@server/typings/models'
5import validator from 'validator'
6import { exists } from '@server/helpers/custom-validators/misc'
7
8export 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
54function 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
358function 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
383function 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
500export {
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'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 3import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
4import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 4import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
5import { AccountModel } from '../account/account'
6import { ActorModel } from '../activitypub/actor' 5import { ActorModel } from '../activitypub/actor'
7import { buildLocalActorIdsIn, throwIfNotValid } from '../utils' 6import { buildLocalActorIdsIn, throwIfNotValid } from '../utils'
8import { VideoModel } from './video' 7import { VideoModel } from './video'
9import { VideoChannelModel } from './video-channel' 8import { literal, Op, Transaction } from 'sequelize'
10import { Op, Transaction } from 'sequelize'
11import { MVideoShareActor, MVideoShareFull } from '../../typings/models/video' 9import { MVideoShareActor, MVideoShareFull } from '../../typings/models/video'
12import { MActorDefault } from '../../typings/models' 10import { 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 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { maxBy, minBy } from 'lodash' 2import { maxBy, minBy, pick } from 'lodash'
3import { join } from 'path' 3import { join } from 'path'
4import { 4import { 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'
16import { 5import {
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'
55import { getVideoFileResolution } from '../../helpers/ffmpeg-utils' 44import { getVideoFileResolution } from '../../helpers/ffmpeg-utils'
56import { logger } from '../../helpers/logger' 45import { logger } from '../../helpers/logger'
57import { getServerActor } from '../../helpers/utils'
58import { 46import {
59 ACTIVITY_PUB, 47 ACTIVITY_PUB,
60 API_VERSION, 48 API_VERSION,
@@ -76,16 +64,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
76import { ActorModel } from '../activitypub/actor' 64import { ActorModel } from '../activitypub/actor'
77import { AvatarModel } from '../avatar/avatar' 65import { AvatarModel } from '../avatar/avatar'
78import { ServerModel } from '../server/server' 66import { ServerModel } from '../server/server'
79import { 67import { 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'
89import { TagModel } from './tag' 68import { TagModel } from './tag'
90import { VideoAbuseModel } from './video-abuse' 69import { VideoAbuseModel } from './video-abuse'
91import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' 70import { 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
142import { MThumbnail } from '../../typings/models/video/thumbnail' 122import { MThumbnail } from '../../typings/models/video/thumbnail'
143import { VideoFile } from '@shared/models/videos/video-file.model' 123import { VideoFile } from '@shared/models/videos/video-file.model'
144import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths' 124import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
145import validator from 'validator' 125import { ModelCache } from '@server/models/model-cache'
146 126import { 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 127import { buildNSFWFilter } from '@server/helpers/express-utils'
148const indexes: (ModelIndexesOptions & { where?: WhereOptions })[] = [ 128import { 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
215export enum ScopeNames { 130export 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})
753export class VideoModel extends Model<VideoModel> { 468export 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 }