aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
authorkontrollanten <6680299+kontrollanten@users.noreply.github.com>2022-02-28 08:34:43 +0100
committerGitHub <noreply@github.com>2022-02-28 08:34:43 +0100
commitd0800f7661f13fabe7bb6f4aa0ea50764f106405 (patch)
treed43e6b0b6f4a5a32e03487e6464edbcaf288be2a /server
parent5cad2ca9db9b9d138f8a33058d10b94a9fd50c69 (diff)
downloadPeerTube-d0800f7661f13fabe7bb6f4aa0ea50764f106405.tar.gz
PeerTube-d0800f7661f13fabe7bb6f4aa0ea50764f106405.tar.zst
PeerTube-d0800f7661f13fabe7bb6f4aa0ea50764f106405.zip
Implement avatar miniatures (#4639)
* client: remove unused file * refactor(client/my-actor-avatar): size from input Read size from component input instead of scss, to make it possible to use smaller avatar images when implemented. * implement avatar miniatures close #4560 * fix(test): max file size * fix(search-index): normalize res acc to avatarMini * refactor avatars to an array * client/search: resize channel avatar to 120 * refactor(client/videos): remove unused function * client(actor-avatar): set default size * fix tests and avatars full result When findOne is used only an array containting one avatar is returned. * update migration version and version notations * server/search: harmonize normalizing * Cleanup avatar miniature PR Co-authored-by: Chocobozzz <me@florianbigard.com>
Diffstat (limited to 'server')
-rw-r--r--server/controllers/activitypub/client.ts15
-rw-r--r--server/controllers/api/accounts.ts2
-rw-r--r--server/controllers/api/users/me.ts19
-rw-r--r--server/controllers/api/users/my-notifications.ts2
-rw-r--r--server/controllers/api/video-channel.ts20
-rw-r--r--server/controllers/client.ts4
-rw-r--r--server/controllers/lazy-static.ts10
-rw-r--r--server/helpers/activitypub.ts3
-rw-r--r--server/initializers/constants.ts30
-rw-r--r--server/initializers/migrations/0685-multiple-actor-images.ts62
-rw-r--r--server/lib/activitypub/actors/image.ts89
-rw-r--r--server/lib/activitypub/actors/shared/creator.ts16
-rw-r--r--server/lib/activitypub/actors/shared/object-to-model-attributes.ts56
-rw-r--r--server/lib/activitypub/actors/updater.ts12
-rw-r--r--server/lib/actor-image.ts14
-rw-r--r--server/lib/client-html.ts10
-rw-r--r--server/lib/local-actor.ts89
-rw-r--r--server/lib/notifier/shared/comment/comment-mention.ts2
-rw-r--r--server/lib/notifier/shared/comment/new-comment-for-video-owner.ts2
-rw-r--r--server/models/abuse/abuse-message.ts32
-rw-r--r--server/models/account/account-blocklist.ts83
-rw-r--r--server/models/account/account-video-rate.ts56
-rw-r--r--server/models/account/account.ts59
-rw-r--r--server/models/actor/actor-follow.ts263
-rw-r--r--server/models/actor/actor-image.ts67
-rw-r--r--server/models/actor/actor.ts129
-rw-r--r--server/models/server/plugin.ts9
-rw-r--r--server/models/server/server-blocklist.ts13
-rw-r--r--server/models/shared/index.ts1
-rw-r--r--server/models/shared/model-builder.ts101
-rw-r--r--server/models/user/sql/user-notitication-list-query-builder.ts269
-rw-r--r--server/models/user/user-notification.ts275
-rw-r--r--server/models/user/user.ts13
-rw-r--r--server/models/utils.ts2
-rw-r--r--server/models/video/sql/video/index.ts3
-rw-r--r--server/models/video/sql/video/shared/abstract-run-query.ts (renamed from server/models/video/sql/shared/abstract-run-query.ts)0
-rw-r--r--server/models/video/sql/video/shared/abstract-video-query-builder.ts (renamed from server/models/video/sql/shared/abstract-video-query-builder.ts)15
-rw-r--r--server/models/video/sql/video/shared/video-file-query-builder.ts (renamed from server/models/video/sql/shared/video-file-query-builder.ts)0
-rw-r--r--server/models/video/sql/video/shared/video-model-builder.ts (renamed from server/models/video/sql/shared/video-model-builder.ts)51
-rw-r--r--server/models/video/sql/video/shared/video-table-attributes.ts (renamed from server/models/video/sql/shared/video-table-attributes.ts)4
-rw-r--r--server/models/video/sql/video/video-model-get-query-builder.ts (renamed from server/models/video/sql/video-model-get-query-builder.ts)0
-rw-r--r--server/models/video/sql/video/videos-id-list-query-builder.ts (renamed from server/models/video/sql/videos-id-list-query-builder.ts)0
-rw-r--r--server/models/video/sql/video/videos-model-list-query-builder.ts (renamed from server/models/video/sql/videos-model-list-query-builder.ts)0
-rw-r--r--server/models/video/video-channel.ts209
-rw-r--r--server/models/video/video-comment.ts102
-rw-r--r--server/models/video/video-import.ts11
-rw-r--r--server/models/video/video-playlist-element.ts36
-rw-r--r--server/models/video/video-playlist.ts98
-rw-r--r--server/models/video/video-share.ts5
-rw-r--r--server/models/video/video.ts18
-rw-r--r--server/tests/api/check-params/video-channels.ts2
-rw-r--r--server/tests/api/moderation/abuses.ts7
-rw-r--r--server/tests/api/moderation/blocklist.ts4
-rw-r--r--server/tests/api/moderation/video-blacklist.ts2
-rw-r--r--server/tests/api/notifications/notifications-api.ts10
-rw-r--r--server/tests/api/search/search-activitypub-video-channels.ts4
-rw-r--r--server/tests/api/search/search-activitypub-video-playlists.ts2
-rw-r--r--server/tests/api/search/search-activitypub-videos.ts4
-rw-r--r--server/tests/api/search/search-channels.ts8
-rw-r--r--server/tests/api/search/search-index.ts14
-rw-r--r--server/tests/api/search/search-playlists.ts6
-rw-r--r--server/tests/api/search/search-videos.ts8
-rw-r--r--server/tests/api/server/homepage.ts6
-rw-r--r--server/tests/api/users/user-subscriptions.ts4
-rw-r--r--server/tests/api/users/users-multiple-servers.ts18
-rw-r--r--server/tests/api/users/users.ts8
-rw-r--r--server/tests/api/videos/multiple-servers.ts7
-rw-r--r--server/tests/api/videos/single-server.ts11
-rw-r--r--server/tests/api/videos/video-channels.ts32
-rw-r--r--server/tests/api/videos/video-comments.ts17
-rw-r--r--server/tests/api/videos/video-playlists.ts2
-rw-r--r--server/tests/api/videos/videos-common-filters.ts4
-rw-r--r--server/tests/cli/prune-storage.ts29
-rw-r--r--server/tests/feeds/feeds.ts4
-rw-r--r--server/tests/fixtures/avatar-resized-120x120.gif (renamed from server/tests/fixtures/avatar-resized.gif)bin88318 -> 88318 bytes
-rw-r--r--server/tests/fixtures/avatar-resized-120x120.png (renamed from server/tests/fixtures/avatar-resized.png)bin1727 -> 1727 bytes
-rw-r--r--server/tests/fixtures/avatar-resized-48x48.gifbin0 -> 20462 bytes
-rw-r--r--server/tests/fixtures/avatar-resized-48x48.pngbin0 -> 727 bytes
-rw-r--r--server/tests/fixtures/avatar2-resized-120x120.png (renamed from server/tests/fixtures/avatar2-resized.png)bin1725 -> 1725 bytes
-rw-r--r--server/tests/fixtures/avatar2-resized-48x48.pngbin0 -> 760 bytes
-rw-r--r--server/tests/shared/notifications.ts11
-rw-r--r--server/types/models/actor/actor-image.ts2
-rw-r--r--server/types/models/actor/actor.ts37
-rw-r--r--server/types/models/user/user-notification.ts9
84 files changed, 1664 insertions, 989 deletions
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index 4e6bd5e25..c4d1be121 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -18,10 +18,10 @@ import {
18} from '../../lib/activitypub/url' 18} from '../../lib/activitypub/url'
19import { 19import {
20 asyncMiddleware, 20 asyncMiddleware,
21 ensureIsLocalChannel,
21 executeIfActivityPub, 22 executeIfActivityPub,
22 localAccountValidator, 23 localAccountValidator,
23 videoChannelsNameWithHostValidator, 24 videoChannelsNameWithHostValidator,
24 ensureIsLocalChannel,
25 videosCustomGetValidator, 25 videosCustomGetValidator,
26 videosShareValidator 26 videosShareValidator
27} from '../../middlewares' 27} from '../../middlewares'
@@ -265,8 +265,8 @@ async function videoAnnouncesController (req: express.Request, res: express.Resp
265 const handler = async (start: number, count: number) => { 265 const handler = async (start: number, count: number) => {
266 const result = await VideoShareModel.listAndCountByVideoId(video.id, start, count) 266 const result = await VideoShareModel.listAndCountByVideoId(video.id, start, count)
267 return { 267 return {
268 total: result.count, 268 total: result.total,
269 data: result.rows.map(r => r.url) 269 data: result.data.map(r => r.url)
270 } 270 }
271 } 271 }
272 const json = await activityPubCollectionPagination(getLocalVideoSharesActivityPubUrl(video), handler, req.query.page) 272 const json = await activityPubCollectionPagination(getLocalVideoSharesActivityPubUrl(video), handler, req.query.page)
@@ -301,9 +301,10 @@ async function videoCommentsController (req: express.Request, res: express.Respo
301 301
302 const handler = async (start: number, count: number) => { 302 const handler = async (start: number, count: number) => {
303 const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count) 303 const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count)
304
304 return { 305 return {
305 total: result.count, 306 total: result.total,
306 data: result.rows.map(r => r.url) 307 data: result.data.map(r => r.url)
307 } 308 }
308 } 309 }
309 const json = await activityPubCollectionPagination(getLocalVideoCommentsActivityPubUrl(video), handler, req.query.page) 310 const json = await activityPubCollectionPagination(getLocalVideoCommentsActivityPubUrl(video), handler, req.query.page)
@@ -425,8 +426,8 @@ function videoRates (req: express.Request, rateType: VideoRateType, video: MVide
425 const handler = async (start: number, count: number) => { 426 const handler = async (start: number, count: number) => {
426 const result = await AccountVideoRateModel.listAndCountAccountUrlsByVideoId(rateType, video.id, start, count) 427 const result = await AccountVideoRateModel.listAndCountAccountUrlsByVideoId(rateType, video.id, start, count)
427 return { 428 return {
428 total: result.count, 429 total: result.total,
429 data: result.rows.map(r => r.url) 430 data: result.data.map(r => r.url)
430 } 431 }
431 } 432 }
432 return activityPubCollectionPagination(url, handler, req.query.page) 433 return activityPubCollectionPagination(url, handler, req.query.page)
diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts
index 46d89bafa..8d9f92d93 100644
--- a/server/controllers/api/accounts.ts
+++ b/server/controllers/api/accounts.ts
@@ -213,7 +213,7 @@ async function listAccountRatings (req: express.Request, res: express.Response)
213 sort: req.query.sort, 213 sort: req.query.sort,
214 type: req.query.rating 214 type: req.query.rating
215 }) 215 })
216 return res.json(getFormattedObjects(resultList.rows, resultList.count)) 216 return res.json(getFormattedObjects(resultList.data, resultList.total))
217} 217}
218 218
219async function listAccountFollowers (req: express.Request, res: express.Response) { 219async function listAccountFollowers (req: express.Request, res: express.Response) {
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
index c2ad0b710..a1d621152 100644
--- a/server/controllers/api/users/me.ts
+++ b/server/controllers/api/users/me.ts
@@ -1,7 +1,9 @@
1import 'multer' 1import 'multer'
2import express from 'express' 2import express from 'express'
3import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '@server/helpers/audit-logger' 3import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '@server/helpers/audit-logger'
4import { getBiggestActorImage } from '@server/lib/actor-image'
4import { Hooks } from '@server/lib/plugins/hooks' 5import { Hooks } from '@server/lib/plugins/hooks'
6import { pick } from '@shared/core-utils'
5import { ActorImageType, HttpStatusCode, UserUpdateMe, UserVideoQuota, UserVideoRate as FormattedUserVideoRate } from '@shared/models' 7import { ActorImageType, HttpStatusCode, UserUpdateMe, UserVideoQuota, UserVideoRate as FormattedUserVideoRate } from '@shared/models'
6import { AttributesOnly } from '@shared/typescript-utils' 8import { AttributesOnly } from '@shared/typescript-utils'
7import { createReqFiles } from '../../../helpers/express-utils' 9import { createReqFiles } from '../../../helpers/express-utils'
@@ -10,7 +12,7 @@ import { CONFIG } from '../../../initializers/config'
10import { MIMETYPES } from '../../../initializers/constants' 12import { MIMETYPES } from '../../../initializers/constants'
11import { sequelizeTypescript } from '../../../initializers/database' 13import { sequelizeTypescript } from '../../../initializers/database'
12import { sendUpdateActor } from '../../../lib/activitypub/send' 14import { sendUpdateActor } from '../../../lib/activitypub/send'
13import { deleteLocalActorImageFile, updateLocalActorImageFile } from '../../../lib/local-actor' 15import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../../lib/local-actor'
14import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user' 16import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user'
15import { 17import {
16 asyncMiddleware, 18 asyncMiddleware,
@@ -30,7 +32,6 @@ import { AccountVideoRateModel } from '../../../models/account/account-video-rat
30import { UserModel } from '../../../models/user/user' 32import { UserModel } from '../../../models/user/user'
31import { VideoModel } from '../../../models/video/video' 33import { VideoModel } from '../../../models/video/video'
32import { VideoImportModel } from '../../../models/video/video-import' 34import { VideoImportModel } from '../../../models/video/video-import'
33import { pick } from '@shared/core-utils'
34 35
35const auditLogger = auditLoggerFactory('users') 36const auditLogger = auditLoggerFactory('users')
36 37
@@ -253,9 +254,17 @@ async function updateMyAvatar (req: express.Request, res: express.Response) {
253 254
254 const userAccount = await AccountModel.load(user.Account.id) 255 const userAccount = await AccountModel.load(user.Account.id)
255 256
256 const avatar = await updateLocalActorImageFile(userAccount, avatarPhysicalFile, ActorImageType.AVATAR) 257 const avatars = await updateLocalActorImageFiles(
258 userAccount,
259 avatarPhysicalFile,
260 ActorImageType.AVATAR
261 )
257 262
258 return res.json({ avatar: avatar.toFormattedJSON() }) 263 return res.json({
264 // TODO: remove, deprecated in 4.2
265 avatar: getBiggestActorImage(avatars).toFormattedJSON(),
266 avatars: avatars.map(avatar => avatar.toFormattedJSON())
267 })
259} 268}
260 269
261async function deleteMyAvatar (req: express.Request, res: express.Response) { 270async function deleteMyAvatar (req: express.Request, res: express.Response) {
@@ -264,5 +273,5 @@ async function deleteMyAvatar (req: express.Request, res: express.Response) {
264 const userAccount = await AccountModel.load(user.Account.id) 273 const userAccount = await AccountModel.load(user.Account.id)
265 await deleteLocalActorImageFile(userAccount, ActorImageType.AVATAR) 274 await deleteLocalActorImageFile(userAccount, ActorImageType.AVATAR)
266 275
267 return res.status(HttpStatusCode.NO_CONTENT_204).end() 276 return res.json({ avatars: [] })
268} 277}
diff --git a/server/controllers/api/users/my-notifications.ts b/server/controllers/api/users/my-notifications.ts
index d107a306e..58732158f 100644
--- a/server/controllers/api/users/my-notifications.ts
+++ b/server/controllers/api/users/my-notifications.ts
@@ -3,7 +3,6 @@ import express from 'express'
3import { UserNotificationModel } from '@server/models/user/user-notification' 3import { UserNotificationModel } from '@server/models/user/user-notification'
4import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' 4import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
5import { UserNotificationSetting } from '../../../../shared/models/users' 5import { UserNotificationSetting } from '../../../../shared/models/users'
6import { getFormattedObjects } from '../../../helpers/utils'
7import { 6import {
8 asyncMiddleware, 7 asyncMiddleware,
9 asyncRetryTransactionMiddleware, 8 asyncRetryTransactionMiddleware,
@@ -20,6 +19,7 @@ import {
20} from '../../../middlewares/validators/user-notifications' 19} from '../../../middlewares/validators/user-notifications'
21import { UserNotificationSettingModel } from '../../../models/user/user-notification-setting' 20import { UserNotificationSettingModel } from '../../../models/user/user-notification-setting'
22import { meRouter } from './me' 21import { meRouter } from './me'
22import { getFormattedObjects } from '@server/helpers/utils'
23 23
24const myNotificationsRouter = express.Router() 24const myNotificationsRouter = express.Router()
25 25
diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts
index e65550a22..2f869d9b3 100644
--- a/server/controllers/api/video-channel.ts
+++ b/server/controllers/api/video-channel.ts
@@ -1,5 +1,6 @@
1import express from 'express' 1import express from 'express'
2import { pickCommonVideoQuery } from '@server/helpers/query' 2import { pickCommonVideoQuery } from '@server/helpers/query'
3import { getBiggestActorImage } from '@server/lib/actor-image'
3import { Hooks } from '@server/lib/plugins/hooks' 4import { Hooks } from '@server/lib/plugins/hooks'
4import { ActorFollowModel } from '@server/models/actor/actor-follow' 5import { ActorFollowModel } from '@server/models/actor/actor-follow'
5import { getServerActor } from '@server/models/application/application' 6import { getServerActor } from '@server/models/application/application'
@@ -16,7 +17,7 @@ import { MIMETYPES } from '../../initializers/constants'
16import { sequelizeTypescript } from '../../initializers/database' 17import { sequelizeTypescript } from '../../initializers/database'
17import { sendUpdateActor } from '../../lib/activitypub/send' 18import { sendUpdateActor } from '../../lib/activitypub/send'
18import { JobQueue } from '../../lib/job-queue' 19import { JobQueue } from '../../lib/job-queue'
19import { deleteLocalActorImageFile, updateLocalActorImageFile } from '../../lib/local-actor' 20import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../lib/local-actor'
20import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel' 21import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel'
21import { 22import {
22 asyncMiddleware, 23 asyncMiddleware,
@@ -186,11 +187,15 @@ async function updateVideoChannelBanner (req: express.Request, res: express.Resp
186 const videoChannel = res.locals.videoChannel 187 const videoChannel = res.locals.videoChannel
187 const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON()) 188 const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
188 189
189 const banner = await updateLocalActorImageFile(videoChannel, bannerPhysicalFile, ActorImageType.BANNER) 190 const banners = await updateLocalActorImageFiles(videoChannel, bannerPhysicalFile, ActorImageType.BANNER)
190 191
191 auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys) 192 auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
192 193
193 return res.json({ banner: banner.toFormattedJSON() }) 194 return res.json({
195 // TODO: remove, deprecated in 4.2
196 banner: getBiggestActorImage(banners).toFormattedJSON(),
197 banners: banners.map(b => b.toFormattedJSON())
198 })
194} 199}
195 200
196async function updateVideoChannelAvatar (req: express.Request, res: express.Response) { 201async function updateVideoChannelAvatar (req: express.Request, res: express.Response) {
@@ -198,11 +203,14 @@ async function updateVideoChannelAvatar (req: express.Request, res: express.Resp
198 const videoChannel = res.locals.videoChannel 203 const videoChannel = res.locals.videoChannel
199 const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON()) 204 const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
200 205
201 const avatar = await updateLocalActorImageFile(videoChannel, avatarPhysicalFile, ActorImageType.AVATAR) 206 const avatars = await updateLocalActorImageFiles(videoChannel, avatarPhysicalFile, ActorImageType.AVATAR)
202
203 auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys) 207 auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
204 208
205 return res.json({ avatar: avatar.toFormattedJSON() }) 209 return res.json({
210 // TODO: remove, deprecated in 4.2
211 avatar: getBiggestActorImage(avatars).toFormattedJSON(),
212 avatars: avatars.map(a => a.toFormattedJSON())
213 })
206} 214}
207 215
208async function deleteVideoChannelAvatar (req: express.Request, res: express.Response) { 216async function deleteVideoChannelAvatar (req: express.Request, res: express.Response) {
diff --git a/server/controllers/client.ts b/server/controllers/client.ts
index 8a56f2f75..f9514d988 100644
--- a/server/controllers/client.ts
+++ b/server/controllers/client.ts
@@ -68,7 +68,9 @@ const staticClientOverrides = [
68 'assets/images/icons/icon-512x512.png', 68 'assets/images/icons/icon-512x512.png',
69 'assets/images/default-playlist.jpg', 69 'assets/images/default-playlist.jpg',
70 'assets/images/default-avatar-account.png', 70 'assets/images/default-avatar-account.png',
71 'assets/images/default-avatar-video-channel.png' 71 'assets/images/default-avatar-account-48x48.png',
72 'assets/images/default-avatar-video-channel.png',
73 'assets/images/default-avatar-video-channel-48x48.png'
72] 74]
73 75
74for (const staticClientOverride of staticClientOverrides) { 76for (const staticClientOverride of staticClientOverrides) {
diff --git a/server/controllers/lazy-static.ts b/server/controllers/lazy-static.ts
index a4076ee56..55bf02660 100644
--- a/server/controllers/lazy-static.ts
+++ b/server/controllers/lazy-static.ts
@@ -64,7 +64,15 @@ async function getActorImage (req: express.Request, res: express.Response, next:
64 logger.info('Lazy serve remote actor image %s.', image.fileUrl) 64 logger.info('Lazy serve remote actor image %s.', image.fileUrl)
65 65
66 try { 66 try {
67 await pushActorImageProcessInQueue({ filename: image.filename, fileUrl: image.fileUrl, type: image.type }) 67 await pushActorImageProcessInQueue({
68 filename: image.filename,
69 fileUrl: image.fileUrl,
70 size: {
71 height: image.height,
72 width: image.width
73 },
74 type: image.type
75 })
68 } catch (err) { 76 } catch (err) {
69 logger.warn('Cannot process remote actor image %s.', image.fileUrl, { err }) 77 logger.warn('Cannot process remote actor image %s.', image.fileUrl, { err })
70 return res.status(HttpStatusCode.NOT_FOUND_404).end() 78 return res.status(HttpStatusCode.NOT_FOUND_404).end()
diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts
index fe721cbac..cbba2f51c 100644
--- a/server/helpers/activitypub.ts
+++ b/server/helpers/activitypub.ts
@@ -38,6 +38,9 @@ function getContextData (type: ContextType) {
38 sensitive: 'as:sensitive', 38 sensitive: 'as:sensitive',
39 language: 'sc:inLanguage', 39 language: 'sc:inLanguage',
40 40
41 // TODO: remove in a few versions, introduced in 4.2
42 icons: 'as:icon',
43
41 isLiveBroadcast: 'sc:isLiveBroadcast', 44 isLiveBroadcast: 'sc:isLiveBroadcast',
42 liveSaveReplay: { 45 liveSaveReplay: {
43 '@type': 'sc:Boolean', 46 '@type': 'sc:Boolean',
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 1c47d43f0..9b972b87e 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -14,7 +14,7 @@ import {
14 VideoTranscodingFPS 14 VideoTranscodingFPS
15} from '../../shared/models' 15} from '../../shared/models'
16import { ActivityPubActorType } from '../../shared/models/activitypub' 16import { ActivityPubActorType } from '../../shared/models/activitypub'
17import { FollowState } from '../../shared/models/actors' 17import { ActorImageType, FollowState } from '../../shared/models/actors'
18import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' 18import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
19import { VideoPlaylistPrivacy } from '../../shared/models/videos/playlist/video-playlist-privacy.model' 19import { VideoPlaylistPrivacy } from '../../shared/models/videos/playlist/video-playlist-privacy.model'
20import { VideoPlaylistType } from '../../shared/models/videos/playlist/video-playlist-type.model' 20import { VideoPlaylistType } from '../../shared/models/videos/playlist/video-playlist-type.model'
@@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
24 24
25// --------------------------------------------------------------------------- 25// ---------------------------------------------------------------------------
26 26
27const LAST_MIGRATION_VERSION = 680 27const LAST_MIGRATION_VERSION = 685
28 28
29// --------------------------------------------------------------------------- 29// ---------------------------------------------------------------------------
30 30
@@ -633,15 +633,23 @@ const PREVIEWS_SIZE = {
633 height: 480, 633 height: 480,
634 minWidth: 400 634 minWidth: 400
635} 635}
636const ACTOR_IMAGES_SIZE = { 636const ACTOR_IMAGES_SIZE: { [key in ActorImageType]: { width: number, height: number }[]} = {
637 AVATARS: { 637 [ActorImageType.AVATAR]: [
638 width: 120, 638 {
639 height: 120 639 width: 120,
640 }, 640 height: 120
641 BANNERS: { 641 },
642 width: 1920, 642 {
643 height: 317 // 6/1 ratio 643 width: 48,
644 } 644 height: 48
645 }
646 ],
647 [ActorImageType.BANNER]: [
648 {
649 width: 1920,
650 height: 317 // 6/1 ratio
651 }
652 ]
645} 653}
646 654
647const EMBED_SIZE = { 655const EMBED_SIZE = {
diff --git a/server/initializers/migrations/0685-multiple-actor-images.ts b/server/initializers/migrations/0685-multiple-actor-images.ts
new file mode 100644
index 000000000..c656f7e28
--- /dev/null
+++ b/server/initializers/migrations/0685-multiple-actor-images.ts
@@ -0,0 +1,62 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7 db: any
8}): Promise<void> {
9 {
10 await utils.queryInterface.addColumn('actorImage', 'actorId', {
11 type: Sequelize.INTEGER,
12 defaultValue: null,
13 allowNull: true,
14 references: {
15 model: 'actor',
16 key: 'id'
17 },
18 onDelete: 'CASCADE'
19 }, { transaction: utils.transaction })
20
21 // Avatars
22 {
23 const query = `UPDATE "actorImage" SET "actorId" = (SELECT "id" FROM "actor" WHERE "actor"."avatarId" = "actorImage"."id") ` +
24 `WHERE "type" = 1`
25 await utils.sequelize.query(query, { type: Sequelize.QueryTypes.UPDATE, transaction: utils.transaction })
26 }
27
28 // Banners
29 {
30 const query = `UPDATE "actorImage" SET "actorId" = (SELECT "id" FROM "actor" WHERE "actor"."bannerId" = "actorImage"."id") ` +
31 `WHERE "type" = 2`
32 await utils.sequelize.query(query, { type: Sequelize.QueryTypes.UPDATE, transaction: utils.transaction })
33 }
34
35 // Remove orphans
36 {
37 const query = `DELETE FROM "actorImage" WHERE id NOT IN (` +
38 `SELECT "bannerId" FROM actor WHERE "bannerId" IS NOT NULL ` +
39 `UNION select "avatarId" FROM actor WHERE "avatarId" IS NOT NULL` +
40 `);`
41
42 await utils.sequelize.query(query, { type: Sequelize.QueryTypes.DELETE, transaction: utils.transaction })
43 }
44
45 await utils.queryInterface.changeColumn('actorImage', 'actorId', {
46 type: Sequelize.INTEGER,
47 allowNull: false
48 }, { transaction: utils.transaction })
49
50 await utils.queryInterface.removeColumn('actor', 'avatarId', { transaction: utils.transaction })
51 await utils.queryInterface.removeColumn('actor', 'bannerId', { transaction: utils.transaction })
52 }
53}
54
55function down () {
56 throw new Error('Not implemented.')
57}
58
59export {
60 up,
61 down
62}
diff --git a/server/lib/activitypub/actors/image.ts b/server/lib/activitypub/actors/image.ts
index 443ad0a63..d17c2ef1a 100644
--- a/server/lib/activitypub/actors/image.ts
+++ b/server/lib/activitypub/actors/image.ts
@@ -12,53 +12,52 @@ type ImageInfo = {
12 onDisk?: boolean 12 onDisk?: boolean
13} 13}
14 14
15async function updateActorImageInstance (actor: MActorImages, type: ActorImageType, imageInfo: ImageInfo | null, t: Transaction) { 15async function updateActorImages (actor: MActorImages, type: ActorImageType, imagesInfo: ImageInfo[], t: Transaction) {
16 const oldImageModel = type === ActorImageType.AVATAR 16 const avatarsOrBanners = type === ActorImageType.AVATAR
17 ? actor.Avatar 17 ? actor.Avatars
18 : actor.Banner 18 : actor.Banners
19 19
20 if (oldImageModel) { 20 if (imagesInfo.length === 0) {
21 // Don't update the avatar if the file URL did not change 21 await deleteActorImages(actor, type, t)
22 if (imageInfo?.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) return actor 22 }
23
24 for (const imageInfo of imagesInfo) {
25 const oldImageModel = (avatarsOrBanners || []).find(i => i.width === imageInfo.width)
23 26
24 try { 27 if (oldImageModel) {
25 await oldImageModel.destroy({ transaction: t }) 28 // Don't update the avatar if the file URL did not change
29 if (imageInfo?.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) {
30 continue
31 }
26 32
27 setActorImage(actor, type, null) 33 await safeDeleteActorImage(actor, oldImageModel, type, t)
28 } catch (err) {
29 logger.error('Cannot remove old actor image of actor %s.', actor.url, { err })
30 } 34 }
31 }
32 35
33 if (imageInfo) {
34 const imageModel = await ActorImageModel.create({ 36 const imageModel = await ActorImageModel.create({
35 filename: imageInfo.name, 37 filename: imageInfo.name,
36 onDisk: imageInfo.onDisk ?? false, 38 onDisk: imageInfo.onDisk ?? false,
37 fileUrl: imageInfo.fileUrl, 39 fileUrl: imageInfo.fileUrl,
38 height: imageInfo.height, 40 height: imageInfo.height,
39 width: imageInfo.width, 41 width: imageInfo.width,
40 type 42 type,
43 actorId: actor.id
41 }, { transaction: t }) 44 }, { transaction: t })
42 45
43 setActorImage(actor, type, imageModel) 46 addActorImage(actor, type, imageModel)
44 } 47 }
45 48
46 return actor 49 return actor
47} 50}
48 51
49async function deleteActorImageInstance (actor: MActorImages, type: ActorImageType, t: Transaction) { 52async function deleteActorImages (actor: MActorImages, type: ActorImageType, t: Transaction) {
50 try { 53 try {
51 if (type === ActorImageType.AVATAR) { 54 const association = buildAssociationName(type)
52 await actor.Avatar.destroy({ transaction: t })
53
54 actor.avatarId = null
55 actor.Avatar = null
56 } else {
57 await actor.Banner.destroy({ transaction: t })
58 55
59 actor.bannerId = null 56 for (const image of actor[association]) {
60 actor.Banner = null 57 await image.destroy({ transaction: t })
61 } 58 }
59
60 actor[association] = []
62 } catch (err) { 61 } catch (err) {
63 logger.error('Cannot remove old image of actor %s.', actor.url, { err }) 62 logger.error('Cannot remove old image of actor %s.', actor.url, { err })
64 } 63 }
@@ -66,29 +65,37 @@ async function deleteActorImageInstance (actor: MActorImages, type: ActorImageTy
66 return actor 65 return actor
67} 66}
68 67
68async function safeDeleteActorImage (actor: MActorImages, toDelete: MActorImage, type: ActorImageType, t: Transaction) {
69 try {
70 await toDelete.destroy({ transaction: t })
71
72 const association = buildAssociationName(type)
73 actor[association] = actor[association].filter(image => image.id !== toDelete.id)
74 } catch (err) {
75 logger.error('Cannot remove old actor image of actor %s.', actor.url, { err })
76 }
77}
78
69// --------------------------------------------------------------------------- 79// ---------------------------------------------------------------------------
70 80
71export { 81export {
72 ImageInfo, 82 ImageInfo,
73 83
74 updateActorImageInstance, 84 updateActorImages,
75 deleteActorImageInstance 85 deleteActorImages
76} 86}
77 87
78// --------------------------------------------------------------------------- 88// ---------------------------------------------------------------------------
79 89
80function setActorImage (actorModel: MActorImages, type: ActorImageType, imageModel: MActorImage) { 90function addActorImage (actor: MActorImages, type: ActorImageType, imageModel: MActorImage) {
81 const id = imageModel 91 const association = buildAssociationName(type)
82 ? imageModel.id 92 if (!actor[association]) actor[association] = []
83 : null 93
84 94 actor[association].push(imageModel)
85 if (type === ActorImageType.AVATAR) { 95}
86 actorModel.avatarId = id
87 actorModel.Avatar = imageModel
88 } else {
89 actorModel.bannerId = id
90 actorModel.Banner = imageModel
91 }
92 96
93 return actorModel 97function buildAssociationName (type: ActorImageType) {
98 return type === ActorImageType.AVATAR
99 ? 'Avatars'
100 : 'Banners'
94} 101}
diff --git a/server/lib/activitypub/actors/shared/creator.ts b/server/lib/activitypub/actors/shared/creator.ts
index 999aed97d..500bc9912 100644
--- a/server/lib/activitypub/actors/shared/creator.ts
+++ b/server/lib/activitypub/actors/shared/creator.ts
@@ -6,8 +6,8 @@ import { ServerModel } from '@server/models/server/server'
6import { VideoChannelModel } from '@server/models/video/video-channel' 6import { VideoChannelModel } from '@server/models/video/video-channel'
7import { MAccount, MAccountDefault, MActor, MActorFullActor, MActorId, MActorImages, MChannel, MServer } from '@server/types/models' 7import { MAccount, MAccountDefault, MActor, MActorFullActor, MActorId, MActorImages, MChannel, MServer } from '@server/types/models'
8import { ActivityPubActor, ActorImageType } from '@shared/models' 8import { ActivityPubActor, ActorImageType } from '@shared/models'
9import { updateActorImageInstance } from '../image' 9import { updateActorImages } from '../image'
10import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImageInfoFromObject } from './object-to-model-attributes' 10import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes'
11import { fetchActorFollowsCount } from './url-to-object' 11import { fetchActorFollowsCount } from './url-to-object'
12 12
13export class APActorCreator { 13export class APActorCreator {
@@ -27,11 +27,11 @@ export class APActorCreator {
27 return sequelizeTypescript.transaction(async t => { 27 return sequelizeTypescript.transaction(async t => {
28 const server = await this.setServer(actorInstance, t) 28 const server = await this.setServer(actorInstance, t)
29 29
30 await this.setImageIfNeeded(actorInstance, ActorImageType.AVATAR, t)
31 await this.setImageIfNeeded(actorInstance, ActorImageType.BANNER, t)
32
33 const { actorCreated, created } = await this.saveActor(actorInstance, t) 30 const { actorCreated, created } = await this.saveActor(actorInstance, t)
34 31
32 await this.setImageIfNeeded(actorCreated, ActorImageType.AVATAR, t)
33 await this.setImageIfNeeded(actorCreated, ActorImageType.BANNER, t)
34
35 await this.tryToFixActorUrlIfNeeded(actorCreated, actorInstance, created, t) 35 await this.tryToFixActorUrlIfNeeded(actorCreated, actorInstance, created, t)
36 36
37 if (actorCreated.type === 'Person' || actorCreated.type === 'Application') { // Account or PeerTube instance 37 if (actorCreated.type === 'Person' || actorCreated.type === 'Application') { // Account or PeerTube instance
@@ -71,10 +71,10 @@ export class APActorCreator {
71 } 71 }
72 72
73 private async setImageIfNeeded (actor: MActor, type: ActorImageType, t: Transaction) { 73 private async setImageIfNeeded (actor: MActor, type: ActorImageType, t: Transaction) {
74 const imageInfo = getImageInfoFromObject(this.actorObject, type) 74 const imagesInfo = getImagesInfoFromObject(this.actorObject, type)
75 if (!imageInfo) return 75 if (imagesInfo.length === 0) return
76 76
77 return updateActorImageInstance(actor as MActorImages, type, imageInfo, t) 77 return updateActorImages(actor as MActorImages, type, imagesInfo, t)
78 } 78 }
79 79
80 private async saveActor (actor: MActor, t: Transaction) { 80 private async saveActor (actor: MActor, t: Transaction) {
diff --git a/server/lib/activitypub/actors/shared/object-to-model-attributes.ts b/server/lib/activitypub/actors/shared/object-to-model-attributes.ts
index 23bc972e5..f6a78c457 100644
--- a/server/lib/activitypub/actors/shared/object-to-model-attributes.ts
+++ b/server/lib/activitypub/actors/shared/object-to-model-attributes.ts
@@ -4,7 +4,7 @@ import { ActorModel } from '@server/models/actor/actor'
4import { FilteredModelAttributes } from '@server/types' 4import { FilteredModelAttributes } from '@server/types'
5import { getLowercaseExtension } from '@shared/core-utils' 5import { getLowercaseExtension } from '@shared/core-utils'
6import { buildUUID } from '@shared/extra-utils' 6import { buildUUID } from '@shared/extra-utils'
7import { ActivityPubActor, ActorImageType } from '@shared/models' 7import { ActivityIconObject, ActivityPubActor, ActorImageType } from '@shared/models'
8 8
9function getActorAttributesFromObject ( 9function getActorAttributesFromObject (
10 actorObject: ActivityPubActor, 10 actorObject: ActivityPubActor,
@@ -30,33 +30,36 @@ function getActorAttributesFromObject (
30 } 30 }
31} 31}
32 32
33function getImageInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType) { 33function getImagesInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType) {
34 const mimetypes = MIMETYPES.IMAGE 34 const iconsOrImages = type === ActorImageType.AVATAR
35 const icon = type === ActorImageType.AVATAR 35 ? actorObject.icons || actorObject.icon
36 ? actorObject.icon
37 : actorObject.image 36 : actorObject.image
38 37
39 if (!icon || icon.type !== 'Image' || !isActivityPubUrlValid(icon.url)) return undefined 38 return normalizeIconOrImage(iconsOrImages).map(iconOrImage => {
39 const mimetypes = MIMETYPES.IMAGE
40 40
41 let extension: string 41 if (iconOrImage.type !== 'Image' || !isActivityPubUrlValid(iconOrImage.url)) return undefined
42 42
43 if (icon.mediaType) { 43 let extension: string
44 extension = mimetypes.MIMETYPE_EXT[icon.mediaType]
45 } else {
46 const tmp = getLowercaseExtension(icon.url)
47 44
48 if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp 45 if (iconOrImage.mediaType) {
49 } 46 extension = mimetypes.MIMETYPE_EXT[iconOrImage.mediaType]
47 } else {
48 const tmp = getLowercaseExtension(iconOrImage.url)
50 49
51 if (!extension) return undefined 50 if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp
51 }
52 52
53 return { 53 if (!extension) return undefined
54 name: buildUUID() + extension, 54
55 fileUrl: icon.url, 55 return {
56 height: icon.height, 56 name: buildUUID() + extension,
57 width: icon.width, 57 fileUrl: iconOrImage.url,
58 type 58 height: iconOrImage.height,
59 } 59 width: iconOrImage.width,
60 type
61 }
62 })
60} 63}
61 64
62function getActorDisplayNameFromObject (actorObject: ActivityPubActor) { 65function getActorDisplayNameFromObject (actorObject: ActivityPubActor) {
@@ -65,6 +68,15 @@ function getActorDisplayNameFromObject (actorObject: ActivityPubActor) {
65 68
66export { 69export {
67 getActorAttributesFromObject, 70 getActorAttributesFromObject,
68 getImageInfoFromObject, 71 getImagesInfoFromObject,
69 getActorDisplayNameFromObject 72 getActorDisplayNameFromObject
70} 73}
74
75// ---------------------------------------------------------------------------
76
77function normalizeIconOrImage (icon: ActivityIconObject | ActivityIconObject[]): ActivityIconObject[] {
78 if (Array.isArray(icon)) return icon
79 if (icon) return [ icon ]
80
81 return []
82}
diff --git a/server/lib/activitypub/actors/updater.ts b/server/lib/activitypub/actors/updater.ts
index 042438d9c..fe94af9f1 100644
--- a/server/lib/activitypub/actors/updater.ts
+++ b/server/lib/activitypub/actors/updater.ts
@@ -5,9 +5,9 @@ import { VideoChannelModel } from '@server/models/video/video-channel'
5import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models' 5import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models'
6import { ActivityPubActor, ActorImageType } from '@shared/models' 6import { ActivityPubActor, ActorImageType } from '@shared/models'
7import { getOrCreateAPOwner } from './get' 7import { getOrCreateAPOwner } from './get'
8import { updateActorImageInstance } from './image' 8import { updateActorImages } from './image'
9import { fetchActorFollowsCount } from './shared' 9import { fetchActorFollowsCount } from './shared'
10import { getImageInfoFromObject } from './shared/object-to-model-attributes' 10import { getImagesInfoFromObject } from './shared/object-to-model-attributes'
11 11
12export class APActorUpdater { 12export class APActorUpdater {
13 13
@@ -29,8 +29,8 @@ export class APActorUpdater {
29 } 29 }
30 30
31 async update () { 31 async update () {
32 const avatarInfo = getImageInfoFromObject(this.actorObject, ActorImageType.AVATAR) 32 const avatarsInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.AVATAR)
33 const bannerInfo = getImageInfoFromObject(this.actorObject, ActorImageType.BANNER) 33 const bannersInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.BANNER)
34 34
35 try { 35 try {
36 await this.updateActorInstance(this.actor, this.actorObject) 36 await this.updateActorInstance(this.actor, this.actorObject)
@@ -47,8 +47,8 @@ export class APActorUpdater {
47 } 47 }
48 48
49 await runInReadCommittedTransaction(async t => { 49 await runInReadCommittedTransaction(async t => {
50 await updateActorImageInstance(this.actor, ActorImageType.AVATAR, avatarInfo, t) 50 await updateActorImages(this.actor, ActorImageType.BANNER, bannersInfo, t)
51 await updateActorImageInstance(this.actor, ActorImageType.BANNER, bannerInfo, t) 51 await updateActorImages(this.actor, ActorImageType.AVATAR, avatarsInfo, t)
52 }) 52 })
53 53
54 await runInReadCommittedTransaction(async t => { 54 await runInReadCommittedTransaction(async t => {
diff --git a/server/lib/actor-image.ts b/server/lib/actor-image.ts
new file mode 100644
index 000000000..e9bd148f6
--- /dev/null
+++ b/server/lib/actor-image.ts
@@ -0,0 +1,14 @@
1import maxBy from 'lodash/maxBy'
2
3function getBiggestActorImage <T extends { width: number }> (images: T[]) {
4 const image = maxBy(images, 'width')
5
6 // If width is null, maxBy won't return a value
7 if (!image) return images[0]
8
9 return image
10}
11
12export {
13 getBiggestActorImage
14}
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 19354ab70..c010f3c44 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -3,6 +3,7 @@ import { readFile } from 'fs-extra'
3import { join } from 'path' 3import { join } from 'path'
4import validator from 'validator' 4import validator from 'validator'
5import { toCompleteUUID } from '@server/helpers/custom-validators/misc' 5import { toCompleteUUID } from '@server/helpers/custom-validators/misc'
6import { ActorImageModel } from '@server/models/actor/actor-image'
6import { root } from '@shared/core-utils' 7import { root } from '@shared/core-utils'
7import { escapeHTML } from '@shared/core-utils/renderer' 8import { escapeHTML } from '@shared/core-utils/renderer'
8import { sha256 } from '@shared/extra-utils' 9import { sha256 } from '@shared/extra-utils'
@@ -16,7 +17,6 @@ import { mdToOneLinePlainText } from '../helpers/markdown'
16import { CONFIG } from '../initializers/config' 17import { CONFIG } from '../initializers/config'
17import { 18import {
18 ACCEPT_HEADERS, 19 ACCEPT_HEADERS,
19 ACTOR_IMAGES_SIZE,
20 CUSTOM_HTML_TAG_COMMENTS, 20 CUSTOM_HTML_TAG_COMMENTS,
21 EMBED_SIZE, 21 EMBED_SIZE,
22 FILES_CONTENT_HASH, 22 FILES_CONTENT_HASH,
@@ -29,6 +29,7 @@ import { VideoModel } from '../models/video/video'
29import { VideoChannelModel } from '../models/video/video-channel' 29import { VideoChannelModel } from '../models/video/video-channel'
30import { VideoPlaylistModel } from '../models/video/video-playlist' 30import { VideoPlaylistModel } from '../models/video/video-playlist'
31import { MAccountActor, MChannelActor } from '../types/models' 31import { MAccountActor, MChannelActor } from '../types/models'
32import { getBiggestActorImage } from './actor-image'
32import { ServerConfigManager } from './server-config-manager' 33import { ServerConfigManager } from './server-config-manager'
33 34
34type Tags = { 35type Tags = {
@@ -273,10 +274,11 @@ class ClientHtml {
273 const siteName = CONFIG.INSTANCE.NAME 274 const siteName = CONFIG.INSTANCE.NAME
274 const title = entity.getDisplayName() 275 const title = entity.getDisplayName()
275 276
277 const avatar = getBiggestActorImage(entity.Actor.Avatars)
276 const image = { 278 const image = {
277 url: entity.Actor.getAvatarUrl(), 279 url: ActorImageModel.getImageUrl(avatar),
278 width: ACTOR_IMAGES_SIZE.AVATARS.width, 280 width: avatar?.width,
279 height: ACTOR_IMAGES_SIZE.AVATARS.height 281 height: avatar?.height
280 } 282 }
281 283
282 const ogType = 'website' 284 const ogType = 'website'
diff --git a/server/lib/local-actor.ts b/server/lib/local-actor.ts
index c6826759b..01046d017 100644
--- a/server/lib/local-actor.ts
+++ b/server/lib/local-actor.ts
@@ -1,5 +1,5 @@
1import 'multer'
2import { queue } from 'async' 1import { queue } from 'async'
2import { remove } from 'fs-extra'
3import LRUCache from 'lru-cache' 3import LRUCache from 'lru-cache'
4import { join } from 'path' 4import { join } from 'path'
5import { ActorModel } from '@server/models/actor/actor' 5import { ActorModel } from '@server/models/actor/actor'
@@ -13,7 +13,7 @@ import { CONFIG } from '../initializers/config'
13import { ACTOR_IMAGES_SIZE, LRU_CACHE, QUEUE_CONCURRENCY, WEBSERVER } from '../initializers/constants' 13import { ACTOR_IMAGES_SIZE, LRU_CACHE, QUEUE_CONCURRENCY, WEBSERVER } from '../initializers/constants'
14import { sequelizeTypescript } from '../initializers/database' 14import { sequelizeTypescript } from '../initializers/database'
15import { MAccountDefault, MActor, MChannelDefault } from '../types/models' 15import { MAccountDefault, MActor, MChannelDefault } from '../types/models'
16import { deleteActorImageInstance, updateActorImageInstance } from './activitypub/actors' 16import { deleteActorImages, updateActorImages } from './activitypub/actors'
17import { sendUpdateActor } from './activitypub/send' 17import { sendUpdateActor } from './activitypub/send'
18 18
19function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string) { 19function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string) {
@@ -33,64 +33,69 @@ function buildActorInstance (type: ActivityPubActorType, url: string, preferredU
33 }) as MActor 33 }) as MActor
34} 34}
35 35
36async function updateLocalActorImageFile ( 36async function updateLocalActorImageFiles (
37 accountOrChannel: MAccountDefault | MChannelDefault, 37 accountOrChannel: MAccountDefault | MChannelDefault,
38 imagePhysicalFile: Express.Multer.File, 38 imagePhysicalFile: Express.Multer.File,
39 type: ActorImageType 39 type: ActorImageType
40) { 40) {
41 const imageSize = type === ActorImageType.AVATAR 41 const processImageSize = async (imageSize: { width: number, height: number }) => {
42 ? ACTOR_IMAGES_SIZE.AVATARS 42 const extension = getLowercaseExtension(imagePhysicalFile.filename)
43 : ACTOR_IMAGES_SIZE.BANNERS 43
44 44 const imageName = buildUUID() + extension
45 const extension = getLowercaseExtension(imagePhysicalFile.filename) 45 const destination = join(CONFIG.STORAGE.ACTOR_IMAGES, imageName)
46 46 await processImage(imagePhysicalFile.path, destination, imageSize, true)
47 const imageName = buildUUID() + extension 47
48 const destination = join(CONFIG.STORAGE.ACTOR_IMAGES, imageName) 48 return {
49 await processImage(imagePhysicalFile.path, destination, imageSize) 49 imageName,
50 50 imageSize
51 return retryTransactionWrapper(() => { 51 }
52 return sequelizeTypescript.transaction(async t => { 52 }
53 const actorImageInfo = { 53
54 name: imageName, 54 const processedImages = await Promise.all(ACTOR_IMAGES_SIZE[type].map(processImageSize))
55 fileUrl: null, 55 await remove(imagePhysicalFile.path)
56 height: imageSize.height, 56
57 width: imageSize.width, 57 return retryTransactionWrapper(() => sequelizeTypescript.transaction(async t => {
58 onDisk: true 58 const actorImagesInfo = processedImages.map(({ imageName, imageSize }) => ({
59 } 59 name: imageName,
60 60 fileUrl: null,
61 const updatedActor = await updateActorImageInstance(accountOrChannel.Actor, type, actorImageInfo, t) 61 height: imageSize.height,
62 await updatedActor.save({ transaction: t }) 62 width: imageSize.width,
63 63 onDisk: true
64 await sendUpdateActor(accountOrChannel, t) 64 }))
65 65
66 return type === ActorImageType.AVATAR 66 const updatedActor = await updateActorImages(accountOrChannel.Actor, type, actorImagesInfo, t)
67 ? updatedActor.Avatar 67 await updatedActor.save({ transaction: t })
68 : updatedActor.Banner 68
69 }) 69 await sendUpdateActor(accountOrChannel, t)
70 }) 70
71 return type === ActorImageType.AVATAR
72 ? updatedActor.Avatars
73 : updatedActor.Banners
74 }))
71} 75}
72 76
73async function deleteLocalActorImageFile (accountOrChannel: MAccountDefault | MChannelDefault, type: ActorImageType) { 77async function deleteLocalActorImageFile (accountOrChannel: MAccountDefault | MChannelDefault, type: ActorImageType) {
74 return retryTransactionWrapper(() => { 78 return retryTransactionWrapper(() => {
75 return sequelizeTypescript.transaction(async t => { 79 return sequelizeTypescript.transaction(async t => {
76 const updatedActor = await deleteActorImageInstance(accountOrChannel.Actor, type, t) 80 const updatedActor = await deleteActorImages(accountOrChannel.Actor, type, t)
77 await updatedActor.save({ transaction: t }) 81 await updatedActor.save({ transaction: t })
78 82
79 await sendUpdateActor(accountOrChannel, t) 83 await sendUpdateActor(accountOrChannel, t)
80 84
81 return updatedActor.Avatar 85 return updatedActor.Avatars
82 }) 86 })
83 }) 87 })
84} 88}
85 89
86type DownloadImageQueueTask = { fileUrl: string, filename: string, type: ActorImageType } 90type DownloadImageQueueTask = {
91 fileUrl: string
92 filename: string
93 type: ActorImageType
94 size: typeof ACTOR_IMAGES_SIZE[ActorImageType][0]
95}
87 96
88const downloadImageQueue = queue<DownloadImageQueueTask, Error>((task, cb) => { 97const downloadImageQueue = queue<DownloadImageQueueTask, Error>((task, cb) => {
89 const size = task.type === ActorImageType.AVATAR 98 downloadImage(task.fileUrl, CONFIG.STORAGE.ACTOR_IMAGES, task.filename, task.size)
90 ? ACTOR_IMAGES_SIZE.AVATARS
91 : ACTOR_IMAGES_SIZE.BANNERS
92
93 downloadImage(task.fileUrl, CONFIG.STORAGE.ACTOR_IMAGES, task.filename, size)
94 .then(() => cb()) 99 .then(() => cb())
95 .catch(err => cb(err)) 100 .catch(err => cb(err))
96}, QUEUE_CONCURRENCY.ACTOR_PROCESS_IMAGE) 101}, QUEUE_CONCURRENCY.ACTOR_PROCESS_IMAGE)
@@ -110,7 +115,7 @@ const actorImagePathUnsafeCache = new LRUCache<string, string>({ max: LRU_CACHE.
110 115
111export { 116export {
112 actorImagePathUnsafeCache, 117 actorImagePathUnsafeCache,
113 updateLocalActorImageFile, 118 updateLocalActorImageFiles,
114 deleteLocalActorImageFile, 119 deleteLocalActorImageFile,
115 pushActorImageProcessInQueue, 120 pushActorImageProcessInQueue,
116 buildActorInstance 121 buildActorInstance
diff --git a/server/lib/notifier/shared/comment/comment-mention.ts b/server/lib/notifier/shared/comment/comment-mention.ts
index 765cbaad9..ecd1687b4 100644
--- a/server/lib/notifier/shared/comment/comment-mention.ts
+++ b/server/lib/notifier/shared/comment/comment-mention.ts
@@ -77,7 +77,7 @@ export class CommentMention extends AbstractNotification <MCommentOwnerVideo, MU
77 userId: user.id, 77 userId: user.id,
78 commentId: this.payload.id 78 commentId: this.payload.id
79 }) 79 })
80 notification.Comment = this.payload 80 notification.VideoComment = this.payload
81 81
82 return notification 82 return notification
83 } 83 }
diff --git a/server/lib/notifier/shared/comment/new-comment-for-video-owner.ts b/server/lib/notifier/shared/comment/new-comment-for-video-owner.ts
index b76fc15bf..757502703 100644
--- a/server/lib/notifier/shared/comment/new-comment-for-video-owner.ts
+++ b/server/lib/notifier/shared/comment/new-comment-for-video-owner.ts
@@ -44,7 +44,7 @@ export class NewCommentForVideoOwner extends AbstractNotification <MCommentOwner
44 userId: user.id, 44 userId: user.id,
45 commentId: this.payload.id 45 commentId: this.payload.id
46 }) 46 })
47 notification.Comment = this.payload 47 notification.VideoComment = this.payload
48 48
49 return notification 49 return notification
50 } 50 }
diff --git a/server/models/abuse/abuse-message.ts b/server/models/abuse/abuse-message.ts
index 6a441a210..d9eb25f0f 100644
--- a/server/models/abuse/abuse-message.ts
+++ b/server/models/abuse/abuse-message.ts
@@ -1,11 +1,12 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { isAbuseMessageValid } from '@server/helpers/custom-validators/abuses' 2import { isAbuseMessageValid } from '@server/helpers/custom-validators/abuses'
3import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models' 3import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { AbuseMessage } from '@shared/models' 4import { AbuseMessage } from '@shared/models'
5import { AttributesOnly } from '@shared/typescript-utils'
6import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' 6import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
7import { getSort, throwIfNotValid } from '../utils' 7import { getSort, throwIfNotValid } from '../utils'
8import { AbuseModel } from './abuse' 8import { AbuseModel } from './abuse'
9import { FindOptions } from 'sequelize/dist'
9 10
10@Table({ 11@Table({
11 tableName: 'abuseMessage', 12 tableName: 'abuseMessage',
@@ -62,21 +63,28 @@ export class AbuseMessageModel extends Model<Partial<AttributesOnly<AbuseMessage
62 Abuse: AbuseModel 63 Abuse: AbuseModel
63 64
64 static listForApi (abuseId: number) { 65 static listForApi (abuseId: number) {
65 const options = { 66 const getQuery = (forCount: boolean) => {
66 where: { abuseId }, 67 const query: FindOptions = {
68 where: { abuseId },
69 order: getSort('createdAt')
70 }
67 71
68 order: getSort('createdAt'), 72 if (forCount !== true) {
73 query.include = [
74 {
75 model: AccountModel.scope(AccountScopeNames.SUMMARY),
76 required: false
77 }
78 ]
79 }
69 80
70 include: [ 81 return query
71 {
72 model: AccountModel.scope(AccountScopeNames.SUMMARY),
73 required: false
74 }
75 ]
76 } 82 }
77 83
78 return AbuseMessageModel.findAndCountAll(options) 84 return Promise.all([
79 .then(({ rows, count }) => ({ data: rows, total: count })) 85 AbuseMessageModel.count(getQuery(true)),
86 AbuseMessageModel.findAll(getQuery(false))
87 ]).then(([ total, data ]) => ({ total, data }))
80 } 88 }
81 89
82 static loadByIdAndAbuseId (messageId: number, abuseId: number): Promise<MAbuseMessage> { 90 static loadByIdAndAbuseId (messageId: number, abuseId: number): Promise<MAbuseMessage> {
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
index 1162962bf..a7b8db076 100644
--- a/server/models/account/account-blocklist.ts
+++ b/server/models/account/account-blocklist.ts
@@ -1,7 +1,7 @@
1import { Op, QueryTypes } from 'sequelize' 1import { FindOptions, Op, QueryTypes } from 'sequelize'
2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { handlesToNameAndHost } from '@server/helpers/actors' 3import { handlesToNameAndHost } from '@server/helpers/actors'
4import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models' 4import { MAccountBlocklist, MAccountBlocklistFormattable } from '@server/types/models'
5import { AttributesOnly } from '@shared/typescript-utils' 5import { AttributesOnly } from '@shared/typescript-utils'
6import { AccountBlock } from '../../../shared/models' 6import { AccountBlock } from '../../../shared/models'
7import { ActorModel } from '../actor/actor' 7import { ActorModel } from '../actor/actor'
@@ -9,27 +9,6 @@ import { ServerModel } from '../server/server'
9import { createSafeIn, getSort, searchAttribute } from '../utils' 9import { createSafeIn, getSort, searchAttribute } from '../utils'
10import { AccountModel } from './account' 10import { AccountModel } from './account'
11 11
12enum ScopeNames {
13 WITH_ACCOUNTS = 'WITH_ACCOUNTS'
14}
15
16@Scopes(() => ({
17 [ScopeNames.WITH_ACCOUNTS]: {
18 include: [
19 {
20 model: AccountModel,
21 required: true,
22 as: 'ByAccount'
23 },
24 {
25 model: AccountModel,
26 required: true,
27 as: 'BlockedAccount'
28 }
29 ]
30 }
31}))
32
33@Table({ 12@Table({
34 tableName: 'accountBlocklist', 13 tableName: 'accountBlocklist',
35 indexes: [ 14 indexes: [
@@ -123,33 +102,45 @@ export class AccountBlocklistModel extends Model<Partial<AttributesOnly<AccountB
123 }) { 102 }) {
124 const { start, count, sort, search, accountId } = parameters 103 const { start, count, sort, search, accountId } = parameters
125 104
126 const query = { 105 const getQuery = (forCount: boolean) => {
127 offset: start, 106 const query: FindOptions = {
128 limit: count, 107 offset: start,
129 order: getSort(sort) 108 limit: count,
130 } 109 order: getSort(sort),
110 where: { accountId }
111 }
131 112
132 const where = { 113 if (search) {
133 accountId 114 Object.assign(query.where, {
134 } 115 [Op.or]: [
116 searchAttribute(search, '$BlockedAccount.name$'),
117 searchAttribute(search, '$BlockedAccount.Actor.url$')
118 ]
119 })
120 }
135 121
136 if (search) { 122 if (forCount !== true) {
137 Object.assign(where, { 123 query.include = [
138 [Op.or]: [ 124 {
139 searchAttribute(search, '$BlockedAccount.name$'), 125 model: AccountModel,
140 searchAttribute(search, '$BlockedAccount.Actor.url$') 126 required: true,
127 as: 'ByAccount'
128 },
129 {
130 model: AccountModel,
131 required: true,
132 as: 'BlockedAccount'
133 }
141 ] 134 ]
142 }) 135 }
143 }
144 136
145 Object.assign(query, { where }) 137 return query
138 }
146 139
147 return AccountBlocklistModel 140 return Promise.all([
148 .scope([ ScopeNames.WITH_ACCOUNTS ]) 141 AccountBlocklistModel.count(getQuery(true)),
149 .findAndCountAll<MAccountBlocklistAccounts>(query) 142 AccountBlocklistModel.findAll(getQuery(false))
150 .then(({ rows, count }) => { 143 ]).then(([ total, data ]) => ({ total, data }))
151 return { total: count, data: rows }
152 })
153 } 144 }
154 145
155 static listHandlesBlockedBy (accountIds: number[]): Promise<string[]> { 146 static listHandlesBlockedBy (accountIds: number[]): Promise<string[]> {
diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts
index e89d31adf..7303651eb 100644
--- a/server/models/account/account-video-rate.ts
+++ b/server/models/account/account-video-rate.ts
@@ -121,29 +121,40 @@ export class AccountVideoRateModel extends Model<Partial<AttributesOnly<AccountV
121 type?: string 121 type?: string
122 accountId: number 122 accountId: number
123 }) { 123 }) {
124 const query: FindOptions = { 124 const getQuery = (forCount: boolean) => {
125 offset: options.start, 125 const query: FindOptions = {
126 limit: options.count, 126 offset: options.start,
127 order: getSort(options.sort), 127 limit: options.count,
128 where: { 128 order: getSort(options.sort),
129 accountId: options.accountId 129 where: {
130 }, 130 accountId: options.accountId
131 include: [
132 {
133 model: VideoModel,
134 required: true,
135 include: [
136 {
137 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
138 required: true
139 }
140 ]
141 } 131 }
142 ] 132 }
133
134 if (options.type) query.where['type'] = options.type
135
136 if (forCount !== true) {
137 query.include = [
138 {
139 model: VideoModel,
140 required: true,
141 include: [
142 {
143 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
144 required: true
145 }
146 ]
147 }
148 ]
149 }
150
151 return query
143 } 152 }
144 if (options.type) query.where['type'] = options.type
145 153
146 return AccountVideoRateModel.findAndCountAll(query) 154 return Promise.all([
155 AccountVideoRateModel.count(getQuery(true)),
156 AccountVideoRateModel.findAll(getQuery(false))
157 ]).then(([ total, data ]) => ({ total, data }))
147 } 158 }
148 159
149 static listRemoteRateUrlsOfLocalVideos () { 160 static listRemoteRateUrlsOfLocalVideos () {
@@ -232,7 +243,10 @@ export class AccountVideoRateModel extends Model<Partial<AttributesOnly<AccountV
232 ] 243 ]
233 } 244 }
234 245
235 return AccountVideoRateModel.findAndCountAll<MAccountVideoRateAccountUrl>(query) 246 return Promise.all([
247 AccountVideoRateModel.count(query),
248 AccountVideoRateModel.findAll<MAccountVideoRateAccountUrl>(query)
249 ]).then(([ total, data ]) => ({ total, data }))
236 } 250 }
237 251
238 static cleanOldRatesOf (videoId: number, type: VideoRateType, beforeUpdatedAt: Date) { 252 static cleanOldRatesOf (videoId: number, type: VideoRateType, beforeUpdatedAt: Date) {
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 619a598dd..8a7dfba94 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -54,6 +54,7 @@ export type SummaryOptions = {
54 whereActor?: WhereOptions 54 whereActor?: WhereOptions
55 whereServer?: WhereOptions 55 whereServer?: WhereOptions
56 withAccountBlockerIds?: number[] 56 withAccountBlockerIds?: number[]
57 forCount?: boolean
57} 58}
58 59
59@DefaultScope(() => ({ 60@DefaultScope(() => ({
@@ -73,22 +74,24 @@ export type SummaryOptions = {
73 where: options.whereServer 74 where: options.whereServer
74 } 75 }
75 76
76 const queryInclude: Includeable[] = [ 77 const actorInclude: Includeable = {
77 { 78 attributes: [ 'id', 'preferredUsername', 'url', 'serverId' ],
78 attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ], 79 model: ActorModel.unscoped(),
79 model: ActorModel.unscoped(), 80 required: options.actorRequired ?? true,
80 required: options.actorRequired ?? true, 81 where: options.whereActor,
81 where: options.whereActor, 82 include: [ serverInclude ]
82 include: [ 83 }
83 serverInclude,
84 84
85 { 85 if (options.forCount !== true) {
86 model: ActorImageModel.unscoped(), 86 actorInclude.include.push({
87 as: 'Avatar', 87 model: ActorImageModel,
88 required: false 88 as: 'Avatars',
89 } 89 required: false
90 ] 90 })
91 } 91 }
92
93 const queryInclude: Includeable[] = [
94 actorInclude
92 ] 95 ]
93 96
94 const query: FindOptions = { 97 const query: FindOptions = {
@@ -349,13 +352,10 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
349 order: getSort(sort) 352 order: getSort(sort)
350 } 353 }
351 354
352 return AccountModel.findAndCountAll(query) 355 return Promise.all([
353 .then(({ rows, count }) => { 356 AccountModel.count(),
354 return { 357 AccountModel.findAll(query)
355 data: rows, 358 ]).then(([ total, data ]) => ({ total, data }))
356 total: count
357 }
358 })
359 } 359 }
360 360
361 static loadAccountIdFromVideo (videoId: number): Promise<MAccount> { 361 static loadAccountIdFromVideo (videoId: number): Promise<MAccount> {
@@ -407,16 +407,15 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
407 } 407 }
408 408
409 toFormattedJSON (this: MAccountFormattable): Account { 409 toFormattedJSON (this: MAccountFormattable): Account {
410 const actor = this.Actor.toFormattedJSON() 410 return {
411 const account = { 411 ...this.Actor.toFormattedJSON(),
412
412 id: this.id, 413 id: this.id,
413 displayName: this.getDisplayName(), 414 displayName: this.getDisplayName(),
414 description: this.description, 415 description: this.description,
415 updatedAt: this.updatedAt, 416 updatedAt: this.updatedAt,
416 userId: this.userId ? this.userId : undefined 417 userId: this.userId ?? undefined
417 } 418 }
418
419 return Object.assign(actor, account)
420 } 419 }
421 420
422 toFormattedSummaryJSON (this: MAccountSummaryFormattable): AccountSummary { 421 toFormattedSummaryJSON (this: MAccountSummaryFormattable): AccountSummary {
@@ -424,10 +423,14 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
424 423
425 return { 424 return {
426 id: this.id, 425 id: this.id,
427 name: actor.name,
428 displayName: this.getDisplayName(), 426 displayName: this.getDisplayName(),
427
428 name: actor.name,
429 url: actor.url, 429 url: actor.url,
430 host: actor.host, 430 host: actor.host,
431 avatars: actor.avatars,
432
433 // TODO: remove, deprecated in 4.2
431 avatar: actor.avatar 434 avatar: actor.avatar
432 } 435 }
433 } 436 }
diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts
index 006282530..0f4d3c0a6 100644
--- a/server/models/actor/actor-follow.ts
+++ b/server/models/actor/actor-follow.ts
@@ -1,5 +1,5 @@
1import { difference, values } from 'lodash' 1import { difference, values } from 'lodash'
2import { IncludeOptions, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize' 2import { Includeable, IncludeOptions, literal, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize'
3import { 3import {
4 AfterCreate, 4 AfterCreate,
5 AfterDestroy, 5 AfterDestroy,
@@ -30,12 +30,12 @@ import {
30 MActorFollowFormattable, 30 MActorFollowFormattable,
31 MActorFollowSubscriptions 31 MActorFollowSubscriptions
32} from '@server/types/models' 32} from '@server/types/models'
33import { AttributesOnly } from '@shared/typescript-utils'
34import { ActivityPubActorType } from '@shared/models' 33import { ActivityPubActorType } from '@shared/models'
34import { AttributesOnly } from '@shared/typescript-utils'
35import { FollowState } from '../../../shared/models/actors' 35import { FollowState } from '../../../shared/models/actors'
36import { ActorFollow } from '../../../shared/models/actors/follow.model' 36import { ActorFollow } from '../../../shared/models/actors/follow.model'
37import { logger } from '../../helpers/logger' 37import { logger } from '../../helpers/logger'
38import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants' 38import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME, SORTABLE_COLUMNS } from '../../initializers/constants'
39import { AccountModel } from '../account/account' 39import { AccountModel } from '../account/account'
40import { ServerModel } from '../server/server' 40import { ServerModel } from '../server/server'
41import { doesExist } from '../shared/query' 41import { doesExist } from '../shared/query'
@@ -375,43 +375,46 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
375 Object.assign(followingWhere, { type: actorType }) 375 Object.assign(followingWhere, { type: actorType })
376 } 376 }
377 377
378 const query = { 378 const getQuery = (forCount: boolean) => {
379 distinct: true, 379 const actorModel = forCount
380 offset: start, 380 ? ActorModel.unscoped()
381 limit: count, 381 : ActorModel
382 order: getFollowsSort(sort), 382
383 where: followWhere, 383 return {
384 include: [ 384 distinct: true,
385 { 385 offset: start,
386 model: ActorModel, 386 limit: count,
387 required: true, 387 order: getFollowsSort(sort),
388 as: 'ActorFollower', 388 where: followWhere,
389 where: { 389 include: [
390 id 390 {
391 } 391 model: actorModel,
392 }, 392 required: true,
393 { 393 as: 'ActorFollower',
394 model: ActorModel, 394 where: {
395 as: 'ActorFollowing', 395 id
396 required: true,
397 where: followingWhere,
398 include: [
399 {
400 model: ServerModel,
401 required: true
402 } 396 }
403 ] 397 },
404 } 398 {
405 ] 399 model: actorModel,
400 as: 'ActorFollowing',
401 required: true,
402 where: followingWhere,
403 include: [
404 {
405 model: ServerModel,
406 required: true
407 }
408 ]
409 }
410 ]
411 }
406 } 412 }
407 413
408 return ActorFollowModel.findAndCountAll<MActorFollowActorsDefault>(query) 414 return Promise.all([
409 .then(({ rows, count }) => { 415 ActorFollowModel.count(getQuery(true)),
410 return { 416 ActorFollowModel.findAll<MActorFollowActorsDefault>(getQuery(false))
411 data: rows, 417 ]).then(([ total, data ]) => ({ total, data }))
412 total: count
413 }
414 })
415 } 418 }
416 419
417 static listFollowersForApi (options: { 420 static listFollowersForApi (options: {
@@ -429,11 +432,17 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
429 const followerWhere: WhereOptions = {} 432 const followerWhere: WhereOptions = {}
430 433
431 if (search) { 434 if (search) {
432 Object.assign(followWhere, { 435 const escapedSearch = ActorFollowModel.sequelize.escape('%' + search + '%')
433 [Op.or]: [ 436
434 searchAttribute(search, '$ActorFollower.preferredUsername$'), 437 Object.assign(followerWhere, {
435 searchAttribute(search, '$ActorFollower.Server.host$') 438 id: {
436 ] 439 [Op.in]: literal(
440 `(` +
441 `SELECT "actor".id FROM actor LEFT JOIN server on server.id = actor."serverId" ` +
442 `WHERE "preferredUsername" ILIKE ${escapedSearch} OR "host" ILIKE ${escapedSearch}` +
443 `)`
444 )
445 }
437 }) 446 })
438 } 447 }
439 448
@@ -441,39 +450,43 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
441 Object.assign(followerWhere, { type: actorType }) 450 Object.assign(followerWhere, { type: actorType })
442 } 451 }
443 452
444 const query = { 453 const getQuery = (forCount: boolean) => {
445 distinct: true, 454 const actorModel = forCount
446 offset: start, 455 ? ActorModel.unscoped()
447 limit: count, 456 : ActorModel
448 order: getFollowsSort(sort), 457
449 where: followWhere, 458 return {
450 include: [ 459 distinct: true,
451 { 460
452 model: ActorModel, 461 offset: start,
453 required: true, 462 limit: count,
454 as: 'ActorFollower', 463 order: getFollowsSort(sort),
455 where: followerWhere 464 where: followWhere,
456 }, 465 include: [
457 { 466 {
458 model: ActorModel, 467 model: actorModel,
459 as: 'ActorFollowing', 468 required: true,
460 required: true, 469 as: 'ActorFollower',
461 where: { 470 where: followerWhere
462 id: { 471 },
463 [Op.in]: actorIds 472 {
473 model: actorModel,
474 as: 'ActorFollowing',
475 required: true,
476 where: {
477 id: {
478 [Op.in]: actorIds
479 }
464 } 480 }
465 } 481 }
466 } 482 ]
467 ] 483 }
468 } 484 }
469 485
470 return ActorFollowModel.findAndCountAll<MActorFollowActorsDefault>(query) 486 return Promise.all([
471 .then(({ rows, count }) => { 487 ActorFollowModel.count(getQuery(true)),
472 return { 488 ActorFollowModel.findAll<MActorFollowActorsDefault>(getQuery(false))
473 data: rows, 489 ]).then(([ total, data ]) => ({ total, data }))
474 total: count
475 }
476 })
477 } 490 }
478 491
479 static listSubscriptionsForApi (options: { 492 static listSubscriptionsForApi (options: {
@@ -497,58 +510,68 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
497 }) 510 })
498 } 511 }
499 512
500 const query = { 513 const getQuery = (forCount: boolean) => {
501 attributes: [], 514 let channelInclude: Includeable[] = []
502 distinct: true, 515
503 offset: start, 516 if (forCount !== true) {
504 limit: count, 517 channelInclude = [
505 order: getSort(sort), 518 {
506 where, 519 attributes: {
507 include: [ 520 exclude: unusedActorAttributesForAPI
508 { 521 },
509 attributes: [ 'id' ], 522 model: ActorModel,
510 model: ActorModel.unscoped(), 523 required: true
511 as: 'ActorFollowing', 524 },
512 required: true, 525 {
513 include: [ 526 model: AccountModel.unscoped(),
514 { 527 required: true,
515 model: VideoChannelModel.unscoped(), 528 include: [
516 required: true, 529 {
517 include: [ 530 attributes: {
518 { 531 exclude: unusedActorAttributesForAPI
519 attributes: {
520 exclude: unusedActorAttributesForAPI
521 },
522 model: ActorModel,
523 required: true
524 }, 532 },
525 { 533 model: ActorModel,
526 model: AccountModel.unscoped(), 534 required: true
527 required: true, 535 }
528 include: [ 536 ]
529 { 537 }
530 attributes: { 538 ]
531 exclude: unusedActorAttributesForAPI 539 }
532 }, 540
533 model: ActorModel, 541 return {
534 required: true 542 attributes: forCount === true
535 } 543 ? []
536 ] 544 : SORTABLE_COLUMNS.USER_SUBSCRIPTIONS,
537 } 545 distinct: true,
538 ] 546 offset: start,
539 } 547 limit: count,
540 ] 548 order: getSort(sort),
541 } 549 where,
542 ] 550 include: [
551 {
552 attributes: [ 'id' ],
553 model: ActorModel.unscoped(),
554 as: 'ActorFollowing',
555 required: true,
556 include: [
557 {
558 model: VideoChannelModel.unscoped(),
559 required: true,
560 include: channelInclude
561 }
562 ]
563 }
564 ]
565 }
543 } 566 }
544 567
545 return ActorFollowModel.findAndCountAll<MActorFollowSubscriptions>(query) 568 return Promise.all([
546 .then(({ rows, count }) => { 569 ActorFollowModel.count(getQuery(true)),
547 return { 570 ActorFollowModel.findAll<MActorFollowSubscriptions>(getQuery(false))
548 data: rows.map(r => r.ActorFollowing.VideoChannel), 571 ]).then(([ total, rows ]) => ({
549 total: count 572 total,
550 } 573 data: rows.map(r => r.ActorFollowing.VideoChannel)
551 }) 574 }))
552 } 575 }
553 576
554 static async keepUnfollowedInstance (hosts: string[]) { 577 static async keepUnfollowedInstance (hosts: string[]) {
diff --git a/server/models/actor/actor-image.ts b/server/models/actor/actor-image.ts
index 8edff5ab4..f74ab735e 100644
--- a/server/models/actor/actor-image.ts
+++ b/server/models/actor/actor-image.ts
@@ -1,15 +1,29 @@
1import { remove } from 'fs-extra' 1import { remove } from 'fs-extra'
2import { join } from 'path' 2import { join } from 'path'
3import { AfterDestroy, AllowNull, Column, CreatedAt, Default, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 3import {
4import { MActorImageFormattable } from '@server/types/models' 4 AfterDestroy,
5 AllowNull,
6 BelongsTo,
7 Column,
8 CreatedAt,
9 Default,
10 ForeignKey,
11 Is,
12 Model,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import { MActorImage, MActorImageFormattable } from '@server/types/models'
17import { getLowercaseExtension } from '@shared/core-utils'
18import { ActivityIconObject, ActorImageType } from '@shared/models'
5import { AttributesOnly } from '@shared/typescript-utils' 19import { AttributesOnly } from '@shared/typescript-utils'
6import { ActorImageType } from '@shared/models'
7import { ActorImage } from '../../../shared/models/actors/actor-image.model' 20import { ActorImage } from '../../../shared/models/actors/actor-image.model'
8import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 21import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
9import { logger } from '../../helpers/logger' 22import { logger } from '../../helpers/logger'
10import { CONFIG } from '../../initializers/config' 23import { CONFIG } from '../../initializers/config'
11import { LAZY_STATIC_PATHS } from '../../initializers/constants' 24import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants'
12import { throwIfNotValid } from '../utils' 25import { throwIfNotValid } from '../utils'
26import { ActorModel } from './actor'
13 27
14@Table({ 28@Table({
15 tableName: 'actorImage', 29 tableName: 'actorImage',
@@ -17,6 +31,10 @@ import { throwIfNotValid } from '../utils'
17 { 31 {
18 fields: [ 'filename' ], 32 fields: [ 'filename' ],
19 unique: true 33 unique: true
34 },
35 {
36 fields: [ 'actorId', 'type', 'width' ],
37 unique: true
20 } 38 }
21 ] 39 ]
22}) 40})
@@ -55,6 +73,18 @@ export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageMode
55 @UpdatedAt 73 @UpdatedAt
56 updatedAt: Date 74 updatedAt: Date
57 75
76 @ForeignKey(() => ActorModel)
77 @Column
78 actorId: number
79
80 @BelongsTo(() => ActorModel, {
81 foreignKey: {
82 allowNull: false
83 },
84 onDelete: 'CASCADE'
85 })
86 Actor: ActorModel
87
58 @AfterDestroy 88 @AfterDestroy
59 static removeFilesAndSendDelete (instance: ActorImageModel) { 89 static removeFilesAndSendDelete (instance: ActorImageModel) {
60 logger.info('Removing actor image file %s.', instance.filename) 90 logger.info('Removing actor image file %s.', instance.filename)
@@ -74,20 +104,41 @@ export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageMode
74 return ActorImageModel.findOne(query) 104 return ActorImageModel.findOne(query)
75 } 105 }
76 106
107 static getImageUrl (image: MActorImage) {
108 if (!image) return undefined
109
110 return WEBSERVER.URL + image.getStaticPath()
111 }
112
77 toFormattedJSON (this: MActorImageFormattable): ActorImage { 113 toFormattedJSON (this: MActorImageFormattable): ActorImage {
78 return { 114 return {
115 width: this.width,
79 path: this.getStaticPath(), 116 path: this.getStaticPath(),
80 createdAt: this.createdAt, 117 createdAt: this.createdAt,
81 updatedAt: this.updatedAt 118 updatedAt: this.updatedAt
82 } 119 }
83 } 120 }
84 121
85 getStaticPath () { 122 toActivityPubObject (): ActivityIconObject {
86 if (this.type === ActorImageType.AVATAR) { 123 const extension = getLowercaseExtension(this.filename)
87 return join(LAZY_STATIC_PATHS.AVATARS, this.filename) 124
125 return {
126 type: 'Image',
127 mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
128 height: this.height,
129 width: this.width,
130 url: ActorImageModel.getImageUrl(this)
88 } 131 }
132 }
89 133
90 return join(LAZY_STATIC_PATHS.BANNERS, this.filename) 134 getStaticPath () {
135 switch (this.type) {
136 case ActorImageType.AVATAR:
137 return join(LAZY_STATIC_PATHS.AVATARS, this.filename)
138
139 case ActorImageType.BANNER:
140 return join(LAZY_STATIC_PATHS.BANNERS, this.filename)
141 }
91 } 142 }
92 143
93 getPath () { 144 getPath () {
diff --git a/server/models/actor/actor.ts b/server/models/actor/actor.ts
index c12dcf634..08cb2fd24 100644
--- a/server/models/actor/actor.ts
+++ b/server/models/actor/actor.ts
@@ -16,11 +16,11 @@ import {
16 Table, 16 Table,
17 UpdatedAt 17 UpdatedAt
18} from 'sequelize-typescript' 18} from 'sequelize-typescript'
19import { getBiggestActorImage } from '@server/lib/actor-image'
19import { ModelCache } from '@server/models/model-cache' 20import { ModelCache } from '@server/models/model-cache'
20import { getLowercaseExtension } from '@shared/core-utils' 21import { getLowercaseExtension } from '@shared/core-utils'
22import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models'
21import { AttributesOnly } from '@shared/typescript-utils' 23import { AttributesOnly } from '@shared/typescript-utils'
22import { ActivityIconObject, ActivityPubActorType } from '../../../shared/models/activitypub'
23import { ActorImage } from '../../../shared/models/actors/actor-image.model'
24import { activityPubContextify } from '../../helpers/activitypub' 24import { activityPubContextify } from '../../helpers/activitypub'
25import { 25import {
26 isActorFollowersCountValid, 26 isActorFollowersCountValid,
@@ -81,7 +81,7 @@ export const unusedActorAttributesForAPI = [
81 }, 81 },
82 { 82 {
83 model: ActorImageModel, 83 model: ActorImageModel,
84 as: 'Avatar', 84 as: 'Avatars',
85 required: false 85 required: false
86 } 86 }
87 ] 87 ]
@@ -109,12 +109,12 @@ export const unusedActorAttributesForAPI = [
109 }, 109 },
110 { 110 {
111 model: ActorImageModel, 111 model: ActorImageModel,
112 as: 'Avatar', 112 as: 'Avatars',
113 required: false 113 required: false
114 }, 114 },
115 { 115 {
116 model: ActorImageModel, 116 model: ActorImageModel,
117 as: 'Banner', 117 as: 'Banners',
118 required: false 118 required: false
119 } 119 }
120 ] 120 ]
@@ -153,9 +153,6 @@ export const unusedActorAttributesForAPI = [
153 fields: [ 'serverId' ] 153 fields: [ 'serverId' ]
154 }, 154 },
155 { 155 {
156 fields: [ 'avatarId' ]
157 },
158 {
159 fields: [ 'followersUrl' ] 156 fields: [ 'followersUrl' ]
160 } 157 }
161 ] 158 ]
@@ -231,35 +228,31 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
231 @UpdatedAt 228 @UpdatedAt
232 updatedAt: Date 229 updatedAt: Date
233 230
234 @ForeignKey(() => ActorImageModel) 231 @HasMany(() => ActorImageModel, {
235 @Column 232 as: 'Avatars',
236 avatarId: number 233 onDelete: 'cascade',
237 234 hooks: true,
238 @ForeignKey(() => ActorImageModel)
239 @Column
240 bannerId: number
241
242 @BelongsTo(() => ActorImageModel, {
243 foreignKey: { 235 foreignKey: {
244 name: 'avatarId', 236 allowNull: false
245 allowNull: true
246 }, 237 },
247 as: 'Avatar', 238 scope: {
248 onDelete: 'set null', 239 type: ActorImageType.AVATAR
249 hooks: true 240 }
250 }) 241 })
251 Avatar: ActorImageModel 242 Avatars: ActorImageModel[]
252 243
253 @BelongsTo(() => ActorImageModel, { 244 @HasMany(() => ActorImageModel, {
245 as: 'Banners',
246 onDelete: 'cascade',
247 hooks: true,
254 foreignKey: { 248 foreignKey: {
255 name: 'bannerId', 249 allowNull: false
256 allowNull: true
257 }, 250 },
258 as: 'Banner', 251 scope: {
259 onDelete: 'set null', 252 type: ActorImageType.BANNER
260 hooks: true 253 }
261 }) 254 })
262 Banner: ActorImageModel 255 Banners: ActorImageModel[]
263 256
264 @HasMany(() => ActorFollowModel, { 257 @HasMany(() => ActorFollowModel, {
265 foreignKey: { 258 foreignKey: {
@@ -386,8 +379,7 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
386 transaction 379 transaction
387 } 380 }
388 381
389 return ActorModel.scope(ScopeNames.FULL) 382 return ActorModel.scope(ScopeNames.FULL).findOne(query)
390 .findOne(query)
391 } 383 }
392 384
393 return ModelCache.Instance.doCache({ 385 return ModelCache.Instance.doCache({
@@ -410,8 +402,7 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
410 transaction 402 transaction
411 } 403 }
412 404
413 return ActorModel.unscoped() 405 return ActorModel.unscoped().findOne(query)
414 .findOne(query)
415 } 406 }
416 407
417 return ModelCache.Instance.doCache({ 408 return ModelCache.Instance.doCache({
@@ -532,55 +523,50 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
532 } 523 }
533 524
534 toFormattedSummaryJSON (this: MActorSummaryFormattable) { 525 toFormattedSummaryJSON (this: MActorSummaryFormattable) {
535 let avatar: ActorImage = null
536 if (this.Avatar) {
537 avatar = this.Avatar.toFormattedJSON()
538 }
539
540 return { 526 return {
541 url: this.url, 527 url: this.url,
542 name: this.preferredUsername, 528 name: this.preferredUsername,
543 host: this.getHost(), 529 host: this.getHost(),
544 avatar 530 avatars: (this.Avatars || []).map(a => a.toFormattedJSON()),
531
532 // TODO: remove, deprecated in 4.2
533 avatar: this.hasImage(ActorImageType.AVATAR)
534 ? this.Avatars[0].toFormattedJSON()
535 : undefined
545 } 536 }
546 } 537 }
547 538
548 toFormattedJSON (this: MActorFormattable) { 539 toFormattedJSON (this: MActorFormattable) {
549 const base = this.toFormattedSummaryJSON() 540 return {
550 541 ...this.toFormattedSummaryJSON(),
551 let banner: ActorImage = null
552 if (this.Banner) {
553 banner = this.Banner.toFormattedJSON()
554 }
555 542
556 return Object.assign(base, {
557 id: this.id, 543 id: this.id,
558 hostRedundancyAllowed: this.getRedundancyAllowed(), 544 hostRedundancyAllowed: this.getRedundancyAllowed(),
559 followingCount: this.followingCount, 545 followingCount: this.followingCount,
560 followersCount: this.followersCount, 546 followersCount: this.followersCount,
561 banner, 547 createdAt: this.getCreatedAt(),
562 createdAt: this.getCreatedAt() 548
563 }) 549 banners: (this.Banners || []).map(b => b.toFormattedJSON()),
550
551 // TODO: remove, deprecated in 4.2
552 banner: this.hasImage(ActorImageType.BANNER)
553 ? this.Banners[0].toFormattedJSON()
554 : undefined
555 }
564 } 556 }
565 557
566 toActivityPubObject (this: MActorAPChannel | MActorAPAccount, name: string) { 558 toActivityPubObject (this: MActorAPChannel | MActorAPAccount, name: string) {
567 let icon: ActivityIconObject 559 let icon: ActivityIconObject
560 let icons: ActivityIconObject[]
568 let image: ActivityIconObject 561 let image: ActivityIconObject
569 562
570 if (this.avatarId) { 563 if (this.hasImage(ActorImageType.AVATAR)) {
571 const extension = getLowercaseExtension(this.Avatar.filename) 564 icon = getBiggestActorImage(this.Avatars).toActivityPubObject()
572 565 icons = this.Avatars.map(a => a.toActivityPubObject())
573 icon = {
574 type: 'Image',
575 mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
576 height: this.Avatar.height,
577 width: this.Avatar.width,
578 url: this.getAvatarUrl()
579 }
580 } 566 }
581 567
582 if (this.bannerId) { 568 if (this.hasImage(ActorImageType.BANNER)) {
583 const banner = (this as MActorAPChannel).Banner 569 const banner = getBiggestActorImage((this as MActorAPChannel).Banners)
584 const extension = getLowercaseExtension(banner.filename) 570 const extension = getLowercaseExtension(banner.filename)
585 571
586 image = { 572 image = {
@@ -588,7 +574,7 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
588 mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension], 574 mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
589 height: banner.height, 575 height: banner.height,
590 width: banner.width, 576 width: banner.width,
591 url: this.getBannerUrl() 577 url: ActorImageModel.getImageUrl(banner)
592 } 578 }
593 } 579 }
594 580
@@ -612,7 +598,10 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
612 publicKeyPem: this.publicKey 598 publicKeyPem: this.publicKey
613 }, 599 },
614 published: this.getCreatedAt().toISOString(), 600 published: this.getCreatedAt().toISOString(),
601
615 icon, 602 icon,
603 icons,
604
616 image 605 image
617 } 606 }
618 607
@@ -677,16 +666,12 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
677 return this.Server ? this.Server.redundancyAllowed : false 666 return this.Server ? this.Server.redundancyAllowed : false
678 } 667 }
679 668
680 getAvatarUrl () { 669 hasImage (type: ActorImageType) {
681 if (!this.avatarId) return undefined 670 const images = type === ActorImageType.AVATAR
682 671 ? this.Avatars
683 return WEBSERVER.URL + this.Avatar.getStaticPath() 672 : this.Banners
684 }
685
686 getBannerUrl () {
687 if (!this.bannerId) return undefined
688 673
689 return WEBSERVER.URL + this.Banner.getStaticPath() 674 return Array.isArray(images) && images.length !== 0
690 } 675 }
691 676
692 isOutdated () { 677 isOutdated () {
diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts
index 05083e3f7..fa5b4cc4b 100644
--- a/server/models/server/plugin.ts
+++ b/server/models/server/plugin.ts
@@ -239,11 +239,10 @@ export class PluginModel extends Model<Partial<AttributesOnly<PluginModel>>> {
239 239
240 if (options.pluginType) query.where['type'] = options.pluginType 240 if (options.pluginType) query.where['type'] = options.pluginType
241 241
242 return PluginModel 242 return Promise.all([
243 .findAndCountAll<MPlugin>(query) 243 PluginModel.count(query),
244 .then(({ rows, count }) => { 244 PluginModel.findAll<MPlugin>(query)
245 return { total: count, data: rows } 245 ]).then(([ total, data ]) => ({ total, data }))
246 })
247 } 246 }
248 247
249 static listInstalled (): Promise<MPlugin[]> { 248 static listInstalled (): Promise<MPlugin[]> {
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
index 9f64eeb7f..9752dfbc3 100644
--- a/server/models/server/server-blocklist.ts
+++ b/server/models/server/server-blocklist.ts
@@ -1,8 +1,8 @@
1import { Op, QueryTypes } from 'sequelize' 1import { Op, QueryTypes } from 'sequelize'
2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models' 3import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { ServerBlock } from '@shared/models' 4import { ServerBlock } from '@shared/models'
5import { AttributesOnly } from '@shared/typescript-utils'
6import { AccountModel } from '../account/account' 6import { AccountModel } from '../account/account'
7import { createSafeIn, getSort, searchAttribute } from '../utils' 7import { createSafeIn, getSort, searchAttribute } from '../utils'
8import { ServerModel } from './server' 8import { ServerModel } from './server'
@@ -169,16 +169,15 @@ export class ServerBlocklistModel extends Model<Partial<AttributesOnly<ServerBlo
169 order: getSort(sort), 169 order: getSort(sort),
170 where: { 170 where: {
171 accountId, 171 accountId,
172
172 ...searchAttribute(search, '$BlockedServer.host$') 173 ...searchAttribute(search, '$BlockedServer.host$')
173 } 174 }
174 } 175 }
175 176
176 return ServerBlocklistModel 177 return Promise.all([
177 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_SERVER ]) 178 ServerBlocklistModel.scope(ScopeNames.WITH_SERVER).count(query),
178 .findAndCountAll<MServerBlocklistAccountServer>(query) 179 ServerBlocklistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_SERVER ]).findAll<MServerBlocklistAccountServer>(query)
179 .then(({ rows, count }) => { 180 ]).then(([ total, data ]) => ({ total, data }))
180 return { total: count, data: rows }
181 })
182 } 181 }
183 182
184 toFormattedJSON (this: MServerBlocklistFormattable): ServerBlock { 183 toFormattedJSON (this: MServerBlocklistFormattable): ServerBlock {
diff --git a/server/models/shared/index.ts b/server/models/shared/index.ts
index 5b97510e0..802404555 100644
--- a/server/models/shared/index.ts
+++ b/server/models/shared/index.ts
@@ -1,2 +1,3 @@
1export * from './model-builder'
1export * from './query' 2export * from './query'
2export * from './update' 3export * from './update'
diff --git a/server/models/shared/model-builder.ts b/server/models/shared/model-builder.ts
new file mode 100644
index 000000000..c015ca4f5
--- /dev/null
+++ b/server/models/shared/model-builder.ts
@@ -0,0 +1,101 @@
1import { isPlainObject } from 'lodash'
2import { Model as SequelizeModel, Sequelize } from 'sequelize'
3import { logger } from '@server/helpers/logger'
4
5export class ModelBuilder <T extends SequelizeModel> {
6 private readonly modelRegistry = new Map<string, T>()
7
8 constructor (private readonly sequelize: Sequelize) {
9
10 }
11
12 createModels (jsonArray: any[], baseModelName: string): T[] {
13 const result: T[] = []
14
15 for (const json of jsonArray) {
16 const { created, model } = this.createModel(json, baseModelName, json.id + '.' + baseModelName)
17
18 if (created) result.push(model)
19 }
20
21 return result
22 }
23
24 private createModel (json: any, modelName: string, keyPath: string) {
25 if (!json.id) return { created: false, model: null }
26
27 const { created, model } = this.createOrFindModel(json, modelName, keyPath)
28
29 for (const key of Object.keys(json)) {
30 const value = json[key]
31 if (!value) continue
32
33 // Child model
34 if (isPlainObject(value)) {
35 const { created, model: subModel } = this.createModel(value, key, keyPath + '.' + json.id + '.' + key)
36 if (!created || !subModel) continue
37
38 const Model = this.findModelBuilder(modelName)
39 const association = Model.associations[key]
40
41 if (!association) {
42 logger.error('Cannot find association %s of model %s', key, modelName, { associations: Object.keys(Model.associations) })
43 continue
44 }
45
46 if (association.isMultiAssociation) {
47 if (!Array.isArray(model[key])) model[key] = []
48
49 model[key].push(subModel)
50 } else {
51 model[key] = subModel
52 }
53 }
54 }
55
56 return { created, model }
57 }
58
59 private createOrFindModel (json: any, modelName: string, keyPath: string) {
60 const registryKey = this.getModelRegistryKey(json, keyPath)
61 if (this.modelRegistry.has(registryKey)) {
62 return {
63 created: false,
64 model: this.modelRegistry.get(registryKey)
65 }
66 }
67
68 const Model = this.findModelBuilder(modelName)
69
70 if (!Model) {
71 logger.error(
72 'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
73 { existing: this.sequelize.modelManager.all.map(m => m.name) }
74 )
75 return undefined
76 }
77
78 // FIXME: typings
79 const model = new (Model as any)(json)
80 this.modelRegistry.set(registryKey, model)
81
82 return { created: true, model }
83 }
84
85 private findModelBuilder (modelName: string) {
86 return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName))
87 }
88
89 private buildSequelizeModelName (modelName: string) {
90 if (modelName === 'Avatars') return 'ActorImageModel'
91 if (modelName === 'ActorFollowing') return 'ActorModel'
92 if (modelName === 'ActorFollower') return 'ActorModel'
93 if (modelName === 'FlaggedAccount') return 'AccountModel'
94
95 return modelName + 'Model'
96 }
97
98 private getModelRegistryKey (json: any, keyPath: string) {
99 return keyPath + json.id
100 }
101}
diff --git a/server/models/user/sql/user-notitication-list-query-builder.ts b/server/models/user/sql/user-notitication-list-query-builder.ts
new file mode 100644
index 000000000..9eae4fc22
--- /dev/null
+++ b/server/models/user/sql/user-notitication-list-query-builder.ts
@@ -0,0 +1,269 @@
1import { QueryTypes, Sequelize } from 'sequelize'
2import { ModelBuilder } from '@server/models/shared'
3import { getSort } from '@server/models/utils'
4import { UserNotificationModelForApi } from '@server/types/models'
5import { ActorImageType } from '@shared/models'
6
7export interface ListNotificationsOptions {
8 userId: number
9 unread?: boolean
10 sort: string
11 offset: number
12 limit: number
13 sequelize: Sequelize
14}
15
16export class UserNotificationListQueryBuilder {
17 private innerQuery: string
18 private replacements: any = {}
19 private query: string
20
21 constructor (private readonly options: ListNotificationsOptions) {
22
23 }
24
25 async listNotifications () {
26 this.buildQuery()
27
28 const results = await this.options.sequelize.query(this.query, {
29 replacements: this.replacements,
30 type: QueryTypes.SELECT,
31 nest: true
32 })
33
34 const modelBuilder = new ModelBuilder<UserNotificationModelForApi>(this.options.sequelize)
35
36 return modelBuilder.createModels(results, 'UserNotification')
37 }
38
39 private buildInnerQuery () {
40 this.innerQuery = `SELECT * FROM "userNotification" AS "UserNotificationModel" ` +
41 `${this.getWhere()} ` +
42 `${this.getOrder()} ` +
43 `LIMIT :limit OFFSET :offset `
44
45 this.replacements.limit = this.options.limit
46 this.replacements.offset = this.options.offset
47 }
48
49 private buildQuery () {
50 this.buildInnerQuery()
51
52 this.query = `
53 ${this.getSelect()}
54 FROM (${this.innerQuery}) "UserNotificationModel"
55 ${this.getJoins()}
56 ${this.getOrder()}`
57 }
58
59 private getWhere () {
60 let base = '"UserNotificationModel"."userId" = :userId '
61 this.replacements.userId = this.options.userId
62
63 if (this.options.unread === true) {
64 base += 'AND "UserNotificationModel"."read" IS FALSE '
65 } else if (this.options.unread === false) {
66 base += 'AND "UserNotificationModel"."read" IS TRUE '
67 }
68
69 return `WHERE ${base}`
70 }
71
72 private getOrder () {
73 const orders = getSort(this.options.sort)
74
75 return 'ORDER BY ' + orders.map(o => `"UserNotificationModel"."${o[0]}" ${o[1]}`).join(', ')
76 }
77
78 private getSelect () {
79 return `SELECT
80 "UserNotificationModel"."id",
81 "UserNotificationModel"."type",
82 "UserNotificationModel"."read",
83 "UserNotificationModel"."createdAt",
84 "UserNotificationModel"."updatedAt",
85 "Video"."id" AS "Video.id",
86 "Video"."uuid" AS "Video.uuid",
87 "Video"."name" AS "Video.name",
88 "Video->VideoChannel"."id" AS "Video.VideoChannel.id",
89 "Video->VideoChannel"."name" AS "Video.VideoChannel.name",
90 "Video->VideoChannel->Actor"."id" AS "Video.VideoChannel.Actor.id",
91 "Video->VideoChannel->Actor"."preferredUsername" AS "Video.VideoChannel.Actor.preferredUsername",
92 "Video->VideoChannel->Actor->Avatars"."id" AS "Video.VideoChannel.Actor.Avatars.id",
93 "Video->VideoChannel->Actor->Avatars"."width" AS "Video.VideoChannel.Actor.Avatars.width",
94 "Video->VideoChannel->Actor->Avatars"."filename" AS "Video.VideoChannel.Actor.Avatars.filename",
95 "Video->VideoChannel->Actor->Server"."id" AS "Video.VideoChannel.Actor.Server.id",
96 "Video->VideoChannel->Actor->Server"."host" AS "Video.VideoChannel.Actor.Server.host",
97 "VideoComment"."id" AS "VideoComment.id",
98 "VideoComment"."originCommentId" AS "VideoComment.originCommentId",
99 "VideoComment->Account"."id" AS "VideoComment.Account.id",
100 "VideoComment->Account"."name" AS "VideoComment.Account.name",
101 "VideoComment->Account->Actor"."id" AS "VideoComment.Account.Actor.id",
102 "VideoComment->Account->Actor"."preferredUsername" AS "VideoComment.Account.Actor.preferredUsername",
103 "VideoComment->Account->Actor->Avatars"."id" AS "VideoComment.Account.Actor.Avatars.id",
104 "VideoComment->Account->Actor->Avatars"."width" AS "VideoComment.Account.Actor.Avatars.width",
105 "VideoComment->Account->Actor->Avatars"."filename" AS "VideoComment.Account.Actor.Avatars.filename",
106 "VideoComment->Account->Actor->Server"."id" AS "VideoComment.Account.Actor.Server.id",
107 "VideoComment->Account->Actor->Server"."host" AS "VideoComment.Account.Actor.Server.host",
108 "VideoComment->Video"."id" AS "VideoComment.Video.id",
109 "VideoComment->Video"."uuid" AS "VideoComment.Video.uuid",
110 "VideoComment->Video"."name" AS "VideoComment.Video.name",
111 "Abuse"."id" AS "Abuse.id",
112 "Abuse"."state" AS "Abuse.state",
113 "Abuse->VideoAbuse"."id" AS "Abuse.VideoAbuse.id",
114 "Abuse->VideoAbuse->Video"."id" AS "Abuse.VideoAbuse.Video.id",
115 "Abuse->VideoAbuse->Video"."uuid" AS "Abuse.VideoAbuse.Video.uuid",
116 "Abuse->VideoAbuse->Video"."name" AS "Abuse.VideoAbuse.Video.name",
117 "Abuse->VideoCommentAbuse"."id" AS "Abuse.VideoCommentAbuse.id",
118 "Abuse->VideoCommentAbuse->VideoComment"."id" AS "Abuse.VideoCommentAbuse.VideoComment.id",
119 "Abuse->VideoCommentAbuse->VideoComment"."originCommentId" AS "Abuse.VideoCommentAbuse.VideoComment.originCommentId",
120 "Abuse->VideoCommentAbuse->VideoComment->Video"."id" AS "Abuse.VideoCommentAbuse.VideoComment.Video.id",
121 "Abuse->VideoCommentAbuse->VideoComment->Video"."name" AS "Abuse.VideoCommentAbuse.VideoComment.Video.name",
122 "Abuse->VideoCommentAbuse->VideoComment->Video"."uuid" AS "Abuse.VideoCommentAbuse.VideoComment.Video.uuid",
123 "Abuse->FlaggedAccount"."id" AS "Abuse.FlaggedAccount.id",
124 "Abuse->FlaggedAccount"."name" AS "Abuse.FlaggedAccount.name",
125 "Abuse->FlaggedAccount"."description" AS "Abuse.FlaggedAccount.description",
126 "Abuse->FlaggedAccount"."actorId" AS "Abuse.FlaggedAccount.actorId",
127 "Abuse->FlaggedAccount"."userId" AS "Abuse.FlaggedAccount.userId",
128 "Abuse->FlaggedAccount"."applicationId" AS "Abuse.FlaggedAccount.applicationId",
129 "Abuse->FlaggedAccount"."createdAt" AS "Abuse.FlaggedAccount.createdAt",
130 "Abuse->FlaggedAccount"."updatedAt" AS "Abuse.FlaggedAccount.updatedAt",
131 "Abuse->FlaggedAccount->Actor"."id" AS "Abuse.FlaggedAccount.Actor.id",
132 "Abuse->FlaggedAccount->Actor"."preferredUsername" AS "Abuse.FlaggedAccount.Actor.preferredUsername",
133 "Abuse->FlaggedAccount->Actor->Avatars"."id" AS "Abuse.FlaggedAccount.Actor.Avatars.id",
134 "Abuse->FlaggedAccount->Actor->Avatars"."width" AS "Abuse.FlaggedAccount.Actor.Avatars.width",
135 "Abuse->FlaggedAccount->Actor->Avatars"."filename" AS "Abuse.FlaggedAccount.Actor.Avatars.filename",
136 "Abuse->FlaggedAccount->Actor->Server"."id" AS "Abuse.FlaggedAccount.Actor.Server.id",
137 "Abuse->FlaggedAccount->Actor->Server"."host" AS "Abuse.FlaggedAccount.Actor.Server.host",
138 "VideoBlacklist"."id" AS "VideoBlacklist.id",
139 "VideoBlacklist->Video"."id" AS "VideoBlacklist.Video.id",
140 "VideoBlacklist->Video"."uuid" AS "VideoBlacklist.Video.uuid",
141 "VideoBlacklist->Video"."name" AS "VideoBlacklist.Video.name",
142 "VideoImport"."id" AS "VideoImport.id",
143 "VideoImport"."magnetUri" AS "VideoImport.magnetUri",
144 "VideoImport"."targetUrl" AS "VideoImport.targetUrl",
145 "VideoImport"."torrentName" AS "VideoImport.torrentName",
146 "VideoImport->Video"."id" AS "VideoImport.Video.id",
147 "VideoImport->Video"."uuid" AS "VideoImport.Video.uuid",
148 "VideoImport->Video"."name" AS "VideoImport.Video.name",
149 "Plugin"."id" AS "Plugin.id",
150 "Plugin"."name" AS "Plugin.name",
151 "Plugin"."type" AS "Plugin.type",
152 "Plugin"."latestVersion" AS "Plugin.latestVersion",
153 "Application"."id" AS "Application.id",
154 "Application"."latestPeerTubeVersion" AS "Application.latestPeerTubeVersion",
155 "ActorFollow"."id" AS "ActorFollow.id",
156 "ActorFollow"."state" AS "ActorFollow.state",
157 "ActorFollow->ActorFollower"."id" AS "ActorFollow.ActorFollower.id",
158 "ActorFollow->ActorFollower"."preferredUsername" AS "ActorFollow.ActorFollower.preferredUsername",
159 "ActorFollow->ActorFollower->Account"."id" AS "ActorFollow.ActorFollower.Account.id",
160 "ActorFollow->ActorFollower->Account"."name" AS "ActorFollow.ActorFollower.Account.name",
161 "ActorFollow->ActorFollower->Avatars"."id" AS "ActorFollow.ActorFollower.Avatars.id",
162 "ActorFollow->ActorFollower->Avatars"."width" AS "ActorFollow.ActorFollower.Avatars.width",
163 "ActorFollow->ActorFollower->Avatars"."filename" AS "ActorFollow.ActorFollower.Avatars.filename",
164 "ActorFollow->ActorFollower->Server"."id" AS "ActorFollow.ActorFollower.Server.id",
165 "ActorFollow->ActorFollower->Server"."host" AS "ActorFollow.ActorFollower.Server.host",
166 "ActorFollow->ActorFollowing"."id" AS "ActorFollow.ActorFollowing.id",
167 "ActorFollow->ActorFollowing"."preferredUsername" AS "ActorFollow.ActorFollowing.preferredUsername",
168 "ActorFollow->ActorFollowing"."type" AS "ActorFollow.ActorFollowing.type",
169 "ActorFollow->ActorFollowing->VideoChannel"."id" AS "ActorFollow.ActorFollowing.VideoChannel.id",
170 "ActorFollow->ActorFollowing->VideoChannel"."name" AS "ActorFollow.ActorFollowing.VideoChannel.name",
171 "ActorFollow->ActorFollowing->Account"."id" AS "ActorFollow.ActorFollowing.Account.id",
172 "ActorFollow->ActorFollowing->Account"."name" AS "ActorFollow.ActorFollowing.Account.name",
173 "ActorFollow->ActorFollowing->Server"."id" AS "ActorFollow.ActorFollowing.Server.id",
174 "ActorFollow->ActorFollowing->Server"."host" AS "ActorFollow.ActorFollowing.Server.host",
175 "Account"."id" AS "Account.id",
176 "Account"."name" AS "Account.name",
177 "Account->Actor"."id" AS "Account.Actor.id",
178 "Account->Actor"."preferredUsername" AS "Account.Actor.preferredUsername",
179 "Account->Actor->Avatars"."id" AS "Account.Actor.Avatars.id",
180 "Account->Actor->Avatars"."width" AS "Account.Actor.Avatars.width",
181 "Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename",
182 "Account->Actor->Server"."id" AS "Account.Actor.Server.id",
183 "Account->Actor->Server"."host" AS "Account.Actor.Server.host"`
184 }
185
186 private getJoins () {
187 return `
188 LEFT JOIN (
189 "video" AS "Video"
190 INNER JOIN "videoChannel" AS "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id"
191 INNER JOIN "actor" AS "Video->VideoChannel->Actor" ON "Video->VideoChannel"."actorId" = "Video->VideoChannel->Actor"."id"
192 LEFT JOIN "actorImage" AS "Video->VideoChannel->Actor->Avatars"
193 ON "Video->VideoChannel->Actor"."id" = "Video->VideoChannel->Actor->Avatars"."actorId"
194 AND "Video->VideoChannel->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
195 LEFT JOIN "server" AS "Video->VideoChannel->Actor->Server"
196 ON "Video->VideoChannel->Actor"."serverId" = "Video->VideoChannel->Actor->Server"."id"
197 ) ON "UserNotificationModel"."videoId" = "Video"."id"
198
199 LEFT JOIN (
200 "videoComment" AS "VideoComment"
201 INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id"
202 INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id"
203 LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars"
204 ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId"
205 AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
206 LEFT JOIN "server" AS "VideoComment->Account->Actor->Server"
207 ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id"
208 INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id"
209 ) ON "UserNotificationModel"."commentId" = "VideoComment"."id"
210
211 LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id"
212 LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId"
213 LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id"
214 LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId"
215 LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
216 ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
217 LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
218 ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
219 LEFT JOIN (
220 "account" AS "Abuse->FlaggedAccount"
221 INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id"
222 LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars"
223 ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId"
224 AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
225 LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server"
226 ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id"
227 ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
228
229 LEFT JOIN (
230 "videoBlacklist" AS "VideoBlacklist"
231 INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
232 ) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
233
234 LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id"
235 LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
236
237 LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id"
238
239 LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id"
240
241 LEFT JOIN (
242 "actorFollow" AS "ActorFollow"
243 INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id"
244 INNER JOIN "account" AS "ActorFollow->ActorFollower->Account"
245 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId"
246 LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars"
247 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId"
248 AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR}
249 LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server"
250 ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id"
251 INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
252 LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
253 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId"
254 LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
255 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId"
256 LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server"
257 ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id"
258 ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
259
260 LEFT JOIN (
261 "account" AS "Account"
262 INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
263 LEFT JOIN "actorImage" AS "Account->Actor->Avatars"
264 ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId"
265 AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
266 LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"
267 ) ON "UserNotificationModel"."accountId" = "Account"."id"`
268 }
269}
diff --git a/server/models/user/user-notification.ts b/server/models/user/user-notification.ts
index edad10a55..eca127e7e 100644
--- a/server/models/user/user-notification.ts
+++ b/server/models/user/user-notification.ts
@@ -1,5 +1,6 @@
1import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize' 1import { ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { getBiggestActorImage } from '@server/lib/actor-image'
3import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user' 4import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user'
4import { uuidToShort } from '@shared/extra-utils' 5import { uuidToShort } from '@shared/extra-utils'
5import { UserNotification, UserNotificationType } from '@shared/models' 6import { UserNotification, UserNotificationType } from '@shared/models'
@@ -7,207 +8,18 @@ import { AttributesOnly } from '@shared/typescript-utils'
7import { isBooleanValid } from '../../helpers/custom-validators/misc' 8import { isBooleanValid } from '../../helpers/custom-validators/misc'
8import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications' 9import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
9import { AbuseModel } from '../abuse/abuse' 10import { AbuseModel } from '../abuse/abuse'
10import { VideoAbuseModel } from '../abuse/video-abuse'
11import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
12import { AccountModel } from '../account/account' 11import { AccountModel } from '../account/account'
13import { ActorModel } from '../actor/actor'
14import { ActorFollowModel } from '../actor/actor-follow' 12import { ActorFollowModel } from '../actor/actor-follow'
15import { ActorImageModel } from '../actor/actor-image'
16import { ApplicationModel } from '../application/application' 13import { ApplicationModel } from '../application/application'
17import { PluginModel } from '../server/plugin' 14import { PluginModel } from '../server/plugin'
18import { ServerModel } from '../server/server' 15import { throwIfNotValid } from '../utils'
19import { getSort, throwIfNotValid } from '../utils'
20import { VideoModel } from '../video/video' 16import { VideoModel } from '../video/video'
21import { VideoBlacklistModel } from '../video/video-blacklist' 17import { VideoBlacklistModel } from '../video/video-blacklist'
22import { VideoChannelModel } from '../video/video-channel'
23import { VideoCommentModel } from '../video/video-comment' 18import { VideoCommentModel } from '../video/video-comment'
24import { VideoImportModel } from '../video/video-import' 19import { VideoImportModel } from '../video/video-import'
20import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder'
25import { UserModel } from './user' 21import { UserModel } from './user'
26 22
27enum ScopeNames {
28 WITH_ALL = 'WITH_ALL'
29}
30
31function buildActorWithAvatarInclude () {
32 return {
33 attributes: [ 'preferredUsername' ],
34 model: ActorModel.unscoped(),
35 required: true,
36 include: [
37 {
38 attributes: [ 'filename' ],
39 as: 'Avatar',
40 model: ActorImageModel.unscoped(),
41 required: false
42 },
43 {
44 attributes: [ 'host' ],
45 model: ServerModel.unscoped(),
46 required: false
47 }
48 ]
49 }
50}
51
52function buildVideoInclude (required: boolean) {
53 return {
54 attributes: [ 'id', 'uuid', 'name' ],
55 model: VideoModel.unscoped(),
56 required
57 }
58}
59
60function buildChannelInclude (required: boolean, withActor = false) {
61 return {
62 required,
63 attributes: [ 'id', 'name' ],
64 model: VideoChannelModel.unscoped(),
65 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
66 }
67}
68
69function buildAccountInclude (required: boolean, withActor = false) {
70 return {
71 required,
72 attributes: [ 'id', 'name' ],
73 model: AccountModel.unscoped(),
74 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
75 }
76}
77
78@Scopes(() => ({
79 [ScopeNames.WITH_ALL]: {
80 include: [
81 Object.assign(buildVideoInclude(false), {
82 include: [ buildChannelInclude(true, true) ]
83 }),
84
85 {
86 attributes: [ 'id', 'originCommentId' ],
87 model: VideoCommentModel.unscoped(),
88 required: false,
89 include: [
90 buildAccountInclude(true, true),
91 buildVideoInclude(true)
92 ]
93 },
94
95 {
96 attributes: [ 'id', 'state' ],
97 model: AbuseModel.unscoped(),
98 required: false,
99 include: [
100 {
101 attributes: [ 'id' ],
102 model: VideoAbuseModel.unscoped(),
103 required: false,
104 include: [ buildVideoInclude(false) ]
105 },
106 {
107 attributes: [ 'id' ],
108 model: VideoCommentAbuseModel.unscoped(),
109 required: false,
110 include: [
111 {
112 attributes: [ 'id', 'originCommentId' ],
113 model: VideoCommentModel.unscoped(),
114 required: false,
115 include: [
116 {
117 attributes: [ 'id', 'name', 'uuid' ],
118 model: VideoModel.unscoped(),
119 required: false
120 }
121 ]
122 }
123 ]
124 },
125 {
126 model: AccountModel,
127 as: 'FlaggedAccount',
128 required: false,
129 include: [ buildActorWithAvatarInclude() ]
130 }
131 ]
132 },
133
134 {
135 attributes: [ 'id' ],
136 model: VideoBlacklistModel.unscoped(),
137 required: false,
138 include: [ buildVideoInclude(true) ]
139 },
140
141 {
142 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
143 model: VideoImportModel.unscoped(),
144 required: false,
145 include: [ buildVideoInclude(false) ]
146 },
147
148 {
149 attributes: [ 'id', 'name', 'type', 'latestVersion' ],
150 model: PluginModel.unscoped(),
151 required: false
152 },
153
154 {
155 attributes: [ 'id', 'latestPeerTubeVersion' ],
156 model: ApplicationModel.unscoped(),
157 required: false
158 },
159
160 {
161 attributes: [ 'id', 'state' ],
162 model: ActorFollowModel.unscoped(),
163 required: false,
164 include: [
165 {
166 attributes: [ 'preferredUsername' ],
167 model: ActorModel.unscoped(),
168 required: true,
169 as: 'ActorFollower',
170 include: [
171 {
172 attributes: [ 'id', 'name' ],
173 model: AccountModel.unscoped(),
174 required: true
175 },
176 {
177 attributes: [ 'filename' ],
178 as: 'Avatar',
179 model: ActorImageModel.unscoped(),
180 required: false
181 },
182 {
183 attributes: [ 'host' ],
184 model: ServerModel.unscoped(),
185 required: false
186 }
187 ]
188 },
189 {
190 attributes: [ 'preferredUsername', 'type' ],
191 model: ActorModel.unscoped(),
192 required: true,
193 as: 'ActorFollowing',
194 include: [
195 buildChannelInclude(false),
196 buildAccountInclude(false),
197 {
198 attributes: [ 'host' ],
199 model: ServerModel.unscoped(),
200 required: false
201 }
202 ]
203 }
204 ]
205 },
206
207 buildAccountInclude(false, true)
208 ]
209 }
210}))
211@Table({ 23@Table({
212 tableName: 'userNotification', 24 tableName: 'userNotification',
213 indexes: [ 25 indexes: [
@@ -342,7 +154,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
342 }, 154 },
343 onDelete: 'cascade' 155 onDelete: 'cascade'
344 }) 156 })
345 Comment: VideoCommentModel 157 VideoComment: VideoCommentModel
346 158
347 @ForeignKey(() => AbuseModel) 159 @ForeignKey(() => AbuseModel)
348 @Column 160 @Column
@@ -431,11 +243,14 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
431 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) { 243 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
432 const where = { userId } 244 const where = { userId }
433 245
434 const query: FindOptions = { 246 const query = {
247 userId,
248 unread,
435 offset: start, 249 offset: start,
436 limit: count, 250 limit: count,
437 order: getSort(sort), 251 sort,
438 where 252 where,
253 sequelize: this.sequelize
439 } 254 }
440 255
441 if (unread !== undefined) query.where['read'] = !unread 256 if (unread !== undefined) query.where['read'] = !unread
@@ -445,8 +260,8 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
445 .then(count => count || 0), 260 .then(count => count || 0),
446 261
447 count === 0 262 count === 0
448 ? [] 263 ? [] as UserNotificationModelForApi[]
449 : UserNotificationModel.scope(ScopeNames.WITH_ALL).findAll(query) 264 : new UserNotificationListQueryBuilder(query).listNotifications()
450 ]).then(([ total, data ]) => ({ total, data })) 265 ]).then(([ total, data ]) => ({ total, data }))
451 } 266 }
452 267
@@ -524,25 +339,31 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
524 339
525 toFormattedJSON (this: UserNotificationModelForApi): UserNotification { 340 toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
526 const video = this.Video 341 const video = this.Video
527 ? Object.assign(this.formatVideo(this.Video), { channel: this.formatActor(this.Video.VideoChannel) }) 342 ? {
343 ...this.formatVideo(this.Video),
344
345 channel: this.formatActor(this.Video.VideoChannel)
346 }
528 : undefined 347 : undefined
529 348
530 const videoImport = this.VideoImport 349 const videoImport = this.VideoImport
531 ? { 350 ? {
532 id: this.VideoImport.id, 351 id: this.VideoImport.id,
533 video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined, 352 video: this.VideoImport.Video
353 ? this.formatVideo(this.VideoImport.Video)
354 : undefined,
534 torrentName: this.VideoImport.torrentName, 355 torrentName: this.VideoImport.torrentName,
535 magnetUri: this.VideoImport.magnetUri, 356 magnetUri: this.VideoImport.magnetUri,
536 targetUrl: this.VideoImport.targetUrl 357 targetUrl: this.VideoImport.targetUrl
537 } 358 }
538 : undefined 359 : undefined
539 360
540 const comment = this.Comment 361 const comment = this.VideoComment
541 ? { 362 ? {
542 id: this.Comment.id, 363 id: this.VideoComment.id,
543 threadId: this.Comment.getThreadId(), 364 threadId: this.VideoComment.getThreadId(),
544 account: this.formatActor(this.Comment.Account), 365 account: this.formatActor(this.VideoComment.Account),
545 video: this.formatVideo(this.Comment.Video) 366 video: this.formatVideo(this.VideoComment.Video)
546 } 367 }
547 : undefined 368 : undefined
548 369
@@ -570,8 +391,9 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
570 id: this.ActorFollow.ActorFollower.Account.id, 391 id: this.ActorFollow.ActorFollower.Account.id,
571 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(), 392 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
572 name: this.ActorFollow.ActorFollower.preferredUsername, 393 name: this.ActorFollow.ActorFollower.preferredUsername,
573 avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getStaticPath() } : undefined, 394 host: this.ActorFollow.ActorFollower.getHost(),
574 host: this.ActorFollow.ActorFollower.getHost() 395
396 ...this.formatAvatars(this.ActorFollow.ActorFollower.Avatars)
575 }, 397 },
576 following: { 398 following: {
577 type: actorFollowingType[this.ActorFollow.ActorFollowing.type], 399 type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
@@ -612,7 +434,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
612 } 434 }
613 } 435 }
614 436
615 formatVideo (this: UserNotificationModelForApi, video: UserNotificationIncludes.VideoInclude) { 437 formatVideo (video: UserNotificationIncludes.VideoInclude) {
616 return { 438 return {
617 id: video.id, 439 id: video.id,
618 uuid: video.uuid, 440 uuid: video.uuid,
@@ -621,7 +443,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
621 } 443 }
622 } 444 }
623 445
624 formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) { 446 formatAbuse (abuse: UserNotificationIncludes.AbuseInclude) {
625 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment 447 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
626 ? { 448 ? {
627 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(), 449 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
@@ -637,9 +459,13 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
637 } 459 }
638 : undefined 460 : undefined
639 461
640 const videoAbuse = abuse.VideoAbuse?.Video ? this.formatVideo(abuse.VideoAbuse.Video) : undefined 462 const videoAbuse = abuse.VideoAbuse?.Video
463 ? this.formatVideo(abuse.VideoAbuse.Video)
464 : undefined
641 465
642 const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount) ? this.formatActor(abuse.FlaggedAccount) : undefined 466 const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount)
467 ? this.formatActor(abuse.FlaggedAccount)
468 : undefined
643 469
644 return { 470 return {
645 id: abuse.id, 471 id: abuse.id,
@@ -651,19 +477,32 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
651 } 477 }
652 478
653 formatActor ( 479 formatActor (
654 this: UserNotificationModelForApi,
655 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor 480 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
656 ) { 481 ) {
657 const avatar = accountOrChannel.Actor.Avatar
658 ? { path: accountOrChannel.Actor.Avatar.getStaticPath() }
659 : undefined
660
661 return { 482 return {
662 id: accountOrChannel.id, 483 id: accountOrChannel.id,
663 displayName: accountOrChannel.getDisplayName(), 484 displayName: accountOrChannel.getDisplayName(),
664 name: accountOrChannel.Actor.preferredUsername, 485 name: accountOrChannel.Actor.preferredUsername,
665 host: accountOrChannel.Actor.getHost(), 486 host: accountOrChannel.Actor.getHost(),
666 avatar 487
488 ...this.formatAvatars(accountOrChannel.Actor.Avatars)
489 }
490 }
491
492 formatAvatars (avatars: UserNotificationIncludes.ActorImageInclude[]) {
493 if (!avatars || avatars.length === 0) return { avatar: undefined, avatars: [] }
494
495 return {
496 avatar: this.formatAvatar(getBiggestActorImage(avatars)),
497
498 avatars: avatars.map(a => this.formatAvatar(a))
499 }
500 }
501
502 formatAvatar (a: UserNotificationIncludes.ActorImageInclude) {
503 return {
504 path: a.getStaticPath(),
505 width: a.width
667 } 506 }
668 } 507 }
669} 508}
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
index ad8ce08cb..bcf56dfa1 100644
--- a/server/models/user/user.ts
+++ b/server/models/user/user.ts
@@ -106,7 +106,7 @@ enum ScopeNames {
106 include: [ 106 include: [
107 { 107 {
108 model: ActorImageModel, 108 model: ActorImageModel,
109 as: 'Banner', 109 as: 'Banners',
110 required: false 110 required: false
111 } 111 }
112 ] 112 ]
@@ -495,13 +495,10 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
495 where 495 where
496 } 496 }
497 497
498 return UserModel.findAndCountAll(query) 498 return Promise.all([
499 .then(({ rows, count }) => { 499 UserModel.unscoped().count(query),
500 return { 500 UserModel.findAll(query)
501 data: rows, 501 ]).then(([ total, data ]) => ({ total, data }))
502 total: count
503 }
504 })
505 } 502 }
506 503
507 static listWithRight (right: UserRight): Promise<MUserDefault[]> { 504 static listWithRight (right: UserRight): Promise<MUserDefault[]> {
diff --git a/server/models/utils.ts b/server/models/utils.ts
index 66b653e3d..70bfbdb8b 100644
--- a/server/models/utils.ts
+++ b/server/models/utils.ts
@@ -181,7 +181,7 @@ function buildServerIdsFollowedBy (actorId: any) {
181 'SELECT "actor"."serverId" FROM "actorFollow" ' + 181 'SELECT "actor"."serverId" FROM "actorFollow" ' +
182 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' + 182 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
183 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + 183 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
184 ')' 184 ')'
185} 185}
186 186
187function buildWhereIdOrUUID (id: number | string) { 187function buildWhereIdOrUUID (id: number | string) {
diff --git a/server/models/video/sql/video/index.ts b/server/models/video/sql/video/index.ts
new file mode 100644
index 000000000..e9132d5e1
--- /dev/null
+++ b/server/models/video/sql/video/index.ts
@@ -0,0 +1,3 @@
1export * from './video-model-get-query-builder'
2export * from './videos-id-list-query-builder'
3export * from './videos-model-list-query-builder'
diff --git a/server/models/video/sql/shared/abstract-run-query.ts b/server/models/video/sql/video/shared/abstract-run-query.ts
index 8e7a7642d..8e7a7642d 100644
--- a/server/models/video/sql/shared/abstract-run-query.ts
+++ b/server/models/video/sql/video/shared/abstract-run-query.ts
diff --git a/server/models/video/sql/shared/abstract-video-query-builder.ts b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
index a6afb04e4..490e5e6e0 100644
--- a/server/models/video/sql/shared/abstract-video-query-builder.ts
+++ b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
@@ -1,5 +1,6 @@
1import { createSafeIn } from '@server/models/utils' 1import { createSafeIn } from '@server/models/utils'
2import { MUserAccountId } from '@server/types/models' 2import { MUserAccountId } from '@server/types/models'
3import { ActorImageType } from '@shared/models'
3import validator from 'validator' 4import validator from 'validator'
4import { AbstractRunQuery } from './abstract-run-query' 5import { AbstractRunQuery } from './abstract-run-query'
5import { VideoTableAttributes } from './video-table-attributes' 6import { VideoTableAttributes } from './video-table-attributes'
@@ -42,8 +43,9 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
42 ) 43 )
43 44
44 this.addJoin( 45 this.addJoin(
45 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatar" ' + 46 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatars" ' +
46 'ON "VideoChannel->Actor"."avatarId" = "VideoChannel->Actor->Avatar"."id"' 47 'ON "VideoChannel->Actor"."id" = "VideoChannel->Actor->Avatars"."actorId" ' +
48 `AND "VideoChannel->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
47 ) 49 )
48 50
49 this.attributes = { 51 this.attributes = {
@@ -51,7 +53,7 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
51 53
52 ...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()), 54 ...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()),
53 ...this.buildActorInclude('VideoChannel->Actor'), 55 ...this.buildActorInclude('VideoChannel->Actor'),
54 ...this.buildAvatarInclude('VideoChannel->Actor->Avatar'), 56 ...this.buildAvatarInclude('VideoChannel->Actor->Avatars'),
55 ...this.buildServerInclude('VideoChannel->Actor->Server') 57 ...this.buildServerInclude('VideoChannel->Actor->Server')
56 } 58 }
57 } 59 }
@@ -68,8 +70,9 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
68 ) 70 )
69 71
70 this.addJoin( 72 this.addJoin(
71 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatar" ' + 73 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatars" ' +
72 'ON "VideoChannel->Account->Actor"."avatarId" = "VideoChannel->Account->Actor->Avatar"."id"' 74 'ON "VideoChannel->Account"."actorId"= "VideoChannel->Account->Actor->Avatars"."actorId" ' +
75 `AND "VideoChannel->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
73 ) 76 )
74 77
75 this.attributes = { 78 this.attributes = {
@@ -77,7 +80,7 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
77 80
78 ...this.buildAttributesObject('VideoChannel->Account', this.tables.getAccountAttributes()), 81 ...this.buildAttributesObject('VideoChannel->Account', this.tables.getAccountAttributes()),
79 ...this.buildActorInclude('VideoChannel->Account->Actor'), 82 ...this.buildActorInclude('VideoChannel->Account->Actor'),
80 ...this.buildAvatarInclude('VideoChannel->Account->Actor->Avatar'), 83 ...this.buildAvatarInclude('VideoChannel->Account->Actor->Avatars'),
81 ...this.buildServerInclude('VideoChannel->Account->Actor->Server') 84 ...this.buildServerInclude('VideoChannel->Account->Actor->Server')
82 } 85 }
83 } 86 }
diff --git a/server/models/video/sql/shared/video-file-query-builder.ts b/server/models/video/sql/video/shared/video-file-query-builder.ts
index 3eb3dc07d..3eb3dc07d 100644
--- a/server/models/video/sql/shared/video-file-query-builder.ts
+++ b/server/models/video/sql/video/shared/video-file-query-builder.ts
diff --git a/server/models/video/sql/shared/video-model-builder.ts b/server/models/video/sql/video/shared/video-model-builder.ts
index 7751d8e68..b1b47b721 100644
--- a/server/models/video/sql/shared/video-model-builder.ts
+++ b/server/models/video/sql/video/shared/video-model-builder.ts
@@ -9,15 +9,15 @@ import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
9import { TrackerModel } from '@server/models/server/tracker' 9import { TrackerModel } from '@server/models/server/tracker'
10import { UserVideoHistoryModel } from '@server/models/user/user-video-history' 10import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
11import { VideoInclude } from '@shared/models' 11import { VideoInclude } from '@shared/models'
12import { ScheduleVideoUpdateModel } from '../../schedule-video-update' 12import { ScheduleVideoUpdateModel } from '../../../schedule-video-update'
13import { TagModel } from '../../tag' 13import { TagModel } from '../../../tag'
14import { ThumbnailModel } from '../../thumbnail' 14import { ThumbnailModel } from '../../../thumbnail'
15import { VideoModel } from '../../video' 15import { VideoModel } from '../../../video'
16import { VideoBlacklistModel } from '../../video-blacklist' 16import { VideoBlacklistModel } from '../../../video-blacklist'
17import { VideoChannelModel } from '../../video-channel' 17import { VideoChannelModel } from '../../../video-channel'
18import { VideoFileModel } from '../../video-file' 18import { VideoFileModel } from '../../../video-file'
19import { VideoLiveModel } from '../../video-live' 19import { VideoLiveModel } from '../../../video-live'
20import { VideoStreamingPlaylistModel } from '../../video-streaming-playlist' 20import { VideoStreamingPlaylistModel } from '../../../video-streaming-playlist'
21import { VideoTableAttributes } from './video-table-attributes' 21import { VideoTableAttributes } from './video-table-attributes'
22 22
23type SQLRow = { [id: string]: string | number } 23type SQLRow = { [id: string]: string | number }
@@ -34,6 +34,7 @@ export class VideoModelBuilder {
34 private videoFileMemo: { [ id: number ]: VideoFileModel } 34 private videoFileMemo: { [ id: number ]: VideoFileModel }
35 35
36 private thumbnailsDone: Set<any> 36 private thumbnailsDone: Set<any>
37 private actorImagesDone: Set<any>
37 private historyDone: Set<any> 38 private historyDone: Set<any>
38 private blacklistDone: Set<any> 39 private blacklistDone: Set<any>
39 private accountBlocklistDone: Set<any> 40 private accountBlocklistDone: Set<any>
@@ -69,11 +70,21 @@ export class VideoModelBuilder {
69 for (const row of rows) { 70 for (const row of rows) {
70 this.buildVideoAndAccount(row) 71 this.buildVideoAndAccount(row)
71 72
72 const videoModel = this.videosMemo[row.id] 73 const videoModel = this.videosMemo[row.id as number]
73 74
74 this.setUserHistory(row, videoModel) 75 this.setUserHistory(row, videoModel)
75 this.addThumbnail(row, videoModel) 76 this.addThumbnail(row, videoModel)
76 77
78 const channelActor = videoModel.VideoChannel?.Actor
79 if (channelActor) {
80 this.addActorAvatar(row, 'VideoChannel.Actor', channelActor)
81 }
82
83 const accountActor = videoModel.VideoChannel?.Account?.Actor
84 if (accountActor) {
85 this.addActorAvatar(row, 'VideoChannel.Account.Actor', accountActor)
86 }
87
77 if (!rowsWebTorrentFiles) { 88 if (!rowsWebTorrentFiles) {
78 this.addWebTorrentFile(row, videoModel) 89 this.addWebTorrentFile(row, videoModel)
79 } 90 }
@@ -113,6 +124,7 @@ export class VideoModelBuilder {
113 this.videoFileMemo = {} 124 this.videoFileMemo = {}
114 125
115 this.thumbnailsDone = new Set() 126 this.thumbnailsDone = new Set()
127 this.actorImagesDone = new Set()
116 this.historyDone = new Set() 128 this.historyDone = new Set()
117 this.blacklistDone = new Set() 129 this.blacklistDone = new Set()
118 this.liveDone = new Set() 130 this.liveDone = new Set()
@@ -195,13 +207,8 @@ export class VideoModelBuilder {
195 207
196 private buildActor (row: SQLRow, prefix: string) { 208 private buildActor (row: SQLRow, prefix: string) {
197 const actorPrefix = `${prefix}.Actor` 209 const actorPrefix = `${prefix}.Actor`
198 const avatarPrefix = `${actorPrefix}.Avatar`
199 const serverPrefix = `${actorPrefix}.Server` 210 const serverPrefix = `${actorPrefix}.Server`
200 211
201 const avatarModel = row[`${avatarPrefix}.id`] !== null
202 ? new ActorImageModel(this.grab(row, this.tables.getAvatarAttributes(), avatarPrefix), this.buildOpts)
203 : null
204
205 const serverModel = row[`${serverPrefix}.id`] !== null 212 const serverModel = row[`${serverPrefix}.id`] !== null
206 ? new ServerModel(this.grab(row, this.tables.getServerAttributes(), serverPrefix), this.buildOpts) 213 ? new ServerModel(this.grab(row, this.tables.getServerAttributes(), serverPrefix), this.buildOpts)
207 : null 214 : null
@@ -209,8 +216,8 @@ export class VideoModelBuilder {
209 if (serverModel) serverModel.BlockedBy = [] 216 if (serverModel) serverModel.BlockedBy = []
210 217
211 const actorModel = new ActorModel(this.grab(row, this.tables.getActorAttributes(), actorPrefix), this.buildOpts) 218 const actorModel = new ActorModel(this.grab(row, this.tables.getActorAttributes(), actorPrefix), this.buildOpts)
212 actorModel.Avatar = avatarModel
213 actorModel.Server = serverModel 219 actorModel.Server = serverModel
220 actorModel.Avatars = []
214 221
215 return actorModel 222 return actorModel
216 } 223 }
@@ -226,6 +233,18 @@ export class VideoModelBuilder {
226 this.historyDone.add(id) 233 this.historyDone.add(id)
227 } 234 }
228 235
236 private addActorAvatar (row: SQLRow, actorPrefix: string, actor: ActorModel) {
237 const avatarPrefix = `${actorPrefix}.Avatar`
238 const id = row[`${avatarPrefix}.id`]
239 if (!id || this.actorImagesDone.has(id)) return
240
241 const attributes = this.grab(row, this.tables.getAvatarAttributes(), avatarPrefix)
242 const avatarModel = new ActorImageModel(attributes, this.buildOpts)
243 actor.Avatars.push(avatarModel)
244
245 this.actorImagesDone.add(id)
246 }
247
229 private addThumbnail (row: SQLRow, videoModel: VideoModel) { 248 private addThumbnail (row: SQLRow, videoModel: VideoModel) {
230 const id = row['Thumbnails.id'] 249 const id = row['Thumbnails.id']
231 if (!id || this.thumbnailsDone.has(id)) return 250 if (!id || this.thumbnailsDone.has(id)) return
diff --git a/server/models/video/sql/shared/video-table-attributes.ts b/server/models/video/sql/video/shared/video-table-attributes.ts
index 8a8d2073a..df2ed3fb0 100644
--- a/server/models/video/sql/shared/video-table-attributes.ts
+++ b/server/models/video/sql/video/shared/video-table-attributes.ts
@@ -186,8 +186,7 @@ export class VideoTableAttributes {
186 'id', 186 'id',
187 'preferredUsername', 187 'preferredUsername',
188 'url', 188 'url',
189 'serverId', 189 'serverId'
190 'avatarId'
191 ] 190 ]
192 191
193 if (this.mode === 'get') { 192 if (this.mode === 'get') {
@@ -212,6 +211,7 @@ export class VideoTableAttributes {
212 getAvatarAttributes () { 211 getAvatarAttributes () {
213 let attributeKeys = [ 212 let attributeKeys = [
214 'id', 213 'id',
214 'width',
215 'filename', 215 'filename',
216 'type', 216 'type',
217 'fileUrl', 217 'fileUrl',
diff --git a/server/models/video/sql/video-model-get-query-builder.ts b/server/models/video/sql/video/video-model-get-query-builder.ts
index a65c96097..a65c96097 100644
--- a/server/models/video/sql/video-model-get-query-builder.ts
+++ b/server/models/video/sql/video/video-model-get-query-builder.ts
diff --git a/server/models/video/sql/videos-id-list-query-builder.ts b/server/models/video/sql/video/videos-id-list-query-builder.ts
index 098e15359..098e15359 100644
--- a/server/models/video/sql/videos-id-list-query-builder.ts
+++ b/server/models/video/sql/video/videos-id-list-query-builder.ts
diff --git a/server/models/video/sql/videos-model-list-query-builder.ts b/server/models/video/sql/video/videos-model-list-query-builder.ts
index b15b29ec3..b15b29ec3 100644
--- a/server/models/video/sql/videos-model-list-query-builder.ts
+++ b/server/models/video/sql/video/videos-model-list-query-builder.ts
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 2c6669bcb..410fd6d3f 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -31,6 +31,7 @@ import {
31import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' 31import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
32import { sendDeleteActor } from '../../lib/activitypub/send' 32import { sendDeleteActor } from '../../lib/activitypub/send'
33import { 33import {
34 MChannel,
34 MChannelActor, 35 MChannelActor,
35 MChannelAP, 36 MChannelAP,
36 MChannelBannerAccountDefault, 37 MChannelBannerAccountDefault,
@@ -62,6 +63,7 @@ type AvailableForListOptions = {
62 search?: string 63 search?: string
63 host?: string 64 host?: string
64 handles?: string[] 65 handles?: string[]
66 forCount?: boolean
65} 67}
66 68
67type AvailableWithStatsOptions = { 69type AvailableWithStatsOptions = {
@@ -116,70 +118,91 @@ export type SummaryOptions = {
116 }) 118 })
117 } 119 }
118 120
119 let rootWhere: WhereOptions 121 if (Array.isArray(options.handles) && options.handles.length !== 0) {
120 if (options.handles) { 122 const or: string[] = []
121 const or: WhereOptions[] = []
122 123
123 for (const handle of options.handles || []) { 124 for (const handle of options.handles || []) {
124 const [ preferredUsername, host ] = handle.split('@') 125 const [ preferredUsername, host ] = handle.split('@')
125 126
126 if (!host || host === WEBSERVER.HOST) { 127 if (!host || host === WEBSERVER.HOST) {
127 or.push({ 128 or.push(`("preferredUsername" = ${VideoChannelModel.sequelize.escape(preferredUsername)} AND "serverId" IS NULL)`)
128 '$Actor.preferredUsername$': preferredUsername,
129 '$Actor.serverId$': null
130 })
131 } else { 129 } else {
132 or.push({ 130 or.push(
133 '$Actor.preferredUsername$': preferredUsername, 131 `(` +
134 '$Actor.Server.host$': host 132 `"preferredUsername" = ${VideoChannelModel.sequelize.escape(preferredUsername)} ` +
135 }) 133 `AND "host" = ${VideoChannelModel.sequelize.escape(host)}` +
134 `)`
135 )
136 } 136 }
137 } 137 }
138 138
139 rootWhere = { 139 whereActorAnd.push({
140 [Op.or]: or 140 id: {
141 } 141 [Op.in]: literal(`(SELECT "actor".id FROM actor LEFT JOIN server on server.id = actor."serverId" WHERE ${or.join(' OR ')})`)
142 }
143 })
144 }
145
146 const channelInclude: Includeable[] = []
147 const accountInclude: Includeable[] = []
148
149 if (options.forCount !== true) {
150 accountInclude.push({
151 model: ServerModel,
152 required: false
153 })
154
155 accountInclude.push({
156 model: ActorImageModel,
157 as: 'Avatars',
158 required: false
159 })
160
161 channelInclude.push({
162 model: ActorImageModel,
163 as: 'Avatars',
164 required: false
165 })
166
167 channelInclude.push({
168 model: ActorImageModel,
169 as: 'Banners',
170 required: false
171 })
172 }
173
174 if (options.forCount !== true || serverRequired) {
175 channelInclude.push({
176 model: ServerModel,
177 duplicating: false,
178 required: serverRequired,
179 where: whereServer
180 })
142 } 181 }
143 182
144 return { 183 return {
145 where: rootWhere,
146 include: [ 184 include: [
147 { 185 {
148 attributes: { 186 attributes: {
149 exclude: unusedActorAttributesForAPI 187 exclude: unusedActorAttributesForAPI
150 }, 188 },
151 model: ActorModel, 189 model: ActorModel.unscoped(),
152 where: { 190 where: {
153 [Op.and]: whereActorAnd 191 [Op.and]: whereActorAnd
154 }, 192 },
155 include: [ 193 include: channelInclude
156 {
157 model: ServerModel,
158 required: serverRequired,
159 where: whereServer
160 },
161 {
162 model: ActorImageModel,
163 as: 'Avatar',
164 required: false
165 },
166 {
167 model: ActorImageModel,
168 as: 'Banner',
169 required: false
170 }
171 ]
172 }, 194 },
173 { 195 {
174 model: AccountModel, 196 model: AccountModel.unscoped(),
175 required: true, 197 required: true,
176 include: [ 198 include: [
177 { 199 {
178 attributes: { 200 attributes: {
179 exclude: unusedActorAttributesForAPI 201 exclude: unusedActorAttributesForAPI
180 }, 202 },
181 model: ActorModel, // Default scope includes avatar and server 203 model: ActorModel.unscoped(),
182 required: true 204 required: true,
205 include: accountInclude
183 } 206 }
184 ] 207 ]
185 } 208 }
@@ -189,7 +212,7 @@ export type SummaryOptions = {
189 [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => { 212 [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
190 const include: Includeable[] = [ 213 const include: Includeable[] = [
191 { 214 {
192 attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ], 215 attributes: [ 'id', 'preferredUsername', 'url', 'serverId' ],
193 model: ActorModel.unscoped(), 216 model: ActorModel.unscoped(),
194 required: options.actorRequired ?? true, 217 required: options.actorRequired ?? true,
195 include: [ 218 include: [
@@ -199,8 +222,8 @@ export type SummaryOptions = {
199 required: false 222 required: false
200 }, 223 },
201 { 224 {
202 model: ActorImageModel.unscoped(), 225 model: ActorImageModel,
203 as: 'Avatar', 226 as: 'Avatars',
204 required: false 227 required: false
205 } 228 }
206 ] 229 ]
@@ -245,7 +268,7 @@ export type SummaryOptions = {
245 { 268 {
246 model: ActorImageModel, 269 model: ActorImageModel,
247 required: false, 270 required: false,
248 as: 'Banner' 271 as: 'Banners'
249 } 272 }
250 ] 273 ]
251 } 274 }
@@ -474,14 +497,14 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
474 order: getSort(parameters.sort) 497 order: getSort(parameters.sort)
475 } 498 }
476 499
477 return VideoChannelModel 500 const getScope = (forCount: boolean) => {
478 .scope({ 501 return { method: [ ScopeNames.FOR_API, { actorId, forCount } as AvailableForListOptions ] }
479 method: [ ScopeNames.FOR_API, { actorId } as AvailableForListOptions ] 502 }
480 }) 503
481 .findAndCountAll(query) 504 return Promise.all([
482 .then(({ rows, count }) => { 505 VideoChannelModel.scope(getScope(true)).count(),
483 return { total: count, data: rows } 506 VideoChannelModel.scope(getScope(false)).findAll(query)
484 }) 507 ]).then(([ total, data ]) => ({ total, data }))
485 } 508 }
486 509
487 static searchForApi (options: Pick<AvailableForListOptions, 'actorId' | 'search' | 'host' | 'handles'> & { 510 static searchForApi (options: Pick<AvailableForListOptions, 'actorId' | 'search' | 'host' | 'handles'> & {
@@ -519,14 +542,22 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
519 where 542 where
520 } 543 }
521 544
522 return VideoChannelModel 545 const getScope = (forCount: boolean) => {
523 .scope({ 546 return {
524 method: [ ScopeNames.FOR_API, pick(options, [ 'actorId', 'host', 'handles' ]) as AvailableForListOptions ] 547 method: [
525 }) 548 ScopeNames.FOR_API, {
526 .findAndCountAll(query) 549 ...pick(options, [ 'actorId', 'host', 'handles' ]),
527 .then(({ rows, count }) => { 550
528 return { total: count, data: rows } 551 forCount
529 }) 552 } as AvailableForListOptions
553 ]
554 }
555 }
556
557 return Promise.all([
558 VideoChannelModel.scope(getScope(true)).count(query),
559 VideoChannelModel.scope(getScope(false)).findAll(query)
560 ]).then(([ total, data ]) => ({ total, data }))
530 } 561 }
531 562
532 static listByAccountForAPI (options: { 563 static listByAccountForAPI (options: {
@@ -552,20 +583,26 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
552 } 583 }
553 : null 584 : null
554 585
555 const query = { 586 const getQuery = (forCount: boolean) => {
556 offset: options.start, 587 const accountModel = forCount
557 limit: options.count, 588 ? AccountModel.unscoped()
558 order: getSort(options.sort), 589 : AccountModel
559 include: [ 590
560 { 591 return {
561 model: AccountModel, 592 offset: options.start,
562 where: { 593 limit: options.count,
563 id: options.accountId 594 order: getSort(options.sort),
564 }, 595 include: [
565 required: true 596 {
566 } 597 model: accountModel,
567 ], 598 where: {
568 where 599 id: options.accountId
600 },
601 required: true
602 }
603 ],
604 where
605 }
569 } 606 }
570 607
571 const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR_BANNER ] 608 const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR_BANNER ]
@@ -576,21 +613,19 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
576 }) 613 })
577 } 614 }
578 615
579 return VideoChannelModel 616 return Promise.all([
580 .scope(scopes) 617 VideoChannelModel.scope(scopes).count(getQuery(true)),
581 .findAndCountAll(query) 618 VideoChannelModel.scope(scopes).findAll(getQuery(false))
582 .then(({ rows, count }) => { 619 ]).then(([ total, data ]) => ({ total, data }))
583 return { total: count, data: rows }
584 })
585 } 620 }
586 621
587 static listAllByAccount (accountId: number) { 622 static listAllByAccount (accountId: number): Promise<MChannel[]> {
588 const query = { 623 const query = {
589 limit: CONFIG.VIDEO_CHANNELS.MAX_PER_USER, 624 limit: CONFIG.VIDEO_CHANNELS.MAX_PER_USER,
590 include: [ 625 include: [
591 { 626 {
592 attributes: [], 627 attributes: [],
593 model: AccountModel, 628 model: AccountModel.unscoped(),
594 where: { 629 where: {
595 id: accountId 630 id: accountId
596 }, 631 },
@@ -621,7 +656,7 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
621 { 656 {
622 model: ActorImageModel, 657 model: ActorImageModel,
623 required: false, 658 required: false,
624 as: 'Banner' 659 as: 'Banners'
625 } 660 }
626 ] 661 ]
627 } 662 }
@@ -655,7 +690,7 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
655 { 690 {
656 model: ActorImageModel, 691 model: ActorImageModel,
657 required: false, 692 required: false,
658 as: 'Banner' 693 as: 'Banners'
659 } 694 }
660 ] 695 ]
661 } 696 }
@@ -685,7 +720,7 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
685 { 720 {
686 model: ActorImageModel, 721 model: ActorImageModel,
687 required: false, 722 required: false,
688 as: 'Banner' 723 as: 'Banners'
689 } 724 }
690 ] 725 ]
691 } 726 }
@@ -706,6 +741,9 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
706 displayName: this.getDisplayName(), 741 displayName: this.getDisplayName(),
707 url: actor.url, 742 url: actor.url,
708 host: actor.host, 743 host: actor.host,
744 avatars: actor.avatars,
745
746 // TODO: remove, deprecated in 4.2
709 avatar: actor.avatar 747 avatar: actor.avatar
710 } 748 }
711 } 749 }
@@ -736,9 +774,16 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
736 support: this.support, 774 support: this.support,
737 isLocal: this.Actor.isOwned(), 775 isLocal: this.Actor.isOwned(),
738 updatedAt: this.updatedAt, 776 updatedAt: this.updatedAt,
777
739 ownerAccount: undefined, 778 ownerAccount: undefined,
779
740 videosCount, 780 videosCount,
741 viewsPerDay 781 viewsPerDay,
782
783 avatars: actor.avatars,
784
785 // TODO: remove, deprecated in 4.2
786 avatar: actor.avatar
742 } 787 }
743 788
744 if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON() 789 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 fa77455bc..2d60c6a30 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,5 +1,5 @@
1import { uniq } from 'lodash' 1import { uniq } from 'lodash'
2import { FindAndCountOptions, FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' 2import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
3import { 3import {
4 AllowNull, 4 AllowNull,
5 BelongsTo, 5 BelongsTo,
@@ -16,8 +16,8 @@ import {
16} from 'sequelize-typescript' 16} from 'sequelize-typescript'
17import { getServerActor } from '@server/models/application/application' 17import { getServerActor } from '@server/models/application/application'
18import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' 18import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
19import { AttributesOnly } from '@shared/typescript-utils'
20import { VideoPrivacy } from '@shared/models' 19import { VideoPrivacy } from '@shared/models'
20import { AttributesOnly } from '@shared/typescript-utils'
21import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' 21import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
22import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 22import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
23import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/comment/video-comment.model' 23import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/comment/video-comment.model'
@@ -363,40 +363,43 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
363 Object.assign(whereVideo, searchAttribute(searchVideo, 'name')) 363 Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
364 } 364 }
365 365
366 const query: FindAndCountOptions = { 366 const getQuery = (forCount: boolean) => {
367 offset: start, 367 return {
368 limit: count, 368 offset: start,
369 order: getCommentSort(sort), 369 limit: count,
370 where, 370 order: getCommentSort(sort),
371 include: [ 371 where,
372 { 372 include: [
373 model: AccountModel.unscoped(), 373 {
374 required: true, 374 model: AccountModel.unscoped(),
375 where: whereAccount, 375 required: true,
376 include: [ 376 where: whereAccount,
377 { 377 include: [
378 attributes: { 378 {
379 exclude: unusedActorAttributesForAPI 379 attributes: {
380 }, 380 exclude: unusedActorAttributesForAPI
381 model: ActorModel, // Default scope includes avatar and server 381 },
382 required: true, 382 model: forCount === true
383 where: whereActor 383 ? ActorModel.unscoped() // Default scope includes avatar and server
384 } 384 : ActorModel,
385 ] 385 required: true,
386 }, 386 where: whereActor
387 { 387 }
388 model: VideoModel.unscoped(), 388 ]
389 required: true, 389 },
390 where: whereVideo 390 {
391 } 391 model: VideoModel.unscoped(),
392 ] 392 required: true,
393 where: whereVideo
394 }
395 ]
396 }
393 } 397 }
394 398
395 return VideoCommentModel 399 return Promise.all([
396 .findAndCountAll(query) 400 VideoCommentModel.count(getQuery(true)),
397 .then(({ rows, count }) => { 401 VideoCommentModel.findAll(getQuery(false))
398 return { total: count, data: rows } 402 ]).then(([ total, data ]) => ({ total, data }))
399 })
400 } 403 }
401 404
402 static async listThreadsForApi (parameters: { 405 static async listThreadsForApi (parameters: {
@@ -443,14 +446,20 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
443 } 446 }
444 } 447 }
445 448
446 const scopesList: (string | ScopeOptions)[] = [ 449 const findScopesList: (string | ScopeOptions)[] = [
447 ScopeNames.WITH_ACCOUNT_FOR_API, 450 ScopeNames.WITH_ACCOUNT_FOR_API,
448 { 451 {
449 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ] 452 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
450 } 453 }
451 ] 454 ]
452 455
453 const queryCount = { 456 const countScopesList: ScopeOptions[] = [
457 {
458 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
459 }
460 ]
461
462 const notDeletedQueryCount = {
454 where: { 463 where: {
455 videoId, 464 videoId,
456 deletedAt: null, 465 deletedAt: null,
@@ -459,9 +468,10 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
459 } 468 }
460 469
461 return Promise.all([ 470 return Promise.all([
462 VideoCommentModel.scope(scopesList).findAndCountAll(queryList), 471 VideoCommentModel.scope(findScopesList).findAll(queryList),
463 VideoCommentModel.count(queryCount) 472 VideoCommentModel.scope(countScopesList).count(queryList),
464 ]).then(([ { rows, count }, totalNotDeletedComments ]) => { 473 VideoCommentModel.count(notDeletedQueryCount)
474 ]).then(([ rows, count, totalNotDeletedComments ]) => {
465 return { total: count, data: rows, totalNotDeletedComments } 475 return { total: count, data: rows, totalNotDeletedComments }
466 }) 476 })
467 } 477 }
@@ -512,11 +522,10 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
512 } 522 }
513 ] 523 ]
514 524
515 return VideoCommentModel.scope(scopes) 525 return Promise.all([
516 .findAndCountAll(query) 526 VideoCommentModel.count(query),
517 .then(({ rows, count }) => { 527 VideoCommentModel.scope(scopes).findAll(query)
518 return { total: count, data: rows } 528 ]).then(([ total, data ]) => ({ total, data }))
519 })
520 } 529 }
521 530
522 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> { 531 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
@@ -565,7 +574,10 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
565 transaction: t 574 transaction: t
566 } 575 }
567 576
568 return VideoCommentModel.findAndCountAll<MComment>(query) 577 return Promise.all([
578 VideoCommentModel.count(query),
579 VideoCommentModel.findAll<MComment>(query)
580 ]).then(([ total, data ]) => ({ total, data }))
569 } 581 }
570 582
571 static async listForFeed (parameters: { 583 static async listForFeed (parameters: {
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
index 5d2b230e8..1d8296060 100644
--- a/server/models/video/video-import.ts
+++ b/server/models/video/video-import.ts
@@ -155,13 +155,10 @@ export class VideoImportModel extends Model<Partial<AttributesOnly<VideoImportMo
155 where 155 where
156 } 156 }
157 157
158 return VideoImportModel.findAndCountAll<MVideoImportDefault>(query) 158 return Promise.all([
159 .then(({ rows, count }) => { 159 VideoImportModel.unscoped().count(query),
160 return { 160 VideoImportModel.findAll<MVideoImportDefault>(query)
161 data: rows, 161 ]).then(([ total, data ]) => ({ total, data }))
162 total: count
163 }
164 })
165 } 162 }
166 163
167 getTargetIdentifier () { 164 getTargetIdentifier () {
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index e20e32f8b..4e4160818 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -23,6 +23,7 @@ import {
23 MVideoPlaylistElementVideoUrlPlaylistPrivacy, 23 MVideoPlaylistElementVideoUrlPlaylistPrivacy,
24 MVideoPlaylistVideoThumbnail 24 MVideoPlaylistVideoThumbnail
25} from '@server/types/models/video/video-playlist-element' 25} from '@server/types/models/video/video-playlist-element'
26import { AttributesOnly } from '@shared/typescript-utils'
26import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object' 27import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
27import { VideoPrivacy } from '../../../shared/models/videos' 28import { VideoPrivacy } from '../../../shared/models/videos'
28import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/models/videos/playlist/video-playlist-element.model' 29import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/models/videos/playlist/video-playlist-element.model'
@@ -32,7 +33,6 @@ import { AccountModel } from '../account/account'
32import { getSort, throwIfNotValid } from '../utils' 33import { getSort, throwIfNotValid } from '../utils'
33import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video' 34import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
34import { VideoPlaylistModel } from './video-playlist' 35import { VideoPlaylistModel } from './video-playlist'
35import { AttributesOnly } from '@shared/typescript-utils'
36 36
37@Table({ 37@Table({
38 tableName: 'videoPlaylistElement', 38 tableName: 'videoPlaylistElement',
@@ -208,22 +208,28 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide
208 } 208 }
209 209
210 static listUrlsOfForAP (videoPlaylistId: number, start: number, count: number, t?: Transaction) { 210 static listUrlsOfForAP (videoPlaylistId: number, start: number, count: number, t?: Transaction) {
211 const query = { 211 const getQuery = (forCount: boolean) => {
212 attributes: [ 'url' ], 212 return {
213 offset: start, 213 attributes: forCount
214 limit: count, 214 ? []
215 order: getSort('position'), 215 : [ 'url' ],
216 where: { 216 offset: start,
217 videoPlaylistId 217 limit: count,
218 }, 218 order: getSort('position'),
219 transaction: t 219 where: {
220 videoPlaylistId
221 },
222 transaction: t
223 }
220 } 224 }
221 225
222 return VideoPlaylistElementModel 226 return Promise.all([
223 .findAndCountAll(query) 227 VideoPlaylistElementModel.count(getQuery(true)),
224 .then(({ rows, count }) => { 228 VideoPlaylistElementModel.findAll(getQuery(false))
225 return { total: count, data: rows.map(e => e.url) } 229 ]).then(([ total, rows ]) => ({
226 }) 230 total,
231 data: rows.map(e => e.url)
232 }))
227 } 233 }
228 234
229 static loadFirstElementWithVideoThumbnail (videoPlaylistId: number): Promise<MVideoPlaylistVideoThumbnail> { 235 static loadFirstElementWithVideoThumbnail (videoPlaylistId: number): Promise<MVideoPlaylistVideoThumbnail> {
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index c125db3ff..ae5e237ec 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -1,5 +1,5 @@
1import { join } from 'path' 1import { join } from 'path'
2import { FindOptions, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' 2import { FindOptions, Includeable, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
3import { 3import {
4 AllowNull, 4 AllowNull,
5 BelongsTo, 5 BelongsTo,
@@ -86,6 +86,7 @@ type AvailableForListOptions = {
86 host?: string 86 host?: string
87 uuids?: string[] 87 uuids?: string[]
88 withVideos?: boolean 88 withVideos?: boolean
89 forCount?: boolean
89} 90}
90 91
91function getVideoLengthSelect () { 92function getVideoLengthSelect () {
@@ -239,23 +240,28 @@ function getVideoLengthSelect () {
239 [Op.and]: whereAnd 240 [Op.and]: whereAnd
240 } 241 }
241 242
243 const include: Includeable[] = [
244 {
245 model: AccountModel.scope({
246 method: [ AccountScopeNames.SUMMARY, { whereActor, whereServer, forCount: options.forCount } as SummaryOptions ]
247 }),
248 required: true
249 }
250 ]
251
252 if (options.forCount !== true) {
253 include.push({
254 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
255 required: false
256 })
257 }
258
242 return { 259 return {
243 attributes: { 260 attributes: {
244 include: attributesInclude 261 include: attributesInclude
245 }, 262 },
246 where, 263 where,
247 include: [ 264 include
248 {
249 model: AccountModel.scope({
250 method: [ AccountScopeNames.SUMMARY, { whereActor, whereServer } as SummaryOptions ]
251 }),
252 required: true
253 },
254 {
255 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
256 required: false
257 }
258 ]
259 } as FindOptions 265 } as FindOptions
260 } 266 }
261})) 267}))
@@ -369,12 +375,23 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
369 order: getPlaylistSort(options.sort) 375 order: getPlaylistSort(options.sort)
370 } 376 }
371 377
372 const scopes: (string | ScopeOptions)[] = [ 378 const commonAvailableForListOptions = pick(options, [
379 'type',
380 'followerActorId',
381 'accountId',
382 'videoChannelId',
383 'listMyPlaylists',
384 'search',
385 'host',
386 'uuids'
387 ])
388
389 const scopesFind: (string | ScopeOptions)[] = [
373 { 390 {
374 method: [ 391 method: [
375 ScopeNames.AVAILABLE_FOR_LIST, 392 ScopeNames.AVAILABLE_FOR_LIST,
376 { 393 {
377 ...pick(options, [ 'type', 'followerActorId', 'accountId', 'videoChannelId', 'listMyPlaylists', 'search', 'host', 'uuids' ]), 394 ...commonAvailableForListOptions,
378 395
379 withVideos: options.withVideos || false 396 withVideos: options.withVideos || false
380 } as AvailableForListOptions 397 } as AvailableForListOptions
@@ -384,12 +401,26 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
384 ScopeNames.WITH_THUMBNAIL 401 ScopeNames.WITH_THUMBNAIL
385 ] 402 ]
386 403
387 return VideoPlaylistModel 404 const scopesCount: (string | ScopeOptions)[] = [
388 .scope(scopes) 405 {
389 .findAndCountAll(query) 406 method: [
390 .then(({ rows, count }) => { 407 ScopeNames.AVAILABLE_FOR_LIST,
391 return { total: count, data: rows } 408
392 }) 409 {
410 ...commonAvailableForListOptions,
411
412 withVideos: options.withVideos || false,
413 forCount: true
414 } as AvailableForListOptions
415 ]
416 },
417 ScopeNames.WITH_VIDEOS_LENGTH
418 ]
419
420 return Promise.all([
421 VideoPlaylistModel.scope(scopesCount).count(),
422 VideoPlaylistModel.scope(scopesFind).findAll(query)
423 ]).then(([ count, rows ]) => ({ total: count, data: rows }))
393 } 424 }
394 425
395 static searchForApi (options: Pick<AvailableForListOptions, 'followerActorId' | 'search'| 'host'| 'uuids'> & { 426 static searchForApi (options: Pick<AvailableForListOptions, 'followerActorId' | 'search'| 'host'| 'uuids'> & {
@@ -419,17 +450,24 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
419 Object.assign(where, { videoChannelId: options.channel.id }) 450 Object.assign(where, { videoChannelId: options.channel.id })
420 } 451 }
421 452
422 const query = { 453 const getQuery = (forCount: boolean) => {
423 attributes: [ 'url' ], 454 return {
424 offset: start, 455 attributes: forCount === true
425 limit: count, 456 ? []
426 where 457 : [ 'url' ],
458 offset: start,
459 limit: count,
460 where
461 }
427 } 462 }
428 463
429 return VideoPlaylistModel.findAndCountAll(query) 464 return Promise.all([
430 .then(({ rows, count }) => { 465 VideoPlaylistModel.count(getQuery(true)),
431 return { total: count, data: rows.map(p => p.url) } 466 VideoPlaylistModel.findAll(getQuery(false))
432 }) 467 ]).then(([ total, rows ]) => ({
468 total,
469 data: rows.map(p => p.url)
470 }))
433 } 471 }
434 472
435 static listPlaylistIdsOf (accountId: number, videoIds: number[]): Promise<MVideoPlaylistIdWithElements[]> { 473 static listPlaylistIdsOf (accountId: number, videoIds: number[]): Promise<MVideoPlaylistIdWithElements[]> {
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
index f6659b992..ad95dec6e 100644
--- a/server/models/video/video-share.ts
+++ b/server/models/video/video-share.ts
@@ -183,7 +183,10 @@ export class VideoShareModel extends Model<Partial<AttributesOnly<VideoShareMode
183 transaction: t 183 transaction: t
184 } 184 }
185 185
186 return VideoShareModel.findAndCountAll(query) 186 return Promise.all([
187 VideoShareModel.count(query),
188 VideoShareModel.findAll(query)
189 ]).then(([ total, data ]) => ({ total, data }))
187 } 190 }
188 191
189 static listRemoteShareUrlsOfLocalVideos () { 192 static listRemoteShareUrlsOfLocalVideos () {
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 9111c71b0..5536334eb 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -114,9 +114,13 @@ import {
114 videoModelToFormattedJSON 114 videoModelToFormattedJSON
115} from './formatter/video-format-utils' 115} from './formatter/video-format-utils'
116import { ScheduleVideoUpdateModel } from './schedule-video-update' 116import { ScheduleVideoUpdateModel } from './schedule-video-update'
117import { VideoModelGetQueryBuilder } from './sql/video-model-get-query-builder' 117import {
118import { BuildVideosListQueryOptions, DisplayOnlyForFollowerOptions, VideosIdListQueryBuilder } from './sql/videos-id-list-query-builder' 118 BuildVideosListQueryOptions,
119import { VideosModelListQueryBuilder } from './sql/videos-model-list-query-builder' 119 DisplayOnlyForFollowerOptions,
120 VideoModelGetQueryBuilder,
121 VideosIdListQueryBuilder,
122 VideosModelListQueryBuilder
123} from './sql/video'
120import { TagModel } from './tag' 124import { TagModel } from './tag'
121import { ThumbnailModel } from './thumbnail' 125import { ThumbnailModel } from './thumbnail'
122import { VideoBlacklistModel } from './video-blacklist' 126import { VideoBlacklistModel } from './video-blacklist'
@@ -229,8 +233,8 @@ export type ForAPIOptions = {
229 required: false 233 required: false
230 }, 234 },
231 { 235 {
232 model: ActorImageModel.unscoped(), 236 model: ActorImageModel,
233 as: 'Avatar', 237 as: 'Avatars',
234 required: false 238 required: false
235 } 239 }
236 ] 240 ]
@@ -252,8 +256,8 @@ export type ForAPIOptions = {
252 required: false 256 required: false
253 }, 257 },
254 { 258 {
255 model: ActorImageModel.unscoped(), 259 model: ActorImageModel,
256 as: 'Avatar', 260 as: 'Avatars',
257 required: false 261 required: false
258 } 262 }
259 ] 263 ]
diff --git a/server/tests/api/check-params/video-channels.ts b/server/tests/api/check-params/video-channels.ts
index 1e9732fe9..5c2650fac 100644
--- a/server/tests/api/check-params/video-channels.ts
+++ b/server/tests/api/check-params/video-channels.ts
@@ -228,7 +228,7 @@ describe('Test video channels API validator', function () {
228 }) 228 })
229 }) 229 })
230 230
231 describe('When updating video channel avatar/banner', function () { 231 describe('When updating video channel avatars/banners', function () {
232 const types = [ 'avatar', 'banner' ] 232 const types = [ 'avatar', 'banner' ]
233 let path: string 233 let path: string
234 234
diff --git a/server/tests/api/moderation/abuses.ts b/server/tests/api/moderation/abuses.ts
index 0c3bed3e7..7bf49c7ec 100644
--- a/server/tests/api/moderation/abuses.ts
+++ b/server/tests/api/moderation/abuses.ts
@@ -2,6 +2,7 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { AbuseMessage, AbusePredefinedReasonsString, AbuseState, AdminAbuse, UserAbuse } from '@shared/models'
5import { 6import {
6 AbusesCommand, 7 AbusesCommand,
7 cleanupTests, 8 cleanupTests,
@@ -9,9 +10,10 @@ import {
9 doubleFollow, 10 doubleFollow,
10 PeerTubeServer, 11 PeerTubeServer,
11 setAccessTokensToServers, 12 setAccessTokensToServers,
13 setDefaultAccountAvatar,
14 setDefaultChannelAvatar,
12 waitJobs 15 waitJobs
13} from '@shared/server-commands' 16} from '@shared/server-commands'
14import { AbuseMessage, AbusePredefinedReasonsString, AbuseState, AdminAbuse, UserAbuse } from '@shared/models'
15 17
16const expect = chai.expect 18const expect = chai.expect
17 19
@@ -27,8 +29,9 @@ describe('Test abuses', function () {
27 // Run servers 29 // Run servers
28 servers = await createMultipleServers(2) 30 servers = await createMultipleServers(2)
29 31
30 // Get the access tokens
31 await setAccessTokensToServers(servers) 32 await setAccessTokensToServers(servers)
33 await setDefaultChannelAvatar(servers)
34 await setDefaultAccountAvatar(servers)
32 35
33 // Server 1 and server 2 follow each other 36 // Server 1 and server 2 follow each other
34 await doubleFollow(servers[0], servers[1]) 37 await doubleFollow(servers[0], servers[1])
diff --git a/server/tests/api/moderation/blocklist.ts b/server/tests/api/moderation/blocklist.ts
index b45460bb4..e1344a245 100644
--- a/server/tests/api/moderation/blocklist.ts
+++ b/server/tests/api/moderation/blocklist.ts
@@ -2,6 +2,7 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { UserNotificationType } from '@shared/models'
5import { 6import {
6 BlocklistCommand, 7 BlocklistCommand,
7 cleanupTests, 8 cleanupTests,
@@ -10,9 +11,9 @@ import {
10 doubleFollow, 11 doubleFollow,
11 PeerTubeServer, 12 PeerTubeServer,
12 setAccessTokensToServers, 13 setAccessTokensToServers,
14 setDefaultAccountAvatar,
13 waitJobs 15 waitJobs
14} from '@shared/server-commands' 16} from '@shared/server-commands'
15import { UserNotificationType } from '@shared/models'
16 17
17const expect = chai.expect 18const expect = chai.expect
18 19
@@ -79,6 +80,7 @@ describe('Test blocklist', function () {
79 80
80 servers = await createMultipleServers(3) 81 servers = await createMultipleServers(3)
81 await setAccessTokensToServers(servers) 82 await setAccessTokensToServers(servers)
83 await setDefaultAccountAvatar(servers)
82 84
83 command = servers[0].blocklist 85 command = servers[0].blocklist
84 commentsCommand = servers.map(s => s.comments) 86 commentsCommand = servers.map(s => s.comments)
diff --git a/server/tests/api/moderation/video-blacklist.ts b/server/tests/api/moderation/video-blacklist.ts
index 3e7f2ba33..1790210ff 100644
--- a/server/tests/api/moderation/video-blacklist.ts
+++ b/server/tests/api/moderation/video-blacklist.ts
@@ -13,6 +13,7 @@ import {
13 killallServers, 13 killallServers,
14 PeerTubeServer, 14 PeerTubeServer,
15 setAccessTokensToServers, 15 setAccessTokensToServers,
16 setDefaultChannelAvatar,
16 waitJobs 17 waitJobs
17} from '@shared/server-commands' 18} from '@shared/server-commands'
18 19
@@ -42,6 +43,7 @@ describe('Test video blacklist', function () {
42 43
43 // Server 1 and server 2 follow each other 44 // Server 1 and server 2 follow each other
44 await doubleFollow(servers[0], servers[1]) 45 await doubleFollow(servers[0], servers[1])
46 await setDefaultChannelAvatar(servers[0])
45 47
46 // Upload 2 videos on server 2 48 // Upload 2 videos on server 2
47 await servers[1].videos.upload({ attributes: { name: 'My 1st video', description: 'A video on server 2' } }) 49 await servers[1].videos.upload({ attributes: { name: 'My 1st video', description: 'A video on server 2' } })
diff --git a/server/tests/api/notifications/notifications-api.ts b/server/tests/api/notifications/notifications-api.ts
index ac08449f8..78864c8a0 100644
--- a/server/tests/api/notifications/notifications-api.ts
+++ b/server/tests/api/notifications/notifications-api.ts
@@ -38,6 +38,16 @@ describe('Test notifications API', function () {
38 await waitJobs([ server ]) 38 await waitJobs([ server ])
39 }) 39 })
40 40
41 describe('Notification list & count', function () {
42
43 it('Should correctly list notifications', async function () {
44 const { data, total } = await server.notifications.list({ token: userToken, start: 0, count: 2 })
45
46 expect(data).to.have.lengthOf(2)
47 expect(total).to.equal(10)
48 })
49 })
50
41 describe('Mark as read', function () { 51 describe('Mark as read', function () {
42 52
43 it('Should mark as read some notifications', async function () { 53 it('Should mark as read some notifications', async function () {
diff --git a/server/tests/api/search/search-activitypub-video-channels.ts b/server/tests/api/search/search-activitypub-video-channels.ts
index 2e0abc6ba..5f5322d03 100644
--- a/server/tests/api/search/search-activitypub-video-channels.ts
+++ b/server/tests/api/search/search-activitypub-video-channels.ts
@@ -10,6 +10,8 @@ import {
10 PeerTubeServer, 10 PeerTubeServer,
11 SearchCommand, 11 SearchCommand,
12 setAccessTokensToServers, 12 setAccessTokensToServers,
13 setDefaultAccountAvatar,
14 setDefaultVideoChannel,
13 waitJobs 15 waitJobs
14} from '@shared/server-commands' 16} from '@shared/server-commands'
15 17
@@ -28,6 +30,8 @@ describe('Test ActivityPub video channels search', function () {
28 servers = await createMultipleServers(2) 30 servers = await createMultipleServers(2)
29 31
30 await setAccessTokensToServers(servers) 32 await setAccessTokensToServers(servers)
33 await setDefaultVideoChannel(servers)
34 await setDefaultAccountAvatar(servers)
31 35
32 { 36 {
33 await servers[0].users.create({ username: 'user1_server1', password: 'password' }) 37 await servers[0].users.create({ username: 'user1_server1', password: 'password' })
diff --git a/server/tests/api/search/search-activitypub-video-playlists.ts b/server/tests/api/search/search-activitypub-video-playlists.ts
index d9243ac53..b9a424292 100644
--- a/server/tests/api/search/search-activitypub-video-playlists.ts
+++ b/server/tests/api/search/search-activitypub-video-playlists.ts
@@ -10,6 +10,7 @@ import {
10 PeerTubeServer, 10 PeerTubeServer,
11 SearchCommand, 11 SearchCommand,
12 setAccessTokensToServers, 12 setAccessTokensToServers,
13 setDefaultAccountAvatar,
13 setDefaultVideoChannel, 14 setDefaultVideoChannel,
14 waitJobs 15 waitJobs
15} from '@shared/server-commands' 16} from '@shared/server-commands'
@@ -31,6 +32,7 @@ describe('Test ActivityPub playlists search', function () {
31 32
32 await setAccessTokensToServers(servers) 33 await setAccessTokensToServers(servers)
33 await setDefaultVideoChannel(servers) 34 await setDefaultVideoChannel(servers)
35 await setDefaultAccountAvatar(servers)
34 36
35 { 37 {
36 const video1 = (await servers[0].videos.quickUpload({ name: 'video 1' })).uuid 38 const video1 = (await servers[0].videos.quickUpload({ name: 'video 1' })).uuid
diff --git a/server/tests/api/search/search-activitypub-videos.ts b/server/tests/api/search/search-activitypub-videos.ts
index 60b95ae4c..20249b1f1 100644
--- a/server/tests/api/search/search-activitypub-videos.ts
+++ b/server/tests/api/search/search-activitypub-videos.ts
@@ -10,6 +10,8 @@ import {
10 PeerTubeServer, 10 PeerTubeServer,
11 SearchCommand, 11 SearchCommand,
12 setAccessTokensToServers, 12 setAccessTokensToServers,
13 setDefaultAccountAvatar,
14 setDefaultVideoChannel,
13 waitJobs 15 waitJobs
14} from '@shared/server-commands' 16} from '@shared/server-commands'
15 17
@@ -28,6 +30,8 @@ describe('Test ActivityPub videos search', function () {
28 servers = await createMultipleServers(2) 30 servers = await createMultipleServers(2)
29 31
30 await setAccessTokensToServers(servers) 32 await setAccessTokensToServers(servers)
33 await setDefaultVideoChannel(servers)
34 await setDefaultAccountAvatar(servers)
31 35
32 { 36 {
33 const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video 1 on server 1' } }) 37 const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video 1 on server 1' } })
diff --git a/server/tests/api/search/search-channels.ts b/server/tests/api/search/search-channels.ts
index 8a92def61..0073c71e1 100644
--- a/server/tests/api/search/search-channels.ts
+++ b/server/tests/api/search/search-channels.ts
@@ -2,15 +2,17 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { VideoChannel } from '@shared/models'
5import { 6import {
6 cleanupTests, 7 cleanupTests,
7 createSingleServer, 8 createSingleServer,
8 doubleFollow, 9 doubleFollow,
9 PeerTubeServer, 10 PeerTubeServer,
10 SearchCommand, 11 SearchCommand,
11 setAccessTokensToServers 12 setAccessTokensToServers,
13 setDefaultAccountAvatar,
14 setDefaultChannelAvatar
12} from '@shared/server-commands' 15} from '@shared/server-commands'
13import { VideoChannel } from '@shared/models'
14 16
15const expect = chai.expect 17const expect = chai.expect
16 18
@@ -30,6 +32,8 @@ describe('Test channels search', function () {
30 remoteServer = servers[1] 32 remoteServer = servers[1]
31 33
32 await setAccessTokensToServers([ server, remoteServer ]) 34 await setAccessTokensToServers([ server, remoteServer ])
35 await setDefaultChannelAvatar(server)
36 await setDefaultAccountAvatar(server)
33 37
34 { 38 {
35 await server.users.create({ username: 'user1' }) 39 await server.users.create({ username: 'user1' })
diff --git a/server/tests/api/search/search-index.ts b/server/tests/api/search/search-index.ts
index f84d03345..ae933449f 100644
--- a/server/tests/api/search/search-index.ts
+++ b/server/tests/api/search/search-index.ts
@@ -14,7 +14,7 @@ import {
14 14
15const expect = chai.expect 15const expect = chai.expect
16 16
17describe('Test videos search', function () { 17describe('Test index search', function () {
18 const localVideoName = 'local video' + new Date().toISOString() 18 const localVideoName = 'local video' + new Date().toISOString()
19 19
20 let server: PeerTubeServer = null 20 let server: PeerTubeServer = null
@@ -134,12 +134,16 @@ describe('Test videos search', function () {
134 expect(video.account.host).to.equal('framatube.org') 134 expect(video.account.host).to.equal('framatube.org')
135 expect(video.account.name).to.equal('framasoft') 135 expect(video.account.name).to.equal('framasoft')
136 expect(video.account.url).to.equal('https://framatube.org/accounts/framasoft') 136 expect(video.account.url).to.equal('https://framatube.org/accounts/framasoft')
137 // TODO: remove, deprecated in 4.2
137 expect(video.account.avatar).to.exist 138 expect(video.account.avatar).to.exist
139 expect(video.account.avatars.length).to.equal(1, 'Account should have one avatar image')
138 140
139 expect(video.channel.host).to.equal('framatube.org') 141 expect(video.channel.host).to.equal('framatube.org')
140 expect(video.channel.name).to.equal('joinpeertube') 142 expect(video.channel.name).to.equal('joinpeertube')
141 expect(video.channel.url).to.equal('https://framatube.org/video-channels/joinpeertube') 143 expect(video.channel.url).to.equal('https://framatube.org/video-channels/joinpeertube')
144 // TODO: remove, deprecated in 4.2
142 expect(video.channel.avatar).to.exist 145 expect(video.channel.avatar).to.exist
146 expect(video.channel.avatars.length).to.equal(1, 'Channel should have one avatar image')
143 } 147 }
144 148
145 const baseSearch: VideosSearchQuery = { 149 const baseSearch: VideosSearchQuery = {
@@ -316,13 +320,17 @@ describe('Test videos search', function () {
316 const videoChannel = body.data[0] 320 const videoChannel = body.data[0]
317 expect(videoChannel.url).to.equal('https://framatube.org/video-channels/bf54d359-cfad-4935-9d45-9d6be93f63e8') 321 expect(videoChannel.url).to.equal('https://framatube.org/video-channels/bf54d359-cfad-4935-9d45-9d6be93f63e8')
318 expect(videoChannel.host).to.equal('framatube.org') 322 expect(videoChannel.host).to.equal('framatube.org')
323 // TODO: remove, deprecated in 4.2
319 expect(videoChannel.avatar).to.exist 324 expect(videoChannel.avatar).to.exist
325 expect(videoChannel.avatars.length).to.equal(1, 'Channel should have two avatar images')
320 expect(videoChannel.displayName).to.exist 326 expect(videoChannel.displayName).to.exist
321 327
322 expect(videoChannel.ownerAccount.url).to.equal('https://framatube.org/accounts/framasoft') 328 expect(videoChannel.ownerAccount.url).to.equal('https://framatube.org/accounts/framasoft')
323 expect(videoChannel.ownerAccount.name).to.equal('framasoft') 329 expect(videoChannel.ownerAccount.name).to.equal('framasoft')
324 expect(videoChannel.ownerAccount.host).to.equal('framatube.org') 330 expect(videoChannel.ownerAccount.host).to.equal('framatube.org')
331 // TODO: remove, deprecated in 4.2
325 expect(videoChannel.ownerAccount.avatar).to.exist 332 expect(videoChannel.ownerAccount.avatar).to.exist
333 expect(videoChannel.ownerAccount.avatars.length).to.equal(1, 'Account should have two avatar images')
326 } 334 }
327 335
328 it('Should make a simple search and not have results', async function () { 336 it('Should make a simple search and not have results', async function () {
@@ -388,12 +396,16 @@ describe('Test videos search', function () {
388 expect(videoPlaylist.ownerAccount.url).to.equal('https://peertube2.cpy.re/accounts/chocobozzz') 396 expect(videoPlaylist.ownerAccount.url).to.equal('https://peertube2.cpy.re/accounts/chocobozzz')
389 expect(videoPlaylist.ownerAccount.name).to.equal('chocobozzz') 397 expect(videoPlaylist.ownerAccount.name).to.equal('chocobozzz')
390 expect(videoPlaylist.ownerAccount.host).to.equal('peertube2.cpy.re') 398 expect(videoPlaylist.ownerAccount.host).to.equal('peertube2.cpy.re')
399 // TODO: remove, deprecated in 4.2
391 expect(videoPlaylist.ownerAccount.avatar).to.exist 400 expect(videoPlaylist.ownerAccount.avatar).to.exist
401 expect(videoPlaylist.ownerAccount.avatars.length).to.equal(1, 'Account should have two avatar images')
392 402
393 expect(videoPlaylist.videoChannel.url).to.equal('https://peertube2.cpy.re/video-channels/chocobozzz_channel') 403 expect(videoPlaylist.videoChannel.url).to.equal('https://peertube2.cpy.re/video-channels/chocobozzz_channel')
394 expect(videoPlaylist.videoChannel.name).to.equal('chocobozzz_channel') 404 expect(videoPlaylist.videoChannel.name).to.equal('chocobozzz_channel')
395 expect(videoPlaylist.videoChannel.host).to.equal('peertube2.cpy.re') 405 expect(videoPlaylist.videoChannel.host).to.equal('peertube2.cpy.re')
406 // TODO: remove, deprecated in 4.2
396 expect(videoPlaylist.videoChannel.avatar).to.exist 407 expect(videoPlaylist.videoChannel.avatar).to.exist
408 expect(videoPlaylist.videoChannel.avatars.length).to.equal(1, 'Channel should have two avatar images')
397 } 409 }
398 410
399 it('Should make a simple search and not have results', async function () { 411 it('Should make a simple search and not have results', async function () {
diff --git a/server/tests/api/search/search-playlists.ts b/server/tests/api/search/search-playlists.ts
index 1e9c8d4bb..fcf2f2ee2 100644
--- a/server/tests/api/search/search-playlists.ts
+++ b/server/tests/api/search/search-playlists.ts
@@ -2,6 +2,7 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { VideoPlaylistPrivacy } from '@shared/models'
5import { 6import {
6 cleanupTests, 7 cleanupTests,
7 createSingleServer, 8 createSingleServer,
@@ -9,9 +10,10 @@ import {
9 PeerTubeServer, 10 PeerTubeServer,
10 SearchCommand, 11 SearchCommand,
11 setAccessTokensToServers, 12 setAccessTokensToServers,
13 setDefaultAccountAvatar,
14 setDefaultChannelAvatar,
12 setDefaultVideoChannel 15 setDefaultVideoChannel
13} from '@shared/server-commands' 16} from '@shared/server-commands'
14import { VideoPlaylistPrivacy } from '@shared/models'
15 17
16const expect = chai.expect 18const expect = chai.expect
17 19
@@ -34,6 +36,8 @@ describe('Test playlists search', function () {
34 36
35 await setAccessTokensToServers([ remoteServer, server ]) 37 await setAccessTokensToServers([ remoteServer, server ])
36 await setDefaultVideoChannel([ remoteServer, server ]) 38 await setDefaultVideoChannel([ remoteServer, server ])
39 await setDefaultChannelAvatar([ remoteServer, server ])
40 await setDefaultAccountAvatar([ remoteServer, server ])
37 41
38 { 42 {
39 const videoId = (await server.videos.upload()).uuid 43 const videoId = (await server.videos.upload()).uuid
diff --git a/server/tests/api/search/search-videos.ts b/server/tests/api/search/search-videos.ts
index c544705d3..ff4c3c161 100644
--- a/server/tests/api/search/search-videos.ts
+++ b/server/tests/api/search/search-videos.ts
@@ -2,6 +2,8 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { wait } from '@shared/core-utils'
6import { VideoPrivacy } from '@shared/models'
5import { 7import {
6 cleanupTests, 8 cleanupTests,
7 createSingleServer, 9 createSingleServer,
@@ -9,11 +11,11 @@ import {
9 PeerTubeServer, 11 PeerTubeServer,
10 SearchCommand, 12 SearchCommand,
11 setAccessTokensToServers, 13 setAccessTokensToServers,
14 setDefaultAccountAvatar,
15 setDefaultChannelAvatar,
12 setDefaultVideoChannel, 16 setDefaultVideoChannel,
13 stopFfmpeg 17 stopFfmpeg
14} from '@shared/server-commands' 18} from '@shared/server-commands'
15import { VideoPrivacy } from '@shared/models'
16import { wait } from '@shared/core-utils'
17 19
18const expect = chai.expect 20const expect = chai.expect
19 21
@@ -38,6 +40,8 @@ describe('Test videos search', function () {
38 40
39 await setAccessTokensToServers([ server, remoteServer ]) 41 await setAccessTokensToServers([ server, remoteServer ])
40 await setDefaultVideoChannel([ server, remoteServer ]) 42 await setDefaultVideoChannel([ server, remoteServer ])
43 await setDefaultChannelAvatar(server)
44 await setDefaultAccountAvatar(servers)
41 45
42 { 46 {
43 const attributes1 = { 47 const attributes1 = {
diff --git a/server/tests/api/server/homepage.ts b/server/tests/api/server/homepage.ts
index 552ee98cf..e7de6bfee 100644
--- a/server/tests/api/server/homepage.ts
+++ b/server/tests/api/server/homepage.ts
@@ -9,7 +9,9 @@ import {
9 CustomPagesCommand, 9 CustomPagesCommand,
10 killallServers, 10 killallServers,
11 PeerTubeServer, 11 PeerTubeServer,
12 setAccessTokensToServers 12 setAccessTokensToServers,
13 setDefaultAccountAvatar,
14 setDefaultChannelAvatar
13} from '../../../../shared/server-commands/index' 15} from '../../../../shared/server-commands/index'
14 16
15const expect = chai.expect 17const expect = chai.expect
@@ -29,6 +31,8 @@ describe('Test instance homepage actions', function () {
29 31
30 server = await createSingleServer(1) 32 server = await createSingleServer(1)
31 await setAccessTokensToServers([ server ]) 33 await setAccessTokensToServers([ server ])
34 await setDefaultChannelAvatar(server)
35 await setDefaultAccountAvatar(server)
32 36
33 command = server.customPage 37 command = server.customPage
34 }) 38 })
diff --git a/server/tests/api/users/user-subscriptions.ts b/server/tests/api/users/user-subscriptions.ts
index 57cca6ad4..9553a69bb 100644
--- a/server/tests/api/users/user-subscriptions.ts
+++ b/server/tests/api/users/user-subscriptions.ts
@@ -9,6 +9,8 @@ import {
9 doubleFollow, 9 doubleFollow,
10 PeerTubeServer, 10 PeerTubeServer,
11 setAccessTokensToServers, 11 setAccessTokensToServers,
12 setDefaultAccountAvatar,
13 setDefaultChannelAvatar,
12 SubscriptionsCommand, 14 SubscriptionsCommand,
13 waitJobs 15 waitJobs
14} from '@shared/server-commands' 16} from '@shared/server-commands'
@@ -29,6 +31,8 @@ describe('Test users subscriptions', function () {
29 31
30 // Get the access tokens 32 // Get the access tokens
31 await setAccessTokensToServers(servers) 33 await setAccessTokensToServers(servers)
34 await setDefaultChannelAvatar(servers)
35 await setDefaultAccountAvatar(servers)
32 36
33 // Server 1 and server 2 follow each other 37 // Server 1 and server 2 follow each other
34 await doubleFollow(servers[0], servers[1]) 38 await doubleFollow(servers[0], servers[1])
diff --git a/server/tests/api/users/users-multiple-servers.ts b/server/tests/api/users/users-multiple-servers.ts
index 5b2bbc520..3e8b932c0 100644
--- a/server/tests/api/users/users-multiple-servers.ts
+++ b/server/tests/api/users/users-multiple-servers.ts
@@ -16,6 +16,7 @@ import {
16 doubleFollow, 16 doubleFollow,
17 PeerTubeServer, 17 PeerTubeServer,
18 setAccessTokensToServers, 18 setAccessTokensToServers,
19 setDefaultChannelAvatar,
19 waitJobs 20 waitJobs
20} from '@shared/server-commands' 21} from '@shared/server-commands'
21 22
@@ -29,7 +30,7 @@ describe('Test users with multiple servers', function () {
29 30
30 let videoUUID: string 31 let videoUUID: string
31 let userAccessToken: string 32 let userAccessToken: string
32 let userAvatarFilename: string 33 let userAvatarFilenames: string[]
33 34
34 before(async function () { 35 before(async function () {
35 this.timeout(120_000) 36 this.timeout(120_000)
@@ -38,6 +39,7 @@ describe('Test users with multiple servers', function () {
38 39
39 // Get the access tokens 40 // Get the access tokens
40 await setAccessTokensToServers(servers) 41 await setAccessTokensToServers(servers)
42 await setDefaultChannelAvatar(servers)
41 43
42 // Server 1 and server 2 follow each other 44 // Server 1 and server 2 follow each other
43 await doubleFollow(servers[0], servers[1]) 45 await doubleFollow(servers[0], servers[1])
@@ -97,9 +99,11 @@ describe('Test users with multiple servers', function () {
97 await servers[0].users.updateMyAvatar({ fixture }) 99 await servers[0].users.updateMyAvatar({ fixture })
98 100
99 user = await servers[0].users.getMyInfo() 101 user = await servers[0].users.getMyInfo()
100 userAvatarFilename = user.account.avatar.path 102 userAvatarFilenames = user.account.avatars.map(({ path }) => path)
101 103
102 await testImage(servers[0].url, 'avatar2-resized', userAvatarFilename, '.png') 104 for (const avatar of user.account.avatars) {
105 await testImage(servers[0].url, `avatar2-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png')
106 }
103 107
104 await waitJobs(servers) 108 await waitJobs(servers)
105 }) 109 })
@@ -129,7 +133,9 @@ describe('Test users with multiple servers', function () {
129 expect(account.userId).to.be.undefined 133 expect(account.userId).to.be.undefined
130 } 134 }
131 135
132 await testImage(server.url, 'avatar2-resized', account.avatar.path, '.png') 136 for (const avatar of account.avatars) {
137 await testImage(server.url, `avatar2-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png')
138 }
133 } 139 }
134 }) 140 })
135 141
@@ -193,7 +199,9 @@ describe('Test users with multiple servers', function () {
193 199
194 it('Should not have actor files', async () => { 200 it('Should not have actor files', async () => {
195 for (const server of servers) { 201 for (const server of servers) {
196 await checkActorFilesWereRemoved(userAvatarFilename, server.internalServerNumber) 202 for (const userAvatarFilename of userAvatarFilenames) {
203 await checkActorFilesWereRemoved(userAvatarFilename, server.internalServerNumber)
204 }
197 } 205 }
198 }) 206 })
199 207
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index 7023b3f08..a47713bf0 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -604,7 +604,9 @@ describe('Test users', function () {
604 await server.users.updateMyAvatar({ token: userToken, fixture }) 604 await server.users.updateMyAvatar({ token: userToken, fixture })
605 605
606 const user = await server.users.getMyInfo({ token: userToken }) 606 const user = await server.users.getMyInfo({ token: userToken })
607 await testImage(server.url, 'avatar-resized', user.account.avatar.path, '.gif') 607 for (const avatar of user.account.avatars) {
608 await testImage(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, '.gif')
609 }
608 }) 610 })
609 611
610 it('Should be able to update my avatar with a gif, and then a png', async function () { 612 it('Should be able to update my avatar with a gif, and then a png', async function () {
@@ -614,7 +616,9 @@ describe('Test users', function () {
614 await server.users.updateMyAvatar({ token: userToken, fixture }) 616 await server.users.updateMyAvatar({ token: userToken, fixture })
615 617
616 const user = await server.users.getMyInfo({ token: userToken }) 618 const user = await server.users.getMyInfo({ token: userToken })
617 await testImage(server.url, 'avatar-resized', user.account.avatar.path, extension) 619 for (const avatar of user.account.avatars) {
620 await testImage(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, extension)
621 }
618 } 622 }
619 }) 623 })
620 624
diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts
index ecdd36613..5bbc60559 100644
--- a/server/tests/api/videos/multiple-servers.ts
+++ b/server/tests/api/videos/multiple-servers.ts
@@ -19,6 +19,8 @@ import {
19 doubleFollow, 19 doubleFollow,
20 PeerTubeServer, 20 PeerTubeServer,
21 setAccessTokensToServers, 21 setAccessTokensToServers,
22 setDefaultAccountAvatar,
23 setDefaultChannelAvatar,
22 waitJobs, 24 waitJobs,
23 webtorrentAdd 25 webtorrentAdd
24} from '@shared/server-commands' 26} from '@shared/server-commands'
@@ -46,6 +48,9 @@ describe('Test multiple servers', function () {
46 description: 'super channel' 48 description: 'super channel'
47 } 49 }
48 await servers[0].channels.create({ attributes: videoChannel }) 50 await servers[0].channels.create({ attributes: videoChannel })
51 await setDefaultChannelAvatar(servers[0], videoChannel.name)
52 await setDefaultAccountAvatar(servers)
53
49 const { data } = await servers[0].channels.list({ start: 0, count: 1 }) 54 const { data } = await servers[0].channels.list({ start: 0, count: 1 })
50 videoChannelId = data[0].id 55 videoChannelId = data[0].id
51 } 56 }
@@ -207,7 +212,7 @@ describe('Test multiple servers', function () {
207 }, 212 },
208 { 213 {
209 resolution: 720, 214 resolution: 720,
210 size: 788000 215 size: 750000
211 } 216 }
212 ], 217 ],
213 thumbnailfile: 'thumbnail', 218 thumbnailfile: 'thumbnail',
diff --git a/server/tests/api/videos/single-server.ts b/server/tests/api/videos/single-server.ts
index 28bf018c5..d37043aef 100644
--- a/server/tests/api/videos/single-server.ts
+++ b/server/tests/api/videos/single-server.ts
@@ -5,7 +5,14 @@ import * as chai from 'chai'
5import { checkVideoFilesWereRemoved, completeVideoCheck, testImage } from '@server/tests/shared' 5import { checkVideoFilesWereRemoved, completeVideoCheck, testImage } from '@server/tests/shared'
6import { wait } from '@shared/core-utils' 6import { wait } from '@shared/core-utils'
7import { Video, VideoPrivacy } from '@shared/models' 7import { Video, VideoPrivacy } from '@shared/models'
8import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' 8import {
9 cleanupTests,
10 createSingleServer,
11 PeerTubeServer,
12 setAccessTokensToServers,
13 setDefaultAccountAvatar,
14 setDefaultChannelAvatar
15} from '@shared/server-commands'
9 16
10const expect = chai.expect 17const expect = chai.expect
11 18
@@ -90,6 +97,8 @@ describe('Test a single server', function () {
90 server = await createSingleServer(1) 97 server = await createSingleServer(1)
91 98
92 await setAccessTokensToServers([ server ]) 99 await setAccessTokensToServers([ server ])
100 await setDefaultChannelAvatar(server)
101 await setDefaultAccountAvatar(server)
93 }) 102 })
94 103
95 it('Should list video categories', async function () { 104 it('Should list video categories', async function () {
diff --git a/server/tests/api/videos/video-channels.ts b/server/tests/api/videos/video-channels.ts
index d435f3682..0f8227fd3 100644
--- a/server/tests/api/videos/video-channels.ts
+++ b/server/tests/api/videos/video-channels.ts
@@ -6,13 +6,14 @@ import { basename } from 'path'
6import { ACTOR_IMAGES_SIZE } from '@server/initializers/constants' 6import { ACTOR_IMAGES_SIZE } from '@server/initializers/constants'
7import { testFileExistsOrNot, testImage } from '@server/tests/shared' 7import { testFileExistsOrNot, testImage } from '@server/tests/shared'
8import { wait } from '@shared/core-utils' 8import { wait } from '@shared/core-utils'
9import { User, VideoChannel } from '@shared/models' 9import { ActorImageType, User, VideoChannel } from '@shared/models'
10import { 10import {
11 cleanupTests, 11 cleanupTests,
12 createMultipleServers, 12 createMultipleServers,
13 doubleFollow, 13 doubleFollow,
14 PeerTubeServer, 14 PeerTubeServer,
15 setAccessTokensToServers, 15 setAccessTokensToServers,
16 setDefaultAccountAvatar,
16 setDefaultVideoChannel, 17 setDefaultVideoChannel,
17 waitJobs 18 waitJobs
18} from '@shared/server-commands' 19} from '@shared/server-commands'
@@ -44,6 +45,7 @@ describe('Test video channels', function () {
44 45
45 await setAccessTokensToServers(servers) 46 await setAccessTokensToServers(servers)
46 await setDefaultVideoChannel(servers) 47 await setDefaultVideoChannel(servers)
48 await setDefaultAccountAvatar(servers)
47 49
48 await doubleFollow(servers[0], servers[1]) 50 await doubleFollow(servers[0], servers[1])
49 }) 51 })
@@ -281,14 +283,19 @@ describe('Test video channels', function () {
281 283
282 for (const server of servers) { 284 for (const server of servers) {
283 const videoChannel = await findChannel(server, secondVideoChannelId) 285 const videoChannel = await findChannel(server, secondVideoChannelId)
286 const expectedSizes = ACTOR_IMAGES_SIZE[ActorImageType.AVATAR]
284 287
285 avatarPaths[server.port] = videoChannel.avatar.path 288 expect(videoChannel.avatars.length).to.equal(expectedSizes.length, 'Expected avatars to be generated in all sizes')
286 await testImage(server.url, 'avatar-resized', avatarPaths[server.port], '.png')
287 await testFileExistsOrNot(server, 'avatars', basename(avatarPaths[server.port]), true)
288 289
289 const row = await server.sql.getActorImage(basename(avatarPaths[server.port])) 290 for (const avatar of videoChannel.avatars) {
290 expect(row.height).to.equal(ACTOR_IMAGES_SIZE.AVATARS.height) 291 avatarPaths[server.port] = avatar.path
291 expect(row.width).to.equal(ACTOR_IMAGES_SIZE.AVATARS.width) 292 await testImage(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatarPaths[server.port], '.png')
293 await testFileExistsOrNot(server, 'avatars', basename(avatarPaths[server.port]), true)
294
295 const row = await server.sql.getActorImage(basename(avatarPaths[server.port]))
296
297 expect(expectedSizes.some(({ height, width }) => row.height === height && row.width === width)).to.equal(true)
298 }
292 } 299 }
293 }) 300 })
294 301
@@ -308,19 +315,18 @@ describe('Test video channels', function () {
308 for (const server of servers) { 315 for (const server of servers) {
309 const videoChannel = await server.channels.get({ channelName: 'second_video_channel@' + servers[0].host }) 316 const videoChannel = await server.channels.get({ channelName: 'second_video_channel@' + servers[0].host })
310 317
311 bannerPaths[server.port] = videoChannel.banner.path 318 bannerPaths[server.port] = videoChannel.banners[0].path
312 await testImage(server.url, 'banner-resized', bannerPaths[server.port]) 319 await testImage(server.url, 'banner-resized', bannerPaths[server.port])
313 await testFileExistsOrNot(server, 'avatars', basename(bannerPaths[server.port]), true) 320 await testFileExistsOrNot(server, 'avatars', basename(bannerPaths[server.port]), true)
314 321
315 const row = await server.sql.getActorImage(basename(bannerPaths[server.port])) 322 const row = await server.sql.getActorImage(basename(bannerPaths[server.port]))
316 expect(row.height).to.equal(ACTOR_IMAGES_SIZE.BANNERS.height) 323 expect(row.height).to.equal(ACTOR_IMAGES_SIZE[ActorImageType.BANNER][0].height)
317 expect(row.width).to.equal(ACTOR_IMAGES_SIZE.BANNERS.width) 324 expect(row.width).to.equal(ACTOR_IMAGES_SIZE[ActorImageType.BANNER][0].width)
318 } 325 }
319 }) 326 })
320 327
321 it('Should delete the video channel avatar', async function () { 328 it('Should delete the video channel avatar', async function () {
322 this.timeout(15000) 329 this.timeout(15000)
323
324 await servers[0].channels.deleteImage({ channelName: 'second_video_channel', type: 'avatar' }) 330 await servers[0].channels.deleteImage({ channelName: 'second_video_channel', type: 'avatar' })
325 331
326 await waitJobs(servers) 332 await waitJobs(servers)
@@ -329,7 +335,7 @@ describe('Test video channels', function () {
329 const videoChannel = await findChannel(server, secondVideoChannelId) 335 const videoChannel = await findChannel(server, secondVideoChannelId)
330 await testFileExistsOrNot(server, 'avatars', basename(avatarPaths[server.port]), false) 336 await testFileExistsOrNot(server, 'avatars', basename(avatarPaths[server.port]), false)
331 337
332 expect(videoChannel.avatar).to.be.null 338 expect(videoChannel.avatars).to.be.empty
333 } 339 }
334 }) 340 })
335 341
@@ -344,7 +350,7 @@ describe('Test video channels', function () {
344 const videoChannel = await findChannel(server, secondVideoChannelId) 350 const videoChannel = await findChannel(server, secondVideoChannelId)
345 await testFileExistsOrNot(server, 'avatars', basename(bannerPaths[server.port]), false) 351 await testFileExistsOrNot(server, 'avatars', basename(bannerPaths[server.port]), false)
346 352
347 expect(videoChannel.banner).to.be.null 353 expect(videoChannel.banners).to.be.empty
348 } 354 }
349 }) 355 })
350 356
diff --git a/server/tests/api/videos/video-comments.ts b/server/tests/api/videos/video-comments.ts
index 2ae523970..1488ce2b5 100644
--- a/server/tests/api/videos/video-comments.ts
+++ b/server/tests/api/videos/video-comments.ts
@@ -3,7 +3,15 @@
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { dateIsValid, testImage } from '@server/tests/shared' 5import { dateIsValid, testImage } from '@server/tests/shared'
6import { cleanupTests, CommentsCommand, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' 6import {
7 cleanupTests,
8 CommentsCommand,
9 createSingleServer,
10 PeerTubeServer,
11 setAccessTokensToServers,
12 setDefaultAccountAvatar,
13 setDefaultChannelAvatar
14} from '@shared/server-commands'
7 15
8const expect = chai.expect 16const expect = chai.expect
9 17
@@ -29,7 +37,8 @@ describe('Test video comments', function () {
29 videoUUID = uuid 37 videoUUID = uuid
30 videoId = id 38 videoId = id
31 39
32 await server.users.updateMyAvatar({ fixture: 'avatar.png' }) 40 await setDefaultChannelAvatar(server)
41 await setDefaultAccountAvatar(server)
33 42
34 userAccessTokenServer1 = await server.users.generateUserAndToken('user1') 43 userAccessTokenServer1 = await server.users.generateUserAndToken('user1')
35 44
@@ -81,7 +90,9 @@ describe('Test video comments', function () {
81 expect(comment.account.name).to.equal('root') 90 expect(comment.account.name).to.equal('root')
82 expect(comment.account.host).to.equal('localhost:' + server.port) 91 expect(comment.account.host).to.equal('localhost:' + server.port)
83 92
84 await testImage(server.url, 'avatar-resized', comment.account.avatar.path, '.png') 93 for (const avatar of comment.account.avatars) {
94 await testImage(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png')
95 }
85 96
86 expect(comment.totalReplies).to.equal(0) 97 expect(comment.totalReplies).to.equal(0)
87 expect(comment.totalRepliesFromVideoAuthor).to.equal(0) 98 expect(comment.totalRepliesFromVideoAuthor).to.equal(0)
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts
index 34327334f..1e8dbef02 100644
--- a/server/tests/api/videos/video-playlists.ts
+++ b/server/tests/api/videos/video-playlists.ts
@@ -20,6 +20,7 @@ import {
20 PeerTubeServer, 20 PeerTubeServer,
21 PlaylistsCommand, 21 PlaylistsCommand,
22 setAccessTokensToServers, 22 setAccessTokensToServers,
23 setDefaultAccountAvatar,
23 setDefaultVideoChannel, 24 setDefaultVideoChannel,
24 waitJobs 25 waitJobs
25} from '@shared/server-commands' 26} from '@shared/server-commands'
@@ -79,6 +80,7 @@ describe('Test video playlists', function () {
79 // Get the access tokens 80 // Get the access tokens
80 await setAccessTokensToServers(servers) 81 await setAccessTokensToServers(servers)
81 await setDefaultVideoChannel(servers) 82 await setDefaultVideoChannel(servers)
83 await setDefaultAccountAvatar(servers)
82 84
83 // Server 1 and server 2 follow each other 85 // Server 1 and server 2 follow each other
84 await doubleFollow(servers[0], servers[1]) 86 await doubleFollow(servers[0], servers[1])
diff --git a/server/tests/api/videos/videos-common-filters.ts b/server/tests/api/videos/videos-common-filters.ts
index 0254662c5..317de90a9 100644
--- a/server/tests/api/videos/videos-common-filters.ts
+++ b/server/tests/api/videos/videos-common-filters.ts
@@ -3,6 +3,7 @@
3import 'mocha' 3import 'mocha'
4import { expect } from 'chai' 4import { expect } from 'chai'
5import { pick } from '@shared/core-utils' 5import { pick } from '@shared/core-utils'
6import { HttpStatusCode, UserRole, Video, VideoDetails, VideoInclude, VideoPrivacy } from '@shared/models'
6import { 7import {
7 cleanupTests, 8 cleanupTests,
8 createMultipleServers, 9 createMultipleServers,
@@ -10,10 +11,10 @@ import {
10 makeGetRequest, 11 makeGetRequest,
11 PeerTubeServer, 12 PeerTubeServer,
12 setAccessTokensToServers, 13 setAccessTokensToServers,
14 setDefaultAccountAvatar,
13 setDefaultVideoChannel, 15 setDefaultVideoChannel,
14 waitJobs 16 waitJobs
15} from '@shared/server-commands' 17} from '@shared/server-commands'
16import { HttpStatusCode, UserRole, Video, VideoDetails, VideoInclude, VideoPrivacy } from '@shared/models'
17 18
18describe('Test videos filter', function () { 19describe('Test videos filter', function () {
19 let servers: PeerTubeServer[] 20 let servers: PeerTubeServer[]
@@ -29,6 +30,7 @@ describe('Test videos filter', function () {
29 30
30 await setAccessTokensToServers(servers) 31 await setAccessTokensToServers(servers)
31 await setDefaultVideoChannel(servers) 32 await setDefaultVideoChannel(servers)
33 await setDefaultAccountAvatar(servers)
32 34
33 for (const server of servers) { 35 for (const server of servers) {
34 const moderator = { username: 'moderator', password: 'my super password' } 36 const moderator = { username: 'moderator', password: 'my super password' }
diff --git a/server/tests/cli/prune-storage.ts b/server/tests/cli/prune-storage.ts
index a723ed8b4..3ca7c19ea 100644
--- a/server/tests/cli/prune-storage.ts
+++ b/server/tests/cli/prune-storage.ts
@@ -51,7 +51,7 @@ async function assertCountAreOkay (servers: PeerTubeServer[]) {
51 expect(thumbnailsCount).to.equal(6) 51 expect(thumbnailsCount).to.equal(6)
52 52
53 const avatarsCount = await countFiles(server, 'avatars') 53 const avatarsCount = await countFiles(server, 'avatars')
54 expect(avatarsCount).to.equal(2) 54 expect(avatarsCount).to.equal(4)
55 55
56 const hlsRootCount = await countFiles(server, 'streaming-playlists/hls') 56 const hlsRootCount = await countFiles(server, 'streaming-playlists/hls')
57 expect(hlsRootCount).to.equal(2) 57 expect(hlsRootCount).to.equal(2)
@@ -87,23 +87,28 @@ describe('Test prune storage scripts', function () {
87 87
88 await doubleFollow(servers[0], servers[1]) 88 await doubleFollow(servers[0], servers[1])
89 89
90 // Lazy load the remote avatar 90 // Lazy load the remote avatars
91 { 91 {
92 const account = await servers[0].accounts.get({ accountName: 'root@localhost:' + servers[1].port }) 92 const account = await servers[0].accounts.get({ accountName: 'root@localhost:' + servers[1].port })
93 await makeGetRequest({ 93
94 url: servers[0].url, 94 for (const avatar of account.avatars) {
95 path: account.avatar.path, 95 await makeGetRequest({
96 expectedStatus: HttpStatusCode.OK_200 96 url: servers[0].url,
97 }) 97 path: avatar.path,
98 expectedStatus: HttpStatusCode.OK_200
99 })
100 }
98 } 101 }
99 102
100 { 103 {
101 const account = await servers[1].accounts.get({ accountName: 'root@localhost:' + servers[0].port }) 104 const account = await servers[1].accounts.get({ accountName: 'root@localhost:' + servers[0].port })
102 await makeGetRequest({ 105 for (const avatar of account.avatars) {
103 url: servers[1].url, 106 await makeGetRequest({
104 path: account.avatar.path, 107 url: servers[1].url,
105 expectedStatus: HttpStatusCode.OK_200 108 path: avatar.path,
106 }) 109 expectedStatus: HttpStatusCode.OK_200
110 })
111 }
107 } 112 }
108 113
109 await wait(1000) 114 await wait(1000)
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index 4dcd77cca..320dc3333 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -3,6 +3,7 @@
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { XMLParser, XMLValidator } from 'fast-xml-parser' 5import { XMLParser, XMLValidator } from 'fast-xml-parser'
6import { HttpStatusCode, VideoPrivacy } from '@shared/models'
6import { 7import {
7 cleanupTests, 8 cleanupTests,
8 createMultipleServers, 9 createMultipleServers,
@@ -11,9 +12,9 @@ import {
11 makeGetRequest, 12 makeGetRequest,
12 PeerTubeServer, 13 PeerTubeServer,
13 setAccessTokensToServers, 14 setAccessTokensToServers,
15 setDefaultChannelAvatar,
14 waitJobs 16 waitJobs
15} from '@shared/server-commands' 17} from '@shared/server-commands'
16import { HttpStatusCode, VideoPrivacy } from '@shared/models'
17 18
18chai.use(require('chai-xml')) 19chai.use(require('chai-xml'))
19chai.use(require('chai-json-schema')) 20chai.use(require('chai-json-schema'))
@@ -44,6 +45,7 @@ describe('Test syndication feeds', () => {
44 }) 45 })
45 46
46 await setAccessTokensToServers([ ...servers, serverHLSOnly ]) 47 await setAccessTokensToServers([ ...servers, serverHLSOnly ])
48 await setDefaultChannelAvatar(servers[0])
47 await doubleFollow(servers[0], servers[1]) 49 await doubleFollow(servers[0], servers[1])
48 50
49 { 51 {
diff --git a/server/tests/fixtures/avatar-resized.gif b/server/tests/fixtures/avatar-resized-120x120.gif
index 81a82189e..81a82189e 100644
--- a/server/tests/fixtures/avatar-resized.gif
+++ b/server/tests/fixtures/avatar-resized-120x120.gif
Binary files differ
diff --git a/server/tests/fixtures/avatar-resized.png b/server/tests/fixtures/avatar-resized-120x120.png
index 9d84151f8..9d84151f8 100644
--- a/server/tests/fixtures/avatar-resized.png
+++ b/server/tests/fixtures/avatar-resized-120x120.png
Binary files differ
diff --git a/server/tests/fixtures/avatar-resized-48x48.gif b/server/tests/fixtures/avatar-resized-48x48.gif
new file mode 100644
index 000000000..5900ff12e
--- /dev/null
+++ b/server/tests/fixtures/avatar-resized-48x48.gif
Binary files differ
diff --git a/server/tests/fixtures/avatar-resized-48x48.png b/server/tests/fixtures/avatar-resized-48x48.png
new file mode 100644
index 000000000..9e5f3b490
--- /dev/null
+++ b/server/tests/fixtures/avatar-resized-48x48.png
Binary files differ
diff --git a/server/tests/fixtures/avatar2-resized.png b/server/tests/fixtures/avatar2-resized-120x120.png
index 44149facb..44149facb 100644
--- a/server/tests/fixtures/avatar2-resized.png
+++ b/server/tests/fixtures/avatar2-resized-120x120.png
Binary files differ
diff --git a/server/tests/fixtures/avatar2-resized-48x48.png b/server/tests/fixtures/avatar2-resized-48x48.png
new file mode 100644
index 000000000..bb3939b1a
--- /dev/null
+++ b/server/tests/fixtures/avatar2-resized-48x48.png
Binary files differ
diff --git a/server/tests/shared/notifications.ts b/server/tests/shared/notifications.ts
index cdc21fdc8..78d3787f0 100644
--- a/server/tests/shared/notifications.ts
+++ b/server/tests/shared/notifications.ts
@@ -10,7 +10,14 @@ import {
10 UserNotificationSettingValue, 10 UserNotificationSettingValue,
11 UserNotificationType 11 UserNotificationType
12} from '@shared/models' 12} from '@shared/models'
13import { createMultipleServers, doubleFollow, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' 13import {
14 createMultipleServers,
15 doubleFollow,
16 PeerTubeServer,
17 setAccessTokensToServers,
18 setDefaultAccountAvatar,
19 setDefaultChannelAvatar
20} from '@shared/server-commands'
14import { MockSmtpServer } from './mock-servers' 21import { MockSmtpServer } from './mock-servers'
15 22
16type CheckerBaseParams = { 23type CheckerBaseParams = {
@@ -646,6 +653,8 @@ async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: an
646 const servers = await createMultipleServers(serversCount, Object.assign(overrideConfig, overrideConfigArg)) 653 const servers = await createMultipleServers(serversCount, Object.assign(overrideConfig, overrideConfigArg))
647 654
648 await setAccessTokensToServers(servers) 655 await setAccessTokensToServers(servers)
656 await setDefaultChannelAvatar(servers)
657 await setDefaultAccountAvatar(servers)
649 658
650 if (serversCount > 1) { 659 if (serversCount > 1) {
651 await doubleFollow(servers[0], servers[1]) 660 await doubleFollow(servers[0], servers[1])
diff --git a/server/types/models/actor/actor-image.ts b/server/types/models/actor/actor-image.ts
index 521b4cc59..e8f32b71e 100644
--- a/server/types/models/actor/actor-image.ts
+++ b/server/types/models/actor/actor-image.ts
@@ -9,4 +9,4 @@ export type MActorImage = ActorImageModel
9 9
10export type MActorImageFormattable = 10export type MActorImageFormattable =
11 FunctionProperties<MActorImage> & 11 FunctionProperties<MActorImage> &
12 Pick<MActorImage, 'filename' | 'createdAt' | 'updatedAt'> 12 Pick<MActorImage, 'width' | 'filename' | 'createdAt' | 'updatedAt'>
diff --git a/server/types/models/actor/actor.ts b/server/types/models/actor/actor.ts
index 9ce97094f..280256bab 100644
--- a/server/types/models/actor/actor.ts
+++ b/server/types/models/actor/actor.ts
@@ -10,7 +10,7 @@ type UseOpt<K extends keyof ActorModel, M> = PickWithOpt<ActorModel, K, M>
10 10
11// ############################################################################ 11// ############################################################################
12 12
13export type MActor = Omit<ActorModel, 'Account' | 'VideoChannel' | 'ActorFollowing' | 'Avatar' | 'ActorFollowers' | 'Server' | 'Banner'> 13export type MActor = Omit<ActorModel, 'Account' | 'VideoChannel' | 'ActorFollowing' | 'ActorFollowers' | 'Server' | 'Banners'>
14 14
15// ############################################################################ 15// ############################################################################
16 16
@@ -35,7 +35,7 @@ export type MActorRedundancyAllowedOpt = PickWithOpt<ActorModel, 'Server', MServ
35export type MActorDefaultLight = 35export type MActorDefaultLight =
36 MActorLight & 36 MActorLight &
37 Use<'Server', MServerHost> & 37 Use<'Server', MServerHost> &
38 Use<'Avatar', MActorImage> 38 Use<'Avatars', MActorImage[]>
39 39
40export type MActorAccountId = 40export type MActorAccountId =
41 MActor & 41 MActor &
@@ -78,13 +78,13 @@ export type MActorServer =
78 78
79export type MActorImages = 79export type MActorImages =
80 MActor & 80 MActor &
81 Use<'Avatar', MActorImage> & 81 Use<'Avatars', MActorImage[]> &
82 UseOpt<'Banner', MActorImage> 82 UseOpt<'Banners', MActorImage[]>
83 83
84export type MActorDefault = 84export type MActorDefault =
85 MActor & 85 MActor &
86 Use<'Server', MServer> & 86 Use<'Server', MServer> &
87 Use<'Avatar', MActorImage> 87 Use<'Avatars', MActorImage[]>
88 88
89export type MActorDefaultChannelId = 89export type MActorDefaultChannelId =
90 MActorDefault & 90 MActorDefault &
@@ -93,8 +93,8 @@ export type MActorDefaultChannelId =
93export type MActorDefaultBanner = 93export type MActorDefaultBanner =
94 MActor & 94 MActor &
95 Use<'Server', MServer> & 95 Use<'Server', MServer> &
96 Use<'Avatar', MActorImage> & 96 Use<'Avatars', MActorImage[]> &
97 Use<'Banner', MActorImage> 97 Use<'Banners', MActorImage[]>
98 98
99// Actor with channel that is associated to an account and its actor 99// Actor with channel that is associated to an account and its actor
100// Actor -> VideoChannel -> Account -> Actor 100// Actor -> VideoChannel -> Account -> Actor
@@ -105,8 +105,8 @@ export type MActorChannelAccountActor =
105export type MActorFull = 105export type MActorFull =
106 MActor & 106 MActor &
107 Use<'Server', MServer> & 107 Use<'Server', MServer> &
108 Use<'Avatar', MActorImage> & 108 Use<'Avatars', MActorImage[]> &
109 Use<'Banner', MActorImage> & 109 Use<'Banners', MActorImage[]> &
110 Use<'Account', MAccount> & 110 Use<'Account', MAccount> &
111 Use<'VideoChannel', MChannelAccountActor> 111 Use<'VideoChannel', MChannelAccountActor>
112 112
@@ -114,8 +114,8 @@ export type MActorFull =
114export type MActorFullActor = 114export type MActorFullActor =
115 MActor & 115 MActor &
116 Use<'Server', MServer> & 116 Use<'Server', MServer> &
117 Use<'Avatar', MActorImage> & 117 Use<'Avatars', MActorImage[]> &
118 Use<'Banner', MActorImage> & 118 Use<'Banners', MActorImage[]> &
119 Use<'Account', MAccountDefault> & 119 Use<'Account', MAccountDefault> &
120 Use<'VideoChannel', MChannelAccountDefault> 120 Use<'VideoChannel', MChannelAccountDefault>
121 121
@@ -125,9 +125,9 @@ export type MActorFullActor =
125 125
126export type MActorSummary = 126export type MActorSummary =
127 FunctionProperties<MActor> & 127 FunctionProperties<MActor> &
128 Pick<MActor, 'id' | 'preferredUsername' | 'url' | 'serverId' | 'avatarId'> & 128 Pick<MActor, 'id' | 'preferredUsername' | 'url' | 'serverId'> &
129 Use<'Server', MServerHost> & 129 Use<'Server', MServerHost> &
130 Use<'Avatar', MActorImage> 130 Use<'Avatars', MActorImage[]>
131 131
132export type MActorSummaryBlocks = 132export type MActorSummaryBlocks =
133 MActorSummary & 133 MActorSummary &
@@ -145,21 +145,22 @@ export type MActorSummaryFormattable =
145 FunctionProperties<MActor> & 145 FunctionProperties<MActor> &
146 Pick<MActor, 'url' | 'preferredUsername'> & 146 Pick<MActor, 'url' | 'preferredUsername'> &
147 Use<'Server', MServerHost> & 147 Use<'Server', MServerHost> &
148 Use<'Avatar', MActorImageFormattable> 148 Use<'Avatars', MActorImageFormattable[]>
149 149
150export type MActorFormattable = 150export type MActorFormattable =
151 MActorSummaryFormattable & 151 MActorSummaryFormattable &
152 Pick<MActor, 'id' | 'followingCount' | 'followersCount' | 'createdAt' | 'updatedAt' | 'remoteCreatedAt' | 'bannerId' | 'avatarId'> & 152 Pick<MActor, 'id' | 'followingCount' | 'followersCount' | 'createdAt' | 'updatedAt' | 'remoteCreatedAt'> &
153 Use<'Server', MServerHost & Partial<Pick<MServer, 'redundancyAllowed'>>> & 153 Use<'Server', MServerHost & Partial<Pick<MServer, 'redundancyAllowed'>>> &
154 UseOpt<'Banner', MActorImageFormattable> 154 UseOpt<'Banners', MActorImageFormattable[]> &
155 UseOpt<'Avatars', MActorImageFormattable[]>
155 156
156type MActorAPBase = 157type MActorAPBase =
157 MActor & 158 MActor &
158 Use<'Avatar', MActorImage> 159 Use<'Avatars', MActorImage[]>
159 160
160export type MActorAPAccount = 161export type MActorAPAccount =
161 MActorAPBase 162 MActorAPBase
162 163
163export type MActorAPChannel = 164export type MActorAPChannel =
164 MActorAPBase & 165 MActorAPBase &
165 Use<'Banner', MActorImage> 166 Use<'Banners', MActorImage[]>
diff --git a/server/types/models/user/user-notification.ts b/server/types/models/user/user-notification.ts
index db9ec0400..d4715a0b6 100644
--- a/server/types/models/user/user-notification.ts
+++ b/server/types/models/user/user-notification.ts
@@ -21,6 +21,7 @@ type Use<K extends keyof UserNotificationModel, M> = PickWith<UserNotificationMo
21// ############################################################################ 21// ############################################################################
22 22
23export module UserNotificationIncludes { 23export module UserNotificationIncludes {
24 export type ActorImageInclude = Pick<ActorImageModel, 'createdAt' | 'filename' | 'getStaticPath' | 'width' | 'updatedAt'>
24 25
25 export type VideoInclude = Pick<VideoModel, 'id' | 'uuid' | 'name'> 26 export type VideoInclude = Pick<VideoModel, 'id' | 'uuid' | 'name'>
26 export type VideoIncludeChannel = 27 export type VideoIncludeChannel =
@@ -29,7 +30,7 @@ export module UserNotificationIncludes {
29 30
30 export type ActorInclude = 31 export type ActorInclude =
31 Pick<ActorModel, 'preferredUsername' | 'getHost'> & 32 Pick<ActorModel, 'preferredUsername' | 'getHost'> &
32 PickWith<ActorModel, 'Avatar', Pick<ActorImageModel, 'filename' | 'getStaticPath'>> & 33 PickWith<ActorModel, 'Avatars', ActorImageInclude[]> &
33 PickWith<ActorModel, 'Server', Pick<ServerModel, 'host'>> 34 PickWith<ActorModel, 'Server', Pick<ServerModel, 'host'>>
34 35
35 export type VideoChannelInclude = Pick<VideoChannelModel, 'id' | 'name' | 'getDisplayName'> 36 export type VideoChannelInclude = Pick<VideoChannelModel, 'id' | 'name' | 'getDisplayName'>
@@ -75,7 +76,7 @@ export module UserNotificationIncludes {
75 Pick<ActorModel, 'preferredUsername' | 'getHost'> & 76 Pick<ActorModel, 'preferredUsername' | 'getHost'> &
76 PickWith<ActorModel, 'Account', AccountInclude> & 77 PickWith<ActorModel, 'Account', AccountInclude> &
77 PickWith<ActorModel, 'Server', Pick<ServerModel, 'host'>> & 78 PickWith<ActorModel, 'Server', Pick<ServerModel, 'host'>> &
78 PickWithOpt<ActorModel, 'Avatar', Pick<ActorImageModel, 'filename' | 'getStaticPath'>> 79 PickWithOpt<ActorModel, 'Avatars', ActorImageInclude[]>
79 80
80 export type ActorFollowing = 81 export type ActorFollowing =
81 Pick<ActorModel, 'preferredUsername' | 'type' | 'getHost'> & 82 Pick<ActorModel, 'preferredUsername' | 'type' | 'getHost'> &
@@ -98,7 +99,7 @@ export module UserNotificationIncludes {
98// ############################################################################ 99// ############################################################################
99 100
100export type MUserNotification = 101export type MUserNotification =
101 Omit<UserNotificationModel, 'User' | 'Video' | 'Comment' | 'Abuse' | 'VideoBlacklist' | 102 Omit<UserNotificationModel, 'User' | 'Video' | 'VideoComment' | 'Abuse' | 'VideoBlacklist' |
102 'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application'> 103 'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application'>
103 104
104// ############################################################################ 105// ############################################################################
@@ -106,7 +107,7 @@ export type MUserNotification =
106export type UserNotificationModelForApi = 107export type UserNotificationModelForApi =
107 MUserNotification & 108 MUserNotification &
108 Use<'Video', UserNotificationIncludes.VideoIncludeChannel> & 109 Use<'Video', UserNotificationIncludes.VideoIncludeChannel> &
109 Use<'Comment', UserNotificationIncludes.VideoCommentInclude> & 110 Use<'VideoComment', UserNotificationIncludes.VideoCommentInclude> &
110 Use<'Abuse', UserNotificationIncludes.AbuseInclude> & 111 Use<'Abuse', UserNotificationIncludes.AbuseInclude> &
111 Use<'VideoBlacklist', UserNotificationIncludes.VideoBlacklistInclude> & 112 Use<'VideoBlacklist', UserNotificationIncludes.VideoBlacklistInclude> &
112 Use<'VideoImport', UserNotificationIncludes.VideoImportInclude> & 113 Use<'VideoImport', UserNotificationIncludes.VideoImportInclude> &