aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
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/config.ts3
-rw-r--r--server/controllers/api/users/index.ts37
-rw-r--r--server/controllers/api/users/me.ts21
-rw-r--r--server/controllers/api/users/my-notifications.ts2
-rw-r--r--server/controllers/api/video-channel.ts25
-rw-r--r--server/controllers/api/video-playlist.ts12
-rw-r--r--server/controllers/api/videos/captions.ts11
-rw-r--r--server/controllers/api/videos/editor.ts118
-rw-r--r--server/controllers/api/videos/import.ts7
-rw-r--r--server/controllers/api/videos/index.ts2
-rw-r--r--server/controllers/api/videos/live.ts10
-rw-r--r--server/controllers/api/videos/transcoding.ts4
-rw-r--r--server/controllers/api/videos/update.ts10
-rw-r--r--server/controllers/api/videos/upload.ts21
-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/helpers/custom-validators/actor-images.ts11
-rw-r--r--server/helpers/custom-validators/misc.ts79
-rw-r--r--server/helpers/custom-validators/video-captions.ts12
-rw-r--r--server/helpers/custom-validators/video-editor.ts52
-rw-r--r--server/helpers/custom-validators/video-imports.ts11
-rw-r--r--server/helpers/custom-validators/videos.ts27
-rw-r--r--server/helpers/express-utils.ts80
-rw-r--r--server/helpers/ffmpeg-utils.ts781
-rw-r--r--server/helpers/ffmpeg/ffmpeg-commons.ts114
-rw-r--r--server/helpers/ffmpeg/ffmpeg-edition.ts242
-rw-r--r--server/helpers/ffmpeg/ffmpeg-encoders.ts116
-rw-r--r--server/helpers/ffmpeg/ffmpeg-images.ts46
-rw-r--r--server/helpers/ffmpeg/ffmpeg-live.ts161
-rw-r--r--server/helpers/ffmpeg/ffmpeg-presets.ts156
-rw-r--r--server/helpers/ffmpeg/ffmpeg-vod.ts254
-rw-r--r--server/helpers/ffmpeg/ffprobe-utils.ts (renamed from server/helpers/ffprobe-utils.ts)97
-rw-r--r--server/helpers/ffmpeg/index.ts8
-rw-r--r--server/helpers/image-utils.ts50
-rw-r--r--server/helpers/markdown.ts4
-rw-r--r--server/helpers/webtorrent.ts39
-rw-r--r--server/initializers/checker-after-init.ts188
-rw-r--r--server/initializers/checker-before-init.ts11
-rw-r--r--server/initializers/config.ts7
-rw-r--r--server/initializers/constants.ts60
-rw-r--r--server/initializers/installer.ts12
-rw-r--r--server/initializers/migrations/0075-video-resolutions.ts8
-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/auth/oauth-model.ts20
-rw-r--r--server/lib/client-html.ts24
-rw-r--r--server/lib/hls.ts6
-rw-r--r--server/lib/job-queue/handlers/video-edition.ts229
-rw-r--r--server/lib/job-queue/handlers/video-file-import.ts12
-rw-r--r--server/lib/job-queue/handlers/video-import.ts8
-rw-r--r--server/lib/job-queue/handlers/video-live-ending.ts8
-rw-r--r--server/lib/job-queue/handlers/video-transcoding.ts10
-rw-r--r--server/lib/job-queue/job-queue.ts9
-rw-r--r--server/lib/live/live-manager.ts14
-rw-r--r--server/lib/live/shared/muxing-session.ts4
-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/lib/plugins/plugin-helpers-builder.ts2
-rw-r--r--server/lib/plugins/register-helpers.ts2
-rw-r--r--server/lib/server-config-manager.ts5
-rw-r--r--server/lib/thumbnail.ts5
-rw-r--r--server/lib/transcoding/default-transcoding-profiles.ts (renamed from server/lib/transcoding/video-transcoding-profiles.ts)25
-rw-r--r--server/lib/transcoding/transcoding.ts (renamed from server/lib/transcoding/video-transcoding.ts)35
-rw-r--r--server/lib/user.ts61
-rw-r--r--server/lib/video-editor.ts32
-rw-r--r--server/lib/video.ts6
-rw-r--r--server/middlewares/validators/config.ts14
-rw-r--r--server/middlewares/validators/shared/utils.ts1
-rw-r--r--server/middlewares/validators/shared/videos.ts26
-rw-r--r--server/middlewares/validators/videos/index.ts1
-rw-r--r--server/middlewares/validators/videos/video-captions.ts10
-rw-r--r--server/middlewares/validators/videos/video-comments.ts28
-rw-r--r--server/middlewares/validators/videos/video-editor.ts112
-rw-r--r--server/middlewares/validators/videos/video-ownership-changes.ts21
-rw-r--r--server/middlewares/validators/videos/video-playlists.ts4
-rw-r--r--server/middlewares/validators/videos/video-rates.ts7
-rw-r--r--server/middlewares/validators/videos/videos.ts40
-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/abstract-run-query.ts (renamed from server/models/video/sql/shared/abstract-run-query.ts)10
-rw-r--r--server/models/shared/index.ts2
-rw-r--r--server/models/shared/model-builder.ts101
-rw-r--r--server/models/user/sql/user-notitication-list-query-builder.ts264
-rw-r--r--server/models/user/user-notification.ts272
-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-video-query-builder.ts (renamed from server/models/video/sql/shared/abstract-video-query-builder.ts)27
-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)2
-rw-r--r--server/models/video/sql/video/shared/video-model-builder.ts (renamed from server/models/video/sql/shared/video-model-builder.ts)57
-rw-r--r--server/models/video/sql/video/shared/video-table-attributes.ts (renamed from server/models/video/sql/shared/video-table-attributes.ts)6
-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)2
-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)4
-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)2
-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.ts24
-rw-r--r--server/tests/api/activitypub/refresher.ts6
-rw-r--r--server/tests/api/check-params/config.ts3
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/check-params/video-channels.ts2
-rw-r--r--server/tests/api/check-params/video-editor.ts385
-rw-r--r--server/tests/api/index.ts1
-rw-r--r--server/tests/api/live/live.ts4
-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.ts12
-rw-r--r--server/tests/api/search/search-index.ts14
-rw-r--r--server/tests/api/search/search-playlists.ts10
-rw-r--r--server/tests/api/search/search-videos.ts8
-rw-r--r--server/tests/api/server/config.ts7
-rw-r--r--server/tests/api/server/homepage.ts6
-rw-r--r--server/tests/api/server/stats.ts8
-rw-r--r--server/tests/api/transcoding/audio-only.ts (renamed from server/tests/api/videos/audio-only.ts)7
-rw-r--r--server/tests/api/transcoding/create-transcoding.ts (renamed from server/tests/api/videos/video-create-transcoding.ts)0
-rw-r--r--server/tests/api/transcoding/hls.ts (renamed from server/tests/api/videos/video-hls.ts)0
-rw-r--r--server/tests/api/transcoding/index.ts5
-rw-r--r--server/tests/api/transcoding/transcoder.ts (renamed from server/tests/api/videos/video-transcoder.ts)39
-rw-r--r--server/tests/api/transcoding/video-editor.ts368
-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/index.ts4
-rw-r--r--server/tests/api/videos/multiple-servers.ts24
-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-playlist-thumbnails.ts6
-rw-r--r--server/tests/api/videos/video-playlists.ts8
-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/cli/update-host.ts3
-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/fixtures/peertube-plugin-test/main.js10
-rw-r--r--server/tests/plugins/filter-hooks.ts42
-rw-r--r--server/tests/plugins/plugin-transcoding.ts10
-rw-r--r--server/tests/shared/generate.ts8
-rw-r--r--server/tests/shared/notifications.ts11
-rw-r--r--server/tests/shared/videos.ts13
-rw-r--r--server/types/express.d.ts3
-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
173 files changed, 4922 insertions, 2343 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/config.ts b/server/controllers/api/config.ts
index 4e3dd4d80..821ed4ad3 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -256,6 +256,9 @@ function customConfig (): CustomConfig {
256 } 256 }
257 } 257 }
258 }, 258 },
259 videoEditor: {
260 enabled: CONFIG.VIDEO_EDITOR.ENABLED
261 },
259 import: { 262 import: {
260 videos: { 263 videos: {
261 concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY, 264 concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index 7efc3a137..8a06bfe93 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -3,8 +3,9 @@ import RateLimit from 'express-rate-limit'
3import { tokensRouter } from '@server/controllers/api/users/token' 3import { tokensRouter } from '@server/controllers/api/users/token'
4import { Hooks } from '@server/lib/plugins/hooks' 4import { Hooks } from '@server/lib/plugins/hooks'
5import { OAuthTokenModel } from '@server/models/oauth/oauth-token' 5import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
6import { MUser, MUserAccountDefault } from '@server/types/models' 6import { MUserAccountDefault } from '@server/types/models'
7import { HttpStatusCode, UserAdminFlag, UserCreate, UserCreateResult, UserRegister, UserRight, UserRole, UserUpdate } from '@shared/models' 7import { pick } from '@shared/core-utils'
8import { HttpStatusCode, UserCreate, UserCreateResult, UserRegister, UserRight, UserUpdate } from '@shared/models'
8import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger' 9import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
9import { logger } from '../../../helpers/logger' 10import { logger } from '../../../helpers/logger'
10import { generateRandomString, getFormattedObjects } from '../../../helpers/utils' 11import { generateRandomString, getFormattedObjects } from '../../../helpers/utils'
@@ -14,7 +15,7 @@ import { sequelizeTypescript } from '../../../initializers/database'
14import { Emailer } from '../../../lib/emailer' 15import { Emailer } from '../../../lib/emailer'
15import { Notifier } from '../../../lib/notifier' 16import { Notifier } from '../../../lib/notifier'
16import { Redis } from '../../../lib/redis' 17import { Redis } from '../../../lib/redis'
17import { createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user' 18import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user'
18import { 19import {
19 asyncMiddleware, 20 asyncMiddleware,
20 asyncRetryTransactionMiddleware, 21 asyncRetryTransactionMiddleware,
@@ -175,18 +176,11 @@ export {
175async function createUser (req: express.Request, res: express.Response) { 176async function createUser (req: express.Request, res: express.Response) {
176 const body: UserCreate = req.body 177 const body: UserCreate = req.body
177 178
178 const userToCreate = new UserModel({ 179 const userToCreate = buildUser({
179 username: body.username, 180 ...pick(body, [ 'username', 'password', 'email', 'role', 'videoQuota', 'videoQuotaDaily', 'adminFlags' ]),
180 password: body.password, 181
181 email: body.email, 182 emailVerified: null
182 nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY, 183 })
183 p2pEnabled: CONFIG.DEFAULTS.P2P.WEBAPP.ENABLED,
184 autoPlayVideo: true,
185 role: body.role,
186 videoQuota: body.videoQuota,
187 videoQuotaDaily: body.videoQuotaDaily,
188 adminFlags: body.adminFlags || UserAdminFlag.NONE
189 }) as MUser
190 184
191 // NB: due to the validator usersAddValidator, password==='' can only be true if we can send the mail. 185 // NB: due to the validator usersAddValidator, password==='' can only be true if we can send the mail.
192 const createPassword = userToCreate.password === '' 186 const createPassword = userToCreate.password === ''
@@ -225,16 +219,9 @@ async function createUser (req: express.Request, res: express.Response) {
225async function registerUser (req: express.Request, res: express.Response) { 219async function registerUser (req: express.Request, res: express.Response) {
226 const body: UserRegister = req.body 220 const body: UserRegister = req.body
227 221
228 const userToCreate = new UserModel({ 222 const userToCreate = buildUser({
229 username: body.username, 223 ...pick(body, [ 'username', 'password', 'email' ]),
230 password: body.password, 224
231 email: body.email,
232 nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
233 p2pEnabled: CONFIG.DEFAULTS.P2P.WEBAPP.ENABLED,
234 autoPlayVideo: true,
235 role: UserRole.USER,
236 videoQuota: CONFIG.USER.VIDEO_QUOTA,
237 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY,
238 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null 225 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
239 }) 226 })
240 227
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
index c2ad0b710..595abcf95 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,11 +32,10 @@ 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
37const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR }) 38const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
38 39
39const meRouter = express.Router() 40const meRouter = express.Router()
40 41
@@ -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..2454b1ec9 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'
@@ -11,12 +12,11 @@ import { resetSequelizeInstance } from '../../helpers/database-utils'
11import { buildNSFWFilter, createReqFiles, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' 12import { buildNSFWFilter, createReqFiles, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
12import { logger } from '../../helpers/logger' 13import { logger } from '../../helpers/logger'
13import { getFormattedObjects } from '../../helpers/utils' 14import { getFormattedObjects } from '../../helpers/utils'
14import { CONFIG } from '../../initializers/config'
15import { MIMETYPES } from '../../initializers/constants' 15import { MIMETYPES } from '../../initializers/constants'
16import { sequelizeTypescript } from '../../initializers/database' 16import { sequelizeTypescript } from '../../initializers/database'
17import { sendUpdateActor } from '../../lib/activitypub/send' 17import { sendUpdateActor } from '../../lib/activitypub/send'
18import { JobQueue } from '../../lib/job-queue' 18import { JobQueue } from '../../lib/job-queue'
19import { deleteLocalActorImageFile, updateLocalActorImageFile } from '../../lib/local-actor' 19import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../lib/local-actor'
20import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel' 20import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel'
21import { 21import {
22 asyncMiddleware, 22 asyncMiddleware,
@@ -50,8 +50,8 @@ import { VideoChannelModel } from '../../models/video/video-channel'
50import { VideoPlaylistModel } from '../../models/video/video-playlist' 50import { VideoPlaylistModel } from '../../models/video/video-playlist'
51 51
52const auditLogger = auditLoggerFactory('channels') 52const auditLogger = auditLoggerFactory('channels')
53const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR }) 53const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
54const reqBannerFile = createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { bannerfile: CONFIG.STORAGE.TMP_DIR }) 54const reqBannerFile = createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
55 55
56const videoChannelRouter = express.Router() 56const videoChannelRouter = express.Router()
57 57
@@ -186,11 +186,15 @@ async function updateVideoChannelBanner (req: express.Request, res: express.Resp
186 const videoChannel = res.locals.videoChannel 186 const videoChannel = res.locals.videoChannel
187 const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON()) 187 const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
188 188
189 const banner = await updateLocalActorImageFile(videoChannel, bannerPhysicalFile, ActorImageType.BANNER) 189 const banners = await updateLocalActorImageFiles(videoChannel, bannerPhysicalFile, ActorImageType.BANNER)
190 190
191 auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys) 191 auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
192 192
193 return res.json({ banner: banner.toFormattedJSON() }) 193 return res.json({
194 // TODO: remove, deprecated in 4.2
195 banner: getBiggestActorImage(banners).toFormattedJSON(),
196 banners: banners.map(b => b.toFormattedJSON())
197 })
194} 198}
195 199
196async function updateVideoChannelAvatar (req: express.Request, res: express.Response) { 200async function updateVideoChannelAvatar (req: express.Request, res: express.Response) {
@@ -198,11 +202,14 @@ async function updateVideoChannelAvatar (req: express.Request, res: express.Resp
198 const videoChannel = res.locals.videoChannel 202 const videoChannel = res.locals.videoChannel
199 const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON()) 203 const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
200 204
201 const avatar = await updateLocalActorImageFile(videoChannel, avatarPhysicalFile, ActorImageType.AVATAR) 205 const avatars = await updateLocalActorImageFiles(videoChannel, avatarPhysicalFile, ActorImageType.AVATAR)
202
203 auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys) 206 auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
204 207
205 return res.json({ avatar: avatar.toFormattedJSON() }) 208 return res.json({
209 // TODO: remove, deprecated in 4.2
210 avatar: getBiggestActorImage(avatars).toFormattedJSON(),
211 avatars: avatars.map(a => a.toFormattedJSON())
212 })
206} 213}
207 214
208async function deleteVideoChannelAvatar (req: express.Request, res: express.Response) { 215async function deleteVideoChannelAvatar (req: express.Request, res: express.Response) {
diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts
index 795e14e73..1255d14c6 100644
--- a/server/controllers/api/video-playlist.ts
+++ b/server/controllers/api/video-playlist.ts
@@ -47,7 +47,7 @@ import { AccountModel } from '../../models/account/account'
47import { VideoPlaylistModel } from '../../models/video/video-playlist' 47import { VideoPlaylistModel } from '../../models/video/video-playlist'
48import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' 48import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
49 49
50const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR }) 50const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
51 51
52const videoPlaylistRouter = express.Router() 52const videoPlaylistRouter = express.Router()
53 53
@@ -453,13 +453,19 @@ async function getVideoPlaylistVideos (req: express.Request, res: express.Respon
453 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined 453 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
454 const server = await getServerActor() 454 const server = await getServerActor()
455 455
456 const resultList = await VideoPlaylistElementModel.listForApi({ 456 const apiOptions = await Hooks.wrapObject({
457 start: req.query.start, 457 start: req.query.start,
458 count: req.query.count, 458 count: req.query.count,
459 videoPlaylistId: videoPlaylistInstance.id, 459 videoPlaylistId: videoPlaylistInstance.id,
460 serverAccount: server.Account, 460 serverAccount: server.Account,
461 user 461 user
462 }) 462 }, 'filter:api.video-playlist.videos.list.params')
463
464 const resultList = await Hooks.wrapPromiseFun(
465 VideoPlaylistElementModel.listForApi,
466 apiOptions,
467 'filter:api.video-playlist.videos.list.result'
468 )
463 469
464 const options = { 470 const options = {
465 displayNSFW: buildNSFWFilter(res, req.query.nsfw), 471 displayNSFW: buildNSFWFilter(res, req.query.nsfw),
diff --git a/server/controllers/api/videos/captions.ts b/server/controllers/api/videos/captions.ts
index 2a9a9d233..2b511a398 100644
--- a/server/controllers/api/videos/captions.ts
+++ b/server/controllers/api/videos/captions.ts
@@ -1,26 +1,19 @@
1import express from 'express' 1import express from 'express'
2import { Hooks } from '@server/lib/plugins/hooks'
2import { MVideoCaption } from '@server/types/models' 3import { MVideoCaption } from '@server/types/models'
3import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' 4import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
4import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils' 5import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
5import { createReqFiles } from '../../../helpers/express-utils' 6import { createReqFiles } from '../../../helpers/express-utils'
6import { logger } from '../../../helpers/logger' 7import { logger } from '../../../helpers/logger'
7import { getFormattedObjects } from '../../../helpers/utils' 8import { getFormattedObjects } from '../../../helpers/utils'
8import { CONFIG } from '../../../initializers/config'
9import { MIMETYPES } from '../../../initializers/constants' 9import { MIMETYPES } from '../../../initializers/constants'
10import { sequelizeTypescript } from '../../../initializers/database' 10import { sequelizeTypescript } from '../../../initializers/database'
11import { federateVideoIfNeeded } from '../../../lib/activitypub/videos' 11import { federateVideoIfNeeded } from '../../../lib/activitypub/videos'
12import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' 12import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
13import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators' 13import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators'
14import { VideoCaptionModel } from '../../../models/video/video-caption' 14import { VideoCaptionModel } from '../../../models/video/video-caption'
15import { Hooks } from '@server/lib/plugins/hooks'
16 15
17const reqVideoCaptionAdd = createReqFiles( 16const reqVideoCaptionAdd = createReqFiles([ 'captionfile' ], MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)
18 [ 'captionfile' ],
19 MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT,
20 {
21 captionfile: CONFIG.STORAGE.CAPTIONS_DIR
22 }
23)
24 17
25const videoCaptionsRouter = express.Router() 18const videoCaptionsRouter = express.Router()
26 19
diff --git a/server/controllers/api/videos/editor.ts b/server/controllers/api/videos/editor.ts
new file mode 100644
index 000000000..588cc1a8c
--- /dev/null
+++ b/server/controllers/api/videos/editor.ts
@@ -0,0 +1,118 @@
1import express from 'express'
2import { createAnyReqFiles } from '@server/helpers/express-utils'
3import { MIMETYPES } from '@server/initializers/constants'
4import { JobQueue } from '@server/lib/job-queue'
5import { buildTaskFileFieldname, getTaskFile } from '@server/lib/video-editor'
6import {
7 HttpStatusCode,
8 VideoEditionTaskPayload,
9 VideoEditorCreateEdition,
10 VideoEditorTask,
11 VideoEditorTaskCut,
12 VideoEditorTaskIntro,
13 VideoEditorTaskOutro,
14 VideoEditorTaskWatermark,
15 VideoState
16} from '@shared/models'
17import { asyncMiddleware, authenticate, videosEditorAddEditionValidator } from '../../../middlewares'
18
19const editorRouter = express.Router()
20
21const tasksFiles = createAnyReqFiles(
22 MIMETYPES.VIDEO.MIMETYPE_EXT,
23 (req: express.Request, file: Express.Multer.File, cb: (err: Error, result?: boolean) => void) => {
24 const body = req.body as VideoEditorCreateEdition
25
26 // Fetch array element
27 const matches = file.fieldname.match(/tasks\[(\d+)\]/)
28 if (!matches) return cb(new Error('Cannot find array element indice for ' + file.fieldname))
29
30 const indice = parseInt(matches[1])
31 const task = body.tasks[indice]
32
33 if (!task) return cb(new Error('Cannot find array element of indice ' + indice + ' for ' + file.fieldname))
34
35 if (
36 [ 'add-intro', 'add-outro', 'add-watermark' ].includes(task.name) &&
37 file.fieldname === buildTaskFileFieldname(indice)
38 ) {
39 return cb(null, true)
40 }
41
42 return cb(null, false)
43 }
44)
45
46editorRouter.post('/:videoId/editor/edit',
47 authenticate,
48 tasksFiles,
49 asyncMiddleware(videosEditorAddEditionValidator),
50 asyncMiddleware(createEditionTasks)
51)
52
53// ---------------------------------------------------------------------------
54
55export {
56 editorRouter
57}
58
59// ---------------------------------------------------------------------------
60
61async function createEditionTasks (req: express.Request, res: express.Response) {
62 const files = req.files as Express.Multer.File[]
63 const body = req.body as VideoEditorCreateEdition
64 const video = res.locals.videoAll
65
66 video.state = VideoState.TO_EDIT
67 await video.save()
68
69 const payload = {
70 videoUUID: video.uuid,
71 tasks: body.tasks.map((t, i) => buildTaskPayload(t, i, files))
72 }
73
74 JobQueue.Instance.createJob({ type: 'video-edition', payload })
75
76 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
77}
78
79const taskPayloadBuilders: {
80 [id in VideoEditorTask['name']]: (task: VideoEditorTask, indice?: number, files?: Express.Multer.File[]) => VideoEditionTaskPayload
81} = {
82 'add-intro': buildIntroOutroTask,
83 'add-outro': buildIntroOutroTask,
84 'cut': buildCutTask,
85 'add-watermark': buildWatermarkTask
86}
87
88function buildTaskPayload (task: VideoEditorTask, indice: number, files: Express.Multer.File[]): VideoEditionTaskPayload {
89 return taskPayloadBuilders[task.name](task, indice, files)
90}
91
92function buildIntroOutroTask (task: VideoEditorTaskIntro | VideoEditorTaskOutro, indice: number, files: Express.Multer.File[]) {
93 return {
94 name: task.name,
95 options: {
96 file: getTaskFile(files, indice).path
97 }
98 }
99}
100
101function buildCutTask (task: VideoEditorTaskCut) {
102 return {
103 name: task.name,
104 options: {
105 start: task.options.start,
106 end: task.options.end
107 }
108 }
109}
110
111function buildWatermarkTask (task: VideoEditorTaskWatermark, indice: number, files: Express.Multer.File[]) {
112 return {
113 name: task.name,
114 options: {
115 file: getTaskFile(files, indice).path
116 }
117 }
118}
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index b54fa822c..44283e266 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -61,12 +61,7 @@ const videoImportsRouter = express.Router()
61 61
62const reqVideoFileImport = createReqFiles( 62const reqVideoFileImport = createReqFiles(
63 [ 'thumbnailfile', 'previewfile', 'torrentfile' ], 63 [ 'thumbnailfile', 'previewfile', 'torrentfile' ],
64 Object.assign({}, MIMETYPES.TORRENT.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT), 64 { ...MIMETYPES.TORRENT.MIMETYPE_EXT, ...MIMETYPES.IMAGE.MIMETYPE_EXT }
65 {
66 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
67 previewfile: CONFIG.STORAGE.TMP_DIR,
68 torrentfile: CONFIG.STORAGE.TMP_DIR
69 }
70) 65)
71 66
72videoImportsRouter.post('/imports', 67videoImportsRouter.post('/imports',
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index 61a030ba1..a5ae07d95 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -35,6 +35,7 @@ import { VideoModel } from '../../../models/video/video'
35import { blacklistRouter } from './blacklist' 35import { blacklistRouter } from './blacklist'
36import { videoCaptionsRouter } from './captions' 36import { videoCaptionsRouter } from './captions'
37import { videoCommentRouter } from './comment' 37import { videoCommentRouter } from './comment'
38import { editorRouter } from './editor'
38import { filesRouter } from './files' 39import { filesRouter } from './files'
39import { videoImportsRouter } from './import' 40import { videoImportsRouter } from './import'
40import { liveRouter } from './live' 41import { liveRouter } from './live'
@@ -51,6 +52,7 @@ const videosRouter = express.Router()
51videosRouter.use('/', blacklistRouter) 52videosRouter.use('/', blacklistRouter)
52videosRouter.use('/', rateVideoRouter) 53videosRouter.use('/', rateVideoRouter)
53videosRouter.use('/', videoCommentRouter) 54videosRouter.use('/', videoCommentRouter)
55videosRouter.use('/', editorRouter)
54videosRouter.use('/', videoCaptionsRouter) 56videosRouter.use('/', videoCaptionsRouter)
55videosRouter.use('/', videoImportsRouter) 57videosRouter.use('/', videoImportsRouter)
56videosRouter.use('/', ownershipVideoRouter) 58videosRouter.use('/', ownershipVideoRouter)
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts
index 8b8cacff9..49cabb6f3 100644
--- a/server/controllers/api/videos/live.ts
+++ b/server/controllers/api/videos/live.ts
@@ -1,6 +1,5 @@
1import express from 'express' 1import express from 'express'
2import { createReqFiles } from '@server/helpers/express-utils' 2import { createReqFiles } from '@server/helpers/express-utils'
3import { CONFIG } from '@server/initializers/config'
4import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' 3import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants'
5import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' 4import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
6import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' 5import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
@@ -19,14 +18,7 @@ import { VideoModel } from '../../../models/video/video'
19 18
20const liveRouter = express.Router() 19const liveRouter = express.Router()
21 20
22const reqVideoFileLive = createReqFiles( 21const reqVideoFileLive = createReqFiles([ 'thumbnailfile', 'previewfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
23 [ 'thumbnailfile', 'previewfile' ],
24 MIMETYPES.IMAGE.MIMETYPE_EXT,
25 {
26 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
27 previewfile: CONFIG.STORAGE.TMP_DIR
28 }
29)
30 22
31liveRouter.post('/live', 23liveRouter.post('/live',
32 authenticate, 24 authenticate,
diff --git a/server/controllers/api/videos/transcoding.ts b/server/controllers/api/videos/transcoding.ts
index fba4545c2..da3ea3c9c 100644
--- a/server/controllers/api/videos/transcoding.ts
+++ b/server/controllers/api/videos/transcoding.ts
@@ -1,5 +1,5 @@
1import express from 'express' 1import express from 'express'
2import { computeLowerResolutionsToTranscode } from '@server/helpers/ffprobe-utils' 2import { computeLowerResolutionsToTranscode } from '@server/helpers/ffmpeg'
3import { logger, loggerTagsFactory } from '@server/helpers/logger' 3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { addTranscodingJob } from '@server/lib/video' 4import { addTranscodingJob } from '@server/lib/video'
5import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@shared/models' 5import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@shared/models'
@@ -29,7 +29,7 @@ async function createTranscoding (req: express.Request, res: express.Response) {
29 29
30 const body: VideoTranscodingCreate = req.body 30 const body: VideoTranscodingCreate = req.body
31 31
32 const { resolution: maxResolution, isPortraitMode, audioStream } = await video.getMaxQualityFileInfo() 32 const { resolution: maxResolution, isPortraitMode, audioStream } = await video.probeMaxQualityFile()
33 const resolutions = computeLowerResolutionsToTranscode(maxResolution, 'vod').concat([ maxResolution ]) 33 const resolutions = computeLowerResolutionsToTranscode(maxResolution, 'vod').concat([ maxResolution ])
34 34
35 video.state = VideoState.TO_TRANSCODE 35 video.state = VideoState.TO_TRANSCODE
diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts
index c01d9276a..15899307d 100644
--- a/server/controllers/api/videos/update.ts
+++ b/server/controllers/api/videos/update.ts
@@ -11,7 +11,6 @@ import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../
11import { resetSequelizeInstance } from '../../../helpers/database-utils' 11import { resetSequelizeInstance } from '../../../helpers/database-utils'
12import { createReqFiles } from '../../../helpers/express-utils' 12import { createReqFiles } from '../../../helpers/express-utils'
13import { logger, loggerTagsFactory } from '../../../helpers/logger' 13import { logger, loggerTagsFactory } from '../../../helpers/logger'
14import { CONFIG } from '../../../initializers/config'
15import { MIMETYPES } from '../../../initializers/constants' 14import { MIMETYPES } from '../../../initializers/constants'
16import { sequelizeTypescript } from '../../../initializers/database' 15import { sequelizeTypescript } from '../../../initializers/database'
17import { federateVideoIfNeeded } from '../../../lib/activitypub/videos' 16import { federateVideoIfNeeded } from '../../../lib/activitypub/videos'
@@ -26,14 +25,7 @@ const lTags = loggerTagsFactory('api', 'video')
26const auditLogger = auditLoggerFactory('videos') 25const auditLogger = auditLoggerFactory('videos')
27const updateRouter = express.Router() 26const updateRouter = express.Router()
28 27
29const reqVideoFileUpdate = createReqFiles( 28const reqVideoFileUpdate = createReqFiles([ 'thumbnailfile', 'previewfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
30 [ 'thumbnailfile', 'previewfile' ],
31 MIMETYPES.IMAGE.MIMETYPE_EXT,
32 {
33 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
34 previewfile: CONFIG.STORAGE.TMP_DIR
35 }
36)
37 29
38updateRouter.put('/:id', 30updateRouter.put('/:id',
39 openapiOperationDoc({ operationId: 'putVideo' }), 31 openapiOperationDoc({ operationId: 'putVideo' }),
diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts
index fd90d9915..dd69cf238 100644
--- a/server/controllers/api/videos/upload.ts
+++ b/server/controllers/api/videos/upload.ts
@@ -24,9 +24,8 @@ import { HttpStatusCode, VideoCreate, VideoResolution, VideoState } from '@share
24import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 24import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
25import { retryTransactionWrapper } from '../../../helpers/database-utils' 25import { retryTransactionWrapper } from '../../../helpers/database-utils'
26import { createReqFiles } from '../../../helpers/express-utils' 26import { createReqFiles } from '../../../helpers/express-utils'
27import { ffprobePromise, getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' 27import { buildFileMetadata, ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '../../../helpers/ffmpeg'
28import { logger, loggerTagsFactory } from '../../../helpers/logger' 28import { logger, loggerTagsFactory } from '../../../helpers/logger'
29import { CONFIG } from '../../../initializers/config'
30import { MIMETYPES } from '../../../initializers/constants' 29import { MIMETYPES } from '../../../initializers/constants'
31import { sequelizeTypescript } from '../../../initializers/database' 30import { sequelizeTypescript } from '../../../initializers/database'
32import { federateVideoIfNeeded } from '../../../lib/activitypub/videos' 31import { federateVideoIfNeeded } from '../../../lib/activitypub/videos'
@@ -52,21 +51,13 @@ const uploadRouter = express.Router()
52 51
53const reqVideoFileAdd = createReqFiles( 52const reqVideoFileAdd = createReqFiles(
54 [ 'videofile', 'thumbnailfile', 'previewfile' ], 53 [ 'videofile', 'thumbnailfile', 'previewfile' ],
55 Object.assign({}, MIMETYPES.VIDEO.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT), 54 { ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.IMAGE.MIMETYPE_EXT }
56 {
57 videofile: CONFIG.STORAGE.TMP_DIR,
58 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
59 previewfile: CONFIG.STORAGE.TMP_DIR
60 }
61) 55)
62 56
63const reqVideoFileAddResumable = createReqFiles( 57const reqVideoFileAddResumable = createReqFiles(
64 [ 'thumbnailfile', 'previewfile' ], 58 [ 'thumbnailfile', 'previewfile' ],
65 MIMETYPES.IMAGE.MIMETYPE_EXT, 59 MIMETYPES.IMAGE.MIMETYPE_EXT,
66 { 60 getResumableUploadPath()
67 thumbnailfile: getResumableUploadPath(),
68 previewfile: getResumableUploadPath()
69 }
70) 61)
71 62
72uploadRouter.post('/upload', 63uploadRouter.post('/upload',
@@ -246,7 +237,7 @@ async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
246 extname: getLowercaseExtension(videoPhysicalFile.filename), 237 extname: getLowercaseExtension(videoPhysicalFile.filename),
247 size: videoPhysicalFile.size, 238 size: videoPhysicalFile.size,
248 videoStreamingPlaylistId: null, 239 videoStreamingPlaylistId: null,
249 metadata: await getMetadataFromFile(videoPhysicalFile.path) 240 metadata: await buildFileMetadata(videoPhysicalFile.path)
250 }) 241 })
251 242
252 const probe = await ffprobePromise(videoPhysicalFile.path) 243 const probe = await ffprobePromise(videoPhysicalFile.path)
@@ -254,8 +245,8 @@ async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
254 if (await isAudioFile(videoPhysicalFile.path, probe)) { 245 if (await isAudioFile(videoPhysicalFile.path, probe)) {
255 videoFile.resolution = VideoResolution.H_NOVIDEO 246 videoFile.resolution = VideoResolution.H_NOVIDEO
256 } else { 247 } else {
257 videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path, probe) 248 videoFile.fps = await getVideoStreamFPS(videoPhysicalFile.path, probe)
258 videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path, probe)).resolution 249 videoFile.resolution = (await getVideoStreamDimensionsInfo(videoPhysicalFile.path, probe)).resolution
259 } 250 }
260 251
261 videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname) 252 videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
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/helpers/custom-validators/actor-images.ts b/server/helpers/custom-validators/actor-images.ts
index 4fb0b7c70..89f5a2262 100644
--- a/server/helpers/custom-validators/actor-images.ts
+++ b/server/helpers/custom-validators/actor-images.ts
@@ -1,4 +1,5 @@
1 1
2import { UploadFilesForCheck } from 'express'
2import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 3import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
3import { isFileValid } from './misc' 4import { isFileValid } from './misc'
4 5
@@ -6,8 +7,14 @@ const imageMimeTypes = CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
6 .map(v => v.replace('.', '')) 7 .map(v => v.replace('.', ''))
7 .join('|') 8 .join('|')
8const imageMimeTypesRegex = `image/(${imageMimeTypes})` 9const imageMimeTypesRegex = `image/(${imageMimeTypes})`
9function isActorImageFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], fieldname: string) { 10
10 return isFileValid(files, imageMimeTypesRegex, fieldname, CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max) 11function isActorImageFile (files: UploadFilesForCheck, fieldname: string) {
12 return isFileValid({
13 files,
14 mimeTypeRegex: imageMimeTypesRegex,
15 field: fieldname,
16 maxSize: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
17 })
11} 18}
12 19
13// --------------------------------------------------------------------------- 20// ---------------------------------------------------------------------------
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index 81a60ee66..c80c86193 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -61,75 +61,43 @@ function isIntOrNull (value: any) {
61 61
62// --------------------------------------------------------------------------- 62// ---------------------------------------------------------------------------
63 63
64function isFileFieldValid ( 64function isFileValid (options: {
65 files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], 65 files: UploadFilesForCheck
66 field: string,
67 optional = false
68) {
69 // Should have files
70 if (!files) return optional
71 if (isArray(files)) return optional
72 66
73 // Should have a file 67 maxSize: number | null
74 const fileArray = files[field] 68 mimeTypeRegex: string | null
75 if (!fileArray || fileArray.length === 0) {
76 return optional
77 }
78 69
79 // The file should exist 70 field?: string
80 const file = fileArray[0]
81 if (!file || !file.originalname) return false
82 return file
83}
84 71
85function isFileMimeTypeValid ( 72 optional?: boolean // Default false
86 files: UploadFilesForCheck, 73}) {
87 mimeTypeRegex: string, 74 const { files, mimeTypeRegex, field, maxSize, optional = false } = options
88 field: string,
89 optional = false
90) {
91 // Should have files
92 if (!files) return optional
93 if (isArray(files)) return optional
94 75
95 // Should have a file
96 const fileArray = files[field]
97 if (!fileArray || fileArray.length === 0) {
98 return optional
99 }
100
101 // The file should exist
102 const file = fileArray[0]
103 if (!file || !file.originalname) return false
104
105 return new RegExp(`^${mimeTypeRegex}$`, 'i').test(file.mimetype)
106}
107
108function isFileValid (
109 files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
110 mimeTypeRegex: string,
111 field: string,
112 maxSize: number | null,
113 optional = false
114) {
115 // Should have files 76 // Should have files
116 if (!files) return optional 77 if (!files) return optional
117 if (isArray(files)) return optional
118 78
119 // Should have a file 79 const fileArray = isArray(files)
120 const fileArray = files[field] 80 ? files
121 if (!fileArray || fileArray.length === 0) { 81 : files[field]
82
83 if (!fileArray || !isArray(fileArray) || fileArray.length === 0) {
122 return optional 84 return optional
123 } 85 }
124 86
125 // The file should exist 87 // The file exists
126 const file = fileArray[0] 88 const file = fileArray[0]
127 if (!file || !file.originalname) return false 89 if (!file || !file.originalname) return false
128 90
129 // Check size 91 // Check size
130 if ((maxSize !== null) && file.size > maxSize) return false 92 if ((maxSize !== null) && file.size > maxSize) return false
131 93
132 return new RegExp(`^${mimeTypeRegex}$`, 'i').test(file.mimetype) 94 if (mimeTypeRegex === null) return true
95
96 return checkMimetypeRegex(file.mimetype, mimeTypeRegex)
97}
98
99function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
100 return new RegExp(`^${mimeTypeRegex}$`, 'i').test(fileMimeType)
133} 101}
134 102
135// --------------------------------------------------------------------------- 103// ---------------------------------------------------------------------------
@@ -204,7 +172,6 @@ export {
204 areUUIDsValid, 172 areUUIDsValid,
205 toArray, 173 toArray,
206 toIntArray, 174 toIntArray,
207 isFileFieldValid, 175 isFileValid,
208 isFileMimeTypeValid, 176 checkMimetypeRegex
209 isFileValid
210} 177}
diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts
index 4cc7dcaf4..59ba005fe 100644
--- a/server/helpers/custom-validators/video-captions.ts
+++ b/server/helpers/custom-validators/video-captions.ts
@@ -1,5 +1,6 @@
1import { getFileSize } from '@shared/extra-utils' 1import { UploadFilesForCheck } from 'express'
2import { readFile } from 'fs-extra' 2import { readFile } from 'fs-extra'
3import { getFileSize } from '@shared/extra-utils'
3import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants' 4import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants'
4import { exists, isFileValid } from './misc' 5import { exists, isFileValid } from './misc'
5 6
@@ -11,8 +12,13 @@ const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT
11 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 12 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
12 .map(m => `(${m})`) 13 .map(m => `(${m})`)
13 .join('|') 14 .join('|')
14function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) { 15function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
15 return isFileValid(files, videoCaptionTypesRegex, field, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max) 16 return isFileValid({
17 files,
18 mimeTypeRegex: videoCaptionTypesRegex,
19 field,
20 maxSize: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
21 })
16} 22}
17 23
18async function isVTTFileValid (filePath: string) { 24async function isVTTFileValid (filePath: string) {
diff --git a/server/helpers/custom-validators/video-editor.ts b/server/helpers/custom-validators/video-editor.ts
new file mode 100644
index 000000000..09238675e
--- /dev/null
+++ b/server/helpers/custom-validators/video-editor.ts
@@ -0,0 +1,52 @@
1import validator from 'validator'
2import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
3import { buildTaskFileFieldname } from '@server/lib/video-editor'
4import { VideoEditorTask } from '@shared/models'
5import { isArray } from './misc'
6import { isVideoFileMimeTypeValid, isVideoImageValid } from './videos'
7
8function isValidEditorTasksArray (tasks: any) {
9 if (!isArray(tasks)) return false
10
11 return tasks.length >= CONSTRAINTS_FIELDS.VIDEO_EDITOR.TASKS.min &&
12 tasks.length <= CONSTRAINTS_FIELDS.VIDEO_EDITOR.TASKS.max
13}
14
15function isEditorCutTaskValid (task: VideoEditorTask) {
16 if (task.name !== 'cut') return false
17 if (!task.options) return false
18
19 const { start, end } = task.options
20 if (!start && !end) return false
21
22 if (start && !validator.isInt(start + '', CONSTRAINTS_FIELDS.VIDEO_EDITOR.CUT_TIME)) return false
23 if (end && !validator.isInt(end + '', CONSTRAINTS_FIELDS.VIDEO_EDITOR.CUT_TIME)) return false
24
25 if (!start || !end) return true
26
27 return parseInt(start + '') < parseInt(end + '')
28}
29
30function isEditorTaskAddIntroOutroValid (task: VideoEditorTask, indice: number, files: Express.Multer.File[]) {
31 const file = files.find(f => f.fieldname === buildTaskFileFieldname(indice, 'file'))
32
33 return (task.name === 'add-intro' || task.name === 'add-outro') &&
34 file && isVideoFileMimeTypeValid([ file ], null)
35}
36
37function isEditorTaskAddWatermarkValid (task: VideoEditorTask, indice: number, files: Express.Multer.File[]) {
38 const file = files.find(f => f.fieldname === buildTaskFileFieldname(indice, 'file'))
39
40 return task.name === 'add-watermark' &&
41 file && isVideoImageValid([ file ], null, true)
42}
43
44// ---------------------------------------------------------------------------
45
46export {
47 isValidEditorTasksArray,
48
49 isEditorCutTaskValid,
50 isEditorTaskAddIntroOutroValid,
51 isEditorTaskAddWatermarkValid
52}
diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts
index dbf6a3504..af93aea56 100644
--- a/server/helpers/custom-validators/video-imports.ts
+++ b/server/helpers/custom-validators/video-imports.ts
@@ -1,4 +1,5 @@
1import 'multer' 1import 'multer'
2import { UploadFilesForCheck } from 'express'
2import validator from 'validator' 3import validator from 'validator'
3import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants' 4import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants'
4import { exists, isFileValid } from './misc' 5import { exists, isFileValid } from './misc'
@@ -25,8 +26,14 @@ const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT)
25 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 26 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
26 .map(m => `(${m})`) 27 .map(m => `(${m})`)
27 .join('|') 28 .join('|')
28function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { 29function isVideoImportTorrentFile (files: UploadFilesForCheck) {
29 return isFileValid(files, videoTorrentImportRegex, 'torrentfile', CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max, true) 30 return isFileValid({
31 files,
32 mimeTypeRegex: videoTorrentImportRegex,
33 field: 'torrentfile',
34 maxSize: CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max,
35 optional: true
36 })
30} 37}
31 38
32// --------------------------------------------------------------------------- 39// ---------------------------------------------------------------------------
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index e526c4284..ca5f70fdc 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -13,7 +13,7 @@ import {
13 VIDEO_RATE_TYPES, 13 VIDEO_RATE_TYPES,
14 VIDEO_STATES 14 VIDEO_STATES
15} from '../../initializers/constants' 15} from '../../initializers/constants'
16import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc' 16import { exists, isArray, isDateValid, isFileValid } from './misc'
17 17
18const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS 18const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
19 19
@@ -66,7 +66,7 @@ function isVideoTagValid (tag: string) {
66 return exists(tag) && validator.isLength(tag, VIDEOS_CONSTRAINTS_FIELDS.TAG) 66 return exists(tag) && validator.isLength(tag, VIDEOS_CONSTRAINTS_FIELDS.TAG)
67} 67}
68 68
69function isVideoTagsValid (tags: string[]) { 69function areVideoTagsValid (tags: string[]) {
70 return tags === null || ( 70 return tags === null || (
71 isArray(tags) && 71 isArray(tags) &&
72 validator.isInt(tags.length.toString(), VIDEOS_CONSTRAINTS_FIELDS.TAGS) && 72 validator.isInt(tags.length.toString(), VIDEOS_CONSTRAINTS_FIELDS.TAGS) &&
@@ -86,8 +86,13 @@ function isVideoFileExtnameValid (value: string) {
86 return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined) 86 return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined)
87} 87}
88 88
89function isVideoFileMimeTypeValid (files: UploadFilesForCheck) { 89function isVideoFileMimeTypeValid (files: UploadFilesForCheck, field = 'videofile') {
90 return isFileMimeTypeValid(files, MIMETYPES.VIDEO.MIMETYPES_REGEX, 'videofile') 90 return isFileValid({
91 files,
92 mimeTypeRegex: MIMETYPES.VIDEO.MIMETYPES_REGEX,
93 field,
94 maxSize: null
95 })
91} 96}
92 97
93const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME 98const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
@@ -95,8 +100,14 @@ const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
95 .join('|') 100 .join('|')
96const videoImageTypesRegex = `image/(${videoImageTypes})` 101const videoImageTypesRegex = `image/(${videoImageTypes})`
97 102
98function isVideoImage (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) { 103function isVideoImageValid (files: UploadFilesForCheck, field: string, optional = true) {
99 return isFileValid(files, videoImageTypesRegex, field, CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max, true) 104 return isFileValid({
105 files,
106 mimeTypeRegex: videoImageTypesRegex,
107 field,
108 maxSize: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max,
109 optional
110 })
100} 111}
101 112
102function isVideoPrivacyValid (value: number) { 113function isVideoPrivacyValid (value: number) {
@@ -144,7 +155,7 @@ export {
144 isVideoDescriptionValid, 155 isVideoDescriptionValid,
145 isVideoFileInfoHashValid, 156 isVideoFileInfoHashValid,
146 isVideoNameValid, 157 isVideoNameValid,
147 isVideoTagsValid, 158 areVideoTagsValid,
148 isVideoFPSResolutionValid, 159 isVideoFPSResolutionValid,
149 isScheduleVideoUpdatePrivacyValid, 160 isScheduleVideoUpdatePrivacyValid,
150 isVideoOriginallyPublishedAtValid, 161 isVideoOriginallyPublishedAtValid,
@@ -160,7 +171,7 @@ export {
160 isVideoPrivacyValid, 171 isVideoPrivacyValid,
161 isVideoFileResolutionValid, 172 isVideoFileResolutionValid,
162 isVideoFileSizeValid, 173 isVideoFileSizeValid,
163 isVideoImage, 174 isVideoImageValid,
164 isVideoSupportValid, 175 isVideoSupportValid,
165 isVideoFilterValid 176 isVideoFilterValid
166} 177}
diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts
index 780fd6345..82dd4c178 100644
--- a/server/helpers/express-utils.ts
+++ b/server/helpers/express-utils.ts
@@ -1,9 +1,9 @@
1import express, { RequestHandler } from 'express' 1import express, { RequestHandler } from 'express'
2import multer, { diskStorage } from 'multer' 2import multer, { diskStorage } from 'multer'
3import { getLowercaseExtension } from '@shared/core-utils'
3import { HttpStatusCode } from '../../shared/models/http/http-error-codes' 4import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
4import { CONFIG } from '../initializers/config' 5import { CONFIG } from '../initializers/config'
5import { REMOTE_SCHEME } from '../initializers/constants' 6import { REMOTE_SCHEME } from '../initializers/constants'
6import { getLowercaseExtension } from '@shared/core-utils'
7import { isArray } from './custom-validators/misc' 7import { isArray } from './custom-validators/misc'
8import { logger } from './logger' 8import { logger } from './logger'
9import { deleteFileAndCatch, generateRandomString } from './utils' 9import { deleteFileAndCatch, generateRandomString } from './utils'
@@ -68,36 +68,15 @@ function badRequest (_req: express.Request, res: express.Response) {
68function createReqFiles ( 68function createReqFiles (
69 fieldNames: string[], 69 fieldNames: string[],
70 mimeTypes: { [id: string]: string | string[] }, 70 mimeTypes: { [id: string]: string | string[] },
71 destinations: { [fieldName: string]: string } 71 destination = CONFIG.STORAGE.TMP_DIR
72): RequestHandler { 72): RequestHandler {
73 const storage = diskStorage({ 73 const storage = diskStorage({
74 destination: (req, file, cb) => { 74 destination: (req, file, cb) => {
75 cb(null, destinations[file.fieldname]) 75 cb(null, destination)
76 }, 76 },
77 77
78 filename: async (req, file, cb) => { 78 filename: (req, file, cb) => {
79 let extension: string 79 return generateReqFilename(file, mimeTypes, cb)
80 const fileExtension = getLowercaseExtension(file.originalname)
81 const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype)
82
83 // Take the file extension if we don't understand the mime type
84 if (!extensionFromMimetype) {
85 extension = fileExtension
86 } else {
87 // Take the first available extension for this mimetype
88 extension = extensionFromMimetype
89 }
90
91 let randomString = ''
92
93 try {
94 randomString = await generateRandomString(16)
95 } catch (err) {
96 logger.error('Cannot generate random string for file name.', { err })
97 randomString = 'fake-random-string'
98 }
99
100 cb(null, randomString + extension)
101 } 80 }
102 }) 81 })
103 82
@@ -112,6 +91,23 @@ function createReqFiles (
112 return multer({ storage }).fields(fields) 91 return multer({ storage }).fields(fields)
113} 92}
114 93
94function createAnyReqFiles (
95 mimeTypes: { [id: string]: string | string[] },
96 fileFilter: (req: express.Request, file: Express.Multer.File, cb: (err: Error, result: boolean) => void) => void
97): RequestHandler {
98 const storage = diskStorage({
99 destination: (req, file, cb) => {
100 cb(null, CONFIG.STORAGE.TMP_DIR)
101 },
102
103 filename: (req, file, cb) => {
104 return generateReqFilename(file, mimeTypes, cb)
105 }
106 })
107
108 return multer({ storage, fileFilter }).any()
109}
110
115function isUserAbleToSearchRemoteURI (res: express.Response) { 111function isUserAbleToSearchRemoteURI (res: express.Response) {
116 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined 112 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
117 113
@@ -128,9 +124,41 @@ function getCountVideos (req: express.Request) {
128export { 124export {
129 buildNSFWFilter, 125 buildNSFWFilter,
130 getHostWithPort, 126 getHostWithPort,
127 createAnyReqFiles,
131 isUserAbleToSearchRemoteURI, 128 isUserAbleToSearchRemoteURI,
132 badRequest, 129 badRequest,
133 createReqFiles, 130 createReqFiles,
134 cleanUpReqFiles, 131 cleanUpReqFiles,
135 getCountVideos 132 getCountVideos
136} 133}
134
135// ---------------------------------------------------------------------------
136
137async function generateReqFilename (
138 file: Express.Multer.File,
139 mimeTypes: { [id: string]: string | string[] },
140 cb: (err: Error, name: string) => void
141) {
142 let extension: string
143 const fileExtension = getLowercaseExtension(file.originalname)
144 const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype)
145
146 // Take the file extension if we don't understand the mime type
147 if (!extensionFromMimetype) {
148 extension = fileExtension
149 } else {
150 // Take the first available extension for this mimetype
151 extension = extensionFromMimetype
152 }
153
154 let randomString = ''
155
156 try {
157 randomString = await generateRandomString(16)
158 } catch (err) {
159 logger.error('Cannot generate random string for file name.', { err })
160 randomString = 'fake-random-string'
161 }
162
163 cb(null, randomString + extension)
164}
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
deleted file mode 100644
index 78ee5fa7f..000000000
--- a/server/helpers/ffmpeg-utils.ts
+++ /dev/null
@@ -1,781 +0,0 @@
1import { Job } from 'bull'
2import ffmpeg, { FfmpegCommand, FilterSpecification, getAvailableEncoders } from 'fluent-ffmpeg'
3import { readFile, remove, writeFile } from 'fs-extra'
4import { dirname, join } from 'path'
5import { FFMPEG_NICE, VIDEO_LIVE } from '@server/initializers/constants'
6import { pick } from '@shared/core-utils'
7import {
8 AvailableEncoders,
9 EncoderOptions,
10 EncoderOptionsBuilder,
11 EncoderOptionsBuilderParams,
12 EncoderProfile,
13 VideoResolution
14} from '../../shared/models/videos'
15import { CONFIG } from '../initializers/config'
16import { execPromise, promisify0 } from './core-utils'
17import { computeFPS, ffprobePromise, getAudioStream, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from './ffprobe-utils'
18import { processImage } from './image-utils'
19import { logger, loggerTagsFactory } from './logger'
20
21const lTags = loggerTagsFactory('ffmpeg')
22
23/**
24 *
25 * Functions that run transcoding/muxing ffmpeg processes
26 * Mainly called by lib/video-transcoding.ts and lib/live-manager.ts
27 *
28 */
29
30// ---------------------------------------------------------------------------
31// Encoder options
32// ---------------------------------------------------------------------------
33
34type StreamType = 'audio' | 'video'
35
36// ---------------------------------------------------------------------------
37// Encoders support
38// ---------------------------------------------------------------------------
39
40// Detect supported encoders by ffmpeg
41let supportedEncoders: Map<string, boolean>
42async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
43 if (supportedEncoders !== undefined) {
44 return supportedEncoders
45 }
46
47 const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
48 const availableFFmpegEncoders = await getAvailableEncodersPromise()
49
50 const searchEncoders = new Set<string>()
51 for (const type of [ 'live', 'vod' ]) {
52 for (const streamType of [ 'audio', 'video' ]) {
53 for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
54 searchEncoders.add(encoder)
55 }
56 }
57 }
58
59 supportedEncoders = new Map<string, boolean>()
60
61 for (const searchEncoder of searchEncoders) {
62 supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
63 }
64
65 logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...lTags() })
66
67 return supportedEncoders
68}
69
70function resetSupportedEncoders () {
71 supportedEncoders = undefined
72}
73
74// ---------------------------------------------------------------------------
75// Image manipulation
76// ---------------------------------------------------------------------------
77
78function convertWebPToJPG (path: string, destination: string): Promise<void> {
79 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
80 .output(destination)
81
82 return runCommand({ command, silent: true })
83}
84
85function processGIF (
86 path: string,
87 destination: string,
88 newSize: { width: number, height: number }
89): Promise<void> {
90 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
91 .fps(20)
92 .size(`${newSize.width}x${newSize.height}`)
93 .output(destination)
94
95 return runCommand({ command })
96}
97
98async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
99 const pendingImageName = 'pending-' + imageName
100
101 const options = {
102 filename: pendingImageName,
103 count: 1,
104 folder
105 }
106
107 const pendingImagePath = join(folder, pendingImageName)
108
109 try {
110 await new Promise<string>((res, rej) => {
111 ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
112 .on('error', rej)
113 .on('end', () => res(imageName))
114 .thumbnail(options)
115 })
116
117 const destination = join(folder, imageName)
118 await processImage(pendingImagePath, destination, size)
119 } catch (err) {
120 logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })
121
122 try {
123 await remove(pendingImagePath)
124 } catch (err) {
125 logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
126 }
127 }
128}
129
130// ---------------------------------------------------------------------------
131// Transcode meta function
132// ---------------------------------------------------------------------------
133
134type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
135
136interface BaseTranscodeOptions {
137 type: TranscodeOptionsType
138
139 inputPath: string
140 outputPath: string
141
142 availableEncoders: AvailableEncoders
143 profile: string
144
145 resolution: number
146
147 isPortraitMode?: boolean
148
149 job?: Job
150}
151
152interface HLSTranscodeOptions extends BaseTranscodeOptions {
153 type: 'hls'
154 copyCodecs: boolean
155 hlsPlaylist: {
156 videoFilename: string
157 }
158}
159
160interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions {
161 type: 'hls-from-ts'
162
163 isAAC: boolean
164
165 hlsPlaylist: {
166 videoFilename: string
167 }
168}
169
170interface QuickTranscodeOptions extends BaseTranscodeOptions {
171 type: 'quick-transcode'
172}
173
174interface VideoTranscodeOptions extends BaseTranscodeOptions {
175 type: 'video'
176}
177
178interface MergeAudioTranscodeOptions extends BaseTranscodeOptions {
179 type: 'merge-audio'
180 audioPath: string
181}
182
183interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions {
184 type: 'only-audio'
185}
186
187type TranscodeOptions =
188 HLSTranscodeOptions
189 | HLSFromTSTranscodeOptions
190 | VideoTranscodeOptions
191 | MergeAudioTranscodeOptions
192 | OnlyAudioTranscodeOptions
193 | QuickTranscodeOptions
194
195const builders: {
196 [ type in TranscodeOptionsType ]: (c: FfmpegCommand, o?: TranscodeOptions) => Promise<FfmpegCommand> | FfmpegCommand
197} = {
198 'quick-transcode': buildQuickTranscodeCommand,
199 'hls': buildHLSVODCommand,
200 'hls-from-ts': buildHLSVODFromTSCommand,
201 'merge-audio': buildAudioMergeCommand,
202 'only-audio': buildOnlyAudioCommand,
203 'video': buildx264VODCommand
204}
205
206async function transcode (options: TranscodeOptions) {
207 logger.debug('Will run transcode.', { options, ...lTags() })
208
209 let command = getFFmpeg(options.inputPath, 'vod')
210 .output(options.outputPath)
211
212 command = await builders[options.type](command, options)
213
214 await runCommand({ command, job: options.job })
215
216 await fixHLSPlaylistIfNeeded(options)
217}
218
219// ---------------------------------------------------------------------------
220// Live muxing/transcoding functions
221// ---------------------------------------------------------------------------
222
223async function getLiveTranscodingCommand (options: {
224 inputUrl: string
225
226 outPath: string
227 masterPlaylistName: string
228
229 resolutions: number[]
230
231 // Input information
232 fps: number
233 bitrate: number
234 ratio: number
235
236 availableEncoders: AvailableEncoders
237 profile: string
238}) {
239 const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options
240
241 const command = getFFmpeg(inputUrl, 'live')
242
243 const varStreamMap: string[] = []
244
245 const complexFilter: FilterSpecification[] = [
246 {
247 inputs: '[v:0]',
248 filter: 'split',
249 options: resolutions.length,
250 outputs: resolutions.map(r => `vtemp${r}`)
251 }
252 ]
253
254 command.outputOption('-sc_threshold 0')
255
256 addDefaultEncoderGlobalParams({ command })
257
258 for (let i = 0; i < resolutions.length; i++) {
259 const resolution = resolutions[i]
260 const resolutionFPS = computeFPS(fps, resolution)
261
262 const baseEncoderBuilderParams = {
263 input: inputUrl,
264
265 availableEncoders,
266 profile,
267
268 inputBitrate: bitrate,
269 inputRatio: ratio,
270
271 resolution,
272 fps: resolutionFPS,
273
274 streamNum: i,
275 videoType: 'live' as 'live'
276 }
277
278 {
279 const streamType: StreamType = 'video'
280 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
281 if (!builderResult) {
282 throw new Error('No available live video encoder found')
283 }
284
285 command.outputOption(`-map [vout${resolution}]`)
286
287 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
288
289 logger.debug(
290 'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile,
291 { builderResult, fps: resolutionFPS, resolution, ...lTags() }
292 )
293
294 command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
295 applyEncoderOptions(command, builderResult.result)
296
297 complexFilter.push({
298 inputs: `vtemp${resolution}`,
299 filter: getScaleFilter(builderResult.result),
300 options: `w=-2:h=${resolution}`,
301 outputs: `vout${resolution}`
302 })
303 }
304
305 {
306 const streamType: StreamType = 'audio'
307 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
308 if (!builderResult) {
309 throw new Error('No available live audio encoder found')
310 }
311
312 command.outputOption('-map a:0')
313
314 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
315
316 logger.debug(
317 'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile,
318 { builderResult, fps: resolutionFPS, resolution, ...lTags() }
319 )
320
321 command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
322 applyEncoderOptions(command, builderResult.result)
323 }
324
325 varStreamMap.push(`v:${i},a:${i}`)
326 }
327
328 command.complexFilter(complexFilter)
329
330 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
331
332 command.outputOption('-var_stream_map', varStreamMap.join(' '))
333
334 return command
335}
336
337function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) {
338 const command = getFFmpeg(inputUrl, 'live')
339
340 command.outputOption('-c:v copy')
341 command.outputOption('-c:a copy')
342 command.outputOption('-map 0:a?')
343 command.outputOption('-map 0:v?')
344
345 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
346
347 return command
348}
349
350function buildStreamSuffix (base: string, streamNum?: number) {
351 if (streamNum !== undefined) {
352 return `${base}:${streamNum}`
353 }
354
355 return base
356}
357
358// ---------------------------------------------------------------------------
359// Default options
360// ---------------------------------------------------------------------------
361
362function addDefaultEncoderGlobalParams (options: {
363 command: FfmpegCommand
364}) {
365 const { command } = options
366
367 // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
368 command.outputOption('-max_muxing_queue_size 1024')
369 // strip all metadata
370 .outputOption('-map_metadata -1')
371 // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
372 .outputOption('-pix_fmt yuv420p')
373}
374
375function addDefaultEncoderParams (options: {
376 command: FfmpegCommand
377 encoder: 'libx264' | string
378 streamNum?: number
379 fps?: number
380}) {
381 const { command, encoder, fps, streamNum } = options
382
383 if (encoder === 'libx264') {
384 // 3.1 is the minimal resource allocation for our highest supported resolution
385 command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
386
387 if (fps) {
388 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
389 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
390 // https://superuser.com/a/908325
391 command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
392 }
393 }
394}
395
396function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) {
397 command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
398 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
399 command.outputOption('-hls_flags delete_segments+independent_segments')
400 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
401 command.outputOption('-master_pl_name ' + masterPlaylistName)
402 command.outputOption(`-f hls`)
403
404 command.output(join(outPath, '%v.m3u8'))
405}
406
407// ---------------------------------------------------------------------------
408// Transcode VOD command builders
409// ---------------------------------------------------------------------------
410
411async function buildx264VODCommand (command: FfmpegCommand, options: TranscodeOptions) {
412 let fps = await getVideoFileFPS(options.inputPath)
413 fps = computeFPS(fps, options.resolution)
414
415 let scaleFilterValue: string
416
417 if (options.resolution !== undefined) {
418 scaleFilterValue = options.isPortraitMode === true
419 ? `w=${options.resolution}:h=-2`
420 : `w=-2:h=${options.resolution}`
421 }
422
423 command = await presetVideo({ command, input: options.inputPath, transcodeOptions: options, fps, scaleFilterValue })
424
425 return command
426}
427
428async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) {
429 command = command.loop(undefined)
430
431 const scaleFilterValue = getScaleCleanerValue()
432 command = await presetVideo({ command, input: options.audioPath, transcodeOptions: options, scaleFilterValue })
433
434 command.outputOption('-preset:v veryfast')
435
436 command = command.input(options.audioPath)
437 .outputOption('-tune stillimage')
438 .outputOption('-shortest')
439
440 return command
441}
442
443function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
444 command = presetOnlyAudio(command)
445
446 return command
447}
448
449function buildQuickTranscodeCommand (command: FfmpegCommand) {
450 command = presetCopy(command)
451
452 command = command.outputOption('-map_metadata -1') // strip all metadata
453 .outputOption('-movflags faststart')
454
455 return command
456}
457
458function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
459 return command.outputOption('-hls_time 4')
460 .outputOption('-hls_list_size 0')
461 .outputOption('-hls_playlist_type vod')
462 .outputOption('-hls_segment_filename ' + outputPath)
463 .outputOption('-hls_segment_type fmp4')
464 .outputOption('-f hls')
465 .outputOption('-hls_flags single_file')
466}
467
468async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) {
469 const videoPath = getHLSVideoPath(options)
470
471 if (options.copyCodecs) command = presetCopy(command)
472 else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
473 else command = await buildx264VODCommand(command, options)
474
475 addCommonHLSVODCommandOptions(command, videoPath)
476
477 return command
478}
479
480function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) {
481 const videoPath = getHLSVideoPath(options)
482
483 command.outputOption('-c copy')
484
485 if (options.isAAC) {
486 // Required for example when copying an AAC stream from an MPEG-TS
487 // Since it's a bitstream filter, we don't need to reencode the audio
488 command.outputOption('-bsf:a aac_adtstoasc')
489 }
490
491 addCommonHLSVODCommandOptions(command, videoPath)
492
493 return command
494}
495
496async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
497 if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
498
499 const fileContent = await readFile(options.outputPath)
500
501 const videoFileName = options.hlsPlaylist.videoFilename
502 const videoFilePath = getHLSVideoPath(options)
503
504 // Fix wrong mapping with some ffmpeg versions
505 const newContent = fileContent.toString()
506 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
507
508 await writeFile(options.outputPath, newContent)
509}
510
511function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
512 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
513}
514
515// ---------------------------------------------------------------------------
516// Transcoding presets
517// ---------------------------------------------------------------------------
518
519// Run encoder builder depending on available encoders
520// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
521// If the default one does not exist, check the next encoder
522async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
523 streamType: 'video' | 'audio'
524 input: string
525
526 availableEncoders: AvailableEncoders
527 profile: string
528
529 videoType: 'vod' | 'live'
530}) {
531 const { availableEncoders, profile, streamType, videoType } = options
532
533 const encodersToTry = availableEncoders.encodersToTry[videoType][streamType]
534 const encoders = availableEncoders.available[videoType]
535
536 for (const encoder of encodersToTry) {
537 if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) {
538 logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder, lTags())
539 continue
540 }
541
542 if (!encoders[encoder]) {
543 logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder, lTags())
544 continue
545 }
546
547 // An object containing available profiles for this encoder
548 const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
549 let builder = builderProfiles[profile]
550
551 if (!builder) {
552 logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder, lTags())
553 builder = builderProfiles.default
554
555 if (!builder) {
556 logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder, lTags())
557 continue
558 }
559 }
560
561 const result = await builder(pick(options, [ 'input', 'resolution', 'inputBitrate', 'fps', 'inputRatio', 'streamNum' ]))
562
563 return {
564 result,
565
566 // If we don't have output options, then copy the input stream
567 encoder: result.copy === true
568 ? 'copy'
569 : encoder
570 }
571 }
572
573 return null
574}
575
576async function presetVideo (options: {
577 command: FfmpegCommand
578 input: string
579 transcodeOptions: TranscodeOptions
580 fps?: number
581 scaleFilterValue?: string
582}) {
583 const { command, input, transcodeOptions, fps, scaleFilterValue } = options
584
585 let localCommand = command
586 .format('mp4')
587 .outputOption('-movflags faststart')
588
589 addDefaultEncoderGlobalParams({ command })
590
591 const probe = await ffprobePromise(input)
592
593 // Audio encoder
594 const parsedAudio = await getAudioStream(input, probe)
595 const bitrate = await getVideoFileBitrate(input, probe)
596 const { ratio } = await getVideoFileResolution(input, probe)
597
598 let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
599
600 if (!parsedAudio.audioStream) {
601 localCommand = localCommand.noAudio()
602 streamsToProcess = [ 'video' ]
603 }
604
605 for (const streamType of streamsToProcess) {
606 const { profile, resolution, availableEncoders } = transcodeOptions
607
608 const builderResult = await getEncoderBuilderResult({
609 streamType,
610 input,
611 resolution,
612 availableEncoders,
613 profile,
614 fps,
615 inputBitrate: bitrate,
616 inputRatio: ratio,
617 videoType: 'vod' as 'vod'
618 })
619
620 if (!builderResult) {
621 throw new Error('No available encoder found for stream ' + streamType)
622 }
623
624 logger.debug(
625 'Apply ffmpeg params from %s for %s stream of input %s using %s profile.',
626 builderResult.encoder, streamType, input, profile,
627 { builderResult, resolution, fps, ...lTags() }
628 )
629
630 if (streamType === 'video') {
631 localCommand.videoCodec(builderResult.encoder)
632
633 if (scaleFilterValue) {
634 localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
635 }
636 } else if (streamType === 'audio') {
637 localCommand.audioCodec(builderResult.encoder)
638 }
639
640 applyEncoderOptions(localCommand, builderResult.result)
641 addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps })
642 }
643
644 return localCommand
645}
646
647function presetCopy (command: FfmpegCommand): FfmpegCommand {
648 return command
649 .format('mp4')
650 .videoCodec('copy')
651 .audioCodec('copy')
652}
653
654function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand {
655 return command
656 .format('mp4')
657 .audioCodec('copy')
658 .noVideo()
659}
660
661function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand {
662 return command
663 .inputOptions(options.inputOptions ?? [])
664 .outputOptions(options.outputOptions ?? [])
665}
666
667function getScaleFilter (options: EncoderOptions): string {
668 if (options.scaleFilter) return options.scaleFilter.name
669
670 return 'scale'
671}
672
673// ---------------------------------------------------------------------------
674// Utils
675// ---------------------------------------------------------------------------
676
677function getFFmpeg (input: string, type: 'live' | 'vod') {
678 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
679 const command = ffmpeg(input, {
680 niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD,
681 cwd: CONFIG.STORAGE.TMP_DIR
682 })
683
684 const threads = type === 'live'
685 ? CONFIG.LIVE.TRANSCODING.THREADS
686 : CONFIG.TRANSCODING.THREADS
687
688 if (threads > 0) {
689 // If we don't set any threads ffmpeg will chose automatically
690 command.outputOption('-threads ' + threads)
691 }
692
693 return command
694}
695
696function getFFmpegVersion () {
697 return new Promise<string>((res, rej) => {
698 (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
699 if (err) return rej(err)
700 if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
701
702 return execPromise(`${ffmpegPath} -version`)
703 .then(stdout => {
704 const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
705 if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
706
707 // Fix ffmpeg version that does not include patch version (4.4 for example)
708 let version = parsed[1]
709 if (version.match(/^\d+\.\d+$/)) {
710 version += '.0'
711 }
712
713 return res(version)
714 })
715 .catch(err => rej(err))
716 })
717 })
718}
719
720async function runCommand (options: {
721 command: FfmpegCommand
722 silent?: boolean // false
723 job?: Job
724}) {
725 const { command, silent = false, job } = options
726
727 return new Promise<void>((res, rej) => {
728 let shellCommand: string
729
730 command.on('start', cmdline => { shellCommand = cmdline })
731
732 command.on('error', (err, stdout, stderr) => {
733 if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...lTags() })
734
735 rej(err)
736 })
737
738 command.on('end', (stdout, stderr) => {
739 logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...lTags() })
740
741 res()
742 })
743
744 if (job) {
745 command.on('progress', progress => {
746 if (!progress.percent) return
747
748 job.progress(Math.round(progress.percent))
749 .catch(err => logger.warn('Cannot set ffmpeg job progress.', { err, ...lTags() }))
750 })
751 }
752
753 command.run()
754 })
755}
756
757// Avoid "height not divisible by 2" error
758function getScaleCleanerValue () {
759 return 'trunc(iw/2)*2:trunc(ih/2)*2'
760}
761
762// ---------------------------------------------------------------------------
763
764export {
765 getLiveTranscodingCommand,
766 getLiveMuxingCommand,
767 buildStreamSuffix,
768 convertWebPToJPG,
769 processGIF,
770 generateImageFromVideoFile,
771 TranscodeOptions,
772 TranscodeOptionsType,
773 transcode,
774 runCommand,
775 getFFmpegVersion,
776
777 resetSupportedEncoders,
778
779 // builders
780 buildx264VODCommand
781}
diff --git a/server/helpers/ffmpeg/ffmpeg-commons.ts b/server/helpers/ffmpeg/ffmpeg-commons.ts
new file mode 100644
index 000000000..ee338889c
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-commons.ts
@@ -0,0 +1,114 @@
1import { Job } from 'bull'
2import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
3import { execPromise } from '@server/helpers/core-utils'
4import { logger, loggerTagsFactory } from '@server/helpers/logger'
5import { CONFIG } from '@server/initializers/config'
6import { FFMPEG_NICE } from '@server/initializers/constants'
7import { EncoderOptions } from '@shared/models'
8
9const lTags = loggerTagsFactory('ffmpeg')
10
11type StreamType = 'audio' | 'video'
12
13function getFFmpeg (input: string, type: 'live' | 'vod') {
14 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
15 const command = ffmpeg(input, {
16 niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD,
17 cwd: CONFIG.STORAGE.TMP_DIR
18 })
19
20 const threads = type === 'live'
21 ? CONFIG.LIVE.TRANSCODING.THREADS
22 : CONFIG.TRANSCODING.THREADS
23
24 if (threads > 0) {
25 // If we don't set any threads ffmpeg will chose automatically
26 command.outputOption('-threads ' + threads)
27 }
28
29 return command
30}
31
32function getFFmpegVersion () {
33 return new Promise<string>((res, rej) => {
34 (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
35 if (err) return rej(err)
36 if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
37
38 return execPromise(`${ffmpegPath} -version`)
39 .then(stdout => {
40 const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
41 if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
42
43 // Fix ffmpeg version that does not include patch version (4.4 for example)
44 let version = parsed[1]
45 if (version.match(/^\d+\.\d+$/)) {
46 version += '.0'
47 }
48
49 return res(version)
50 })
51 .catch(err => rej(err))
52 })
53 })
54}
55
56async function runCommand (options: {
57 command: FfmpegCommand
58 silent?: boolean // false by default
59 job?: Job
60}) {
61 const { command, silent = false, job } = options
62
63 return new Promise<void>((res, rej) => {
64 let shellCommand: string
65
66 command.on('start', cmdline => { shellCommand = cmdline })
67
68 command.on('error', (err, stdout, stderr) => {
69 if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...lTags() })
70
71 rej(err)
72 })
73
74 command.on('end', (stdout, stderr) => {
75 logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...lTags() })
76
77 res()
78 })
79
80 if (job) {
81 command.on('progress', progress => {
82 if (!progress.percent) return
83
84 job.progress(Math.round(progress.percent))
85 .catch(err => logger.warn('Cannot set ffmpeg job progress.', { err, ...lTags() }))
86 })
87 }
88
89 command.run()
90 })
91}
92
93function buildStreamSuffix (base: string, streamNum?: number) {
94 if (streamNum !== undefined) {
95 return `${base}:${streamNum}`
96 }
97
98 return base
99}
100
101function getScaleFilter (options: EncoderOptions): string {
102 if (options.scaleFilter) return options.scaleFilter.name
103
104 return 'scale'
105}
106
107export {
108 getFFmpeg,
109 getFFmpegVersion,
110 runCommand,
111 StreamType,
112 buildStreamSuffix,
113 getScaleFilter
114}
diff --git a/server/helpers/ffmpeg/ffmpeg-edition.ts b/server/helpers/ffmpeg/ffmpeg-edition.ts
new file mode 100644
index 000000000..a5baa7ef1
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-edition.ts
@@ -0,0 +1,242 @@
1import { FilterSpecification } from 'fluent-ffmpeg'
2import { VIDEO_FILTERS } from '@server/initializers/constants'
3import { AvailableEncoders } from '@shared/models'
4import { logger, loggerTagsFactory } from '../logger'
5import { getFFmpeg, runCommand } from './ffmpeg-commons'
6import { presetCopy, presetVOD } from './ffmpeg-presets'
7import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe-utils'
8
9const lTags = loggerTagsFactory('ffmpeg')
10
11async function cutVideo (options: {
12 inputPath: string
13 outputPath: string
14 start?: number
15 end?: number
16}) {
17 const { inputPath, outputPath } = options
18
19 logger.debug('Will cut the video.', { options, ...lTags() })
20
21 let command = getFFmpeg(inputPath, 'vod')
22 .output(outputPath)
23
24 command = presetCopy(command)
25
26 if (options.start) command.inputOption('-ss ' + options.start)
27
28 if (options.end) {
29 const endSeeking = options.end - (options.start || 0)
30
31 command.outputOption('-to ' + endSeeking)
32 }
33
34 await runCommand({ command })
35}
36
37async function addWatermark (options: {
38 inputPath: string
39 watermarkPath: string
40 outputPath: string
41
42 availableEncoders: AvailableEncoders
43 profile: string
44}) {
45 const { watermarkPath, inputPath, outputPath, availableEncoders, profile } = options
46
47 logger.debug('Will add watermark to the video.', { options, ...lTags() })
48
49 const videoProbe = await ffprobePromise(inputPath)
50 const fps = await getVideoStreamFPS(inputPath, videoProbe)
51 const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe)
52
53 let command = getFFmpeg(inputPath, 'vod')
54 .output(outputPath)
55 command.input(watermarkPath)
56
57 command = await presetVOD({
58 command,
59 input: inputPath,
60 availableEncoders,
61 profile,
62 resolution,
63 fps,
64 canCopyAudio: true,
65 canCopyVideo: false
66 })
67
68 const complexFilter: FilterSpecification[] = [
69 // Scale watermark
70 {
71 inputs: [ '[1]', '[0]' ],
72 filter: 'scale2ref',
73 options: {
74 w: 'oh*mdar',
75 h: `ih*${VIDEO_FILTERS.WATERMARK.SIZE_RATIO}`
76 },
77 outputs: [ '[watermark]', '[video]' ]
78 },
79
80 {
81 inputs: [ '[video]', '[watermark]' ],
82 filter: 'overlay',
83 options: {
84 x: `main_w - overlay_w - (main_h * ${VIDEO_FILTERS.WATERMARK.HORIZONTAL_MARGIN_RATIO})`,
85 y: `main_h * ${VIDEO_FILTERS.WATERMARK.VERTICAL_MARGIN_RATIO}`
86 }
87 }
88 ]
89
90 command.complexFilter(complexFilter)
91
92 await runCommand({ command })
93}
94
95async function addIntroOutro (options: {
96 inputPath: string
97 introOutroPath: string
98 outputPath: string
99 type: 'intro' | 'outro'
100
101 availableEncoders: AvailableEncoders
102 profile: string
103}) {
104 const { introOutroPath, inputPath, outputPath, availableEncoders, profile, type } = options
105
106 logger.debug('Will add intro/outro to the video.', { options, ...lTags() })
107
108 const mainProbe = await ffprobePromise(inputPath)
109 const fps = await getVideoStreamFPS(inputPath, mainProbe)
110 const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
111 const mainHasAudio = await hasAudioStream(inputPath, mainProbe)
112
113 const introOutroProbe = await ffprobePromise(introOutroPath)
114 const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
115
116 let command = getFFmpeg(inputPath, 'vod')
117 .output(outputPath)
118
119 command.input(introOutroPath)
120
121 if (!introOutroHasAudio && mainHasAudio) {
122 const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
123
124 command.input('anullsrc')
125 command.withInputFormat('lavfi')
126 command.withInputOption('-t ' + duration)
127 }
128
129 command = await presetVOD({
130 command,
131 input: inputPath,
132 availableEncoders,
133 profile,
134 resolution,
135 fps,
136 canCopyAudio: false,
137 canCopyVideo: false
138 })
139
140 // Add black background to correctly scale intro/outro with padding
141 const complexFilter: FilterSpecification[] = [
142 {
143 inputs: [ '1', '0' ],
144 filter: 'scale2ref',
145 options: {
146 w: 'iw',
147 h: `ih`
148 },
149 outputs: [ 'intro-outro', 'main' ]
150 },
151 {
152 inputs: [ 'intro-outro', 'main' ],
153 filter: 'scale2ref',
154 options: {
155 w: 'iw',
156 h: `ih`
157 },
158 outputs: [ 'to-scale', 'main' ]
159 },
160 {
161 inputs: 'to-scale',
162 filter: 'drawbox',
163 options: {
164 t: 'fill'
165 },
166 outputs: [ 'to-scale-bg' ]
167 },
168 {
169 inputs: [ '1', 'to-scale-bg' ],
170 filter: 'scale2ref',
171 options: {
172 w: 'iw',
173 h: 'ih',
174 force_original_aspect_ratio: 'decrease',
175 flags: 'spline'
176 },
177 outputs: [ 'to-scale', 'to-scale-bg' ]
178 },
179 {
180 inputs: [ 'to-scale-bg', 'to-scale' ],
181 filter: 'overlay',
182 options: {
183 x: '(main_w - overlay_w)/2',
184 y: '(main_h - overlay_h)/2'
185 },
186 outputs: 'intro-outro-resized'
187 }
188 ]
189
190 const concatFilter = {
191 inputs: [],
192 filter: 'concat',
193 options: {
194 n: 2,
195 v: 1,
196 unsafe: 1
197 },
198 outputs: [ 'v' ]
199 }
200
201 const introOutroFilterInputs = [ 'intro-outro-resized' ]
202 const mainFilterInputs = [ 'main' ]
203
204 if (mainHasAudio) {
205 mainFilterInputs.push('0:a')
206
207 if (introOutroHasAudio) {
208 introOutroFilterInputs.push('1:a')
209 } else {
210 // Silent input
211 introOutroFilterInputs.push('2:a')
212 }
213 }
214
215 if (type === 'intro') {
216 concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ]
217 } else {
218 concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ]
219 }
220
221 if (mainHasAudio) {
222 concatFilter.options['a'] = 1
223 concatFilter.outputs.push('a')
224
225 command.outputOption('-map [a]')
226 }
227
228 command.outputOption('-map [v]')
229
230 complexFilter.push(concatFilter)
231 command.complexFilter(complexFilter)
232
233 await runCommand({ command })
234}
235
236// ---------------------------------------------------------------------------
237
238export {
239 cutVideo,
240 addIntroOutro,
241 addWatermark
242}
diff --git a/server/helpers/ffmpeg/ffmpeg-encoders.ts b/server/helpers/ffmpeg/ffmpeg-encoders.ts
new file mode 100644
index 000000000..5bd80ba05
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-encoders.ts
@@ -0,0 +1,116 @@
1import { getAvailableEncoders } from 'fluent-ffmpeg'
2import { pick } from '@shared/core-utils'
3import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models'
4import { promisify0 } from '../core-utils'
5import { logger, loggerTagsFactory } from '../logger'
6
7const lTags = loggerTagsFactory('ffmpeg')
8
9// Detect supported encoders by ffmpeg
10let supportedEncoders: Map<string, boolean>
11async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
12 if (supportedEncoders !== undefined) {
13 return supportedEncoders
14 }
15
16 const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
17 const availableFFmpegEncoders = await getAvailableEncodersPromise()
18
19 const searchEncoders = new Set<string>()
20 for (const type of [ 'live', 'vod' ]) {
21 for (const streamType of [ 'audio', 'video' ]) {
22 for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
23 searchEncoders.add(encoder)
24 }
25 }
26 }
27
28 supportedEncoders = new Map<string, boolean>()
29
30 for (const searchEncoder of searchEncoders) {
31 supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
32 }
33
34 logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...lTags() })
35
36 return supportedEncoders
37}
38
39function resetSupportedEncoders () {
40 supportedEncoders = undefined
41}
42
43// Run encoder builder depending on available encoders
44// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
45// If the default one does not exist, check the next encoder
46async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
47 streamType: 'video' | 'audio'
48 input: string
49
50 availableEncoders: AvailableEncoders
51 profile: string
52
53 videoType: 'vod' | 'live'
54}) {
55 const { availableEncoders, profile, streamType, videoType } = options
56
57 const encodersToTry = availableEncoders.encodersToTry[videoType][streamType]
58 const encoders = availableEncoders.available[videoType]
59
60 for (const encoder of encodersToTry) {
61 if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) {
62 logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder, lTags())
63 continue
64 }
65
66 if (!encoders[encoder]) {
67 logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder, lTags())
68 continue
69 }
70
71 // An object containing available profiles for this encoder
72 const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
73 let builder = builderProfiles[profile]
74
75 if (!builder) {
76 logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder, lTags())
77 builder = builderProfiles.default
78
79 if (!builder) {
80 logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder, lTags())
81 continue
82 }
83 }
84
85 const result = await builder(
86 pick(options, [
87 'input',
88 'canCopyAudio',
89 'canCopyVideo',
90 'resolution',
91 'inputBitrate',
92 'fps',
93 'inputRatio',
94 'streamNum'
95 ])
96 )
97
98 return {
99 result,
100
101 // If we don't have output options, then copy the input stream
102 encoder: result.copy === true
103 ? 'copy'
104 : encoder
105 }
106 }
107
108 return null
109}
110
111export {
112 checkFFmpegEncoders,
113 resetSupportedEncoders,
114
115 getEncoderBuilderResult
116}
diff --git a/server/helpers/ffmpeg/ffmpeg-images.ts b/server/helpers/ffmpeg/ffmpeg-images.ts
new file mode 100644
index 000000000..7f64c6d0a
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-images.ts
@@ -0,0 +1,46 @@
1import ffmpeg from 'fluent-ffmpeg'
2import { FFMPEG_NICE } from '@server/initializers/constants'
3import { runCommand } from './ffmpeg-commons'
4
5function convertWebPToJPG (path: string, destination: string): Promise<void> {
6 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
7 .output(destination)
8
9 return runCommand({ command, silent: true })
10}
11
12function processGIF (
13 path: string,
14 destination: string,
15 newSize: { width: number, height: number }
16): Promise<void> {
17 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
18 .fps(20)
19 .size(`${newSize.width}x${newSize.height}`)
20 .output(destination)
21
22 return runCommand({ command })
23}
24
25async function generateThumbnailFromVideo (fromPath: string, folder: string, imageName: string) {
26 const pendingImageName = 'pending-' + imageName
27
28 const options = {
29 filename: pendingImageName,
30 count: 1,
31 folder
32 }
33
34 return new Promise<string>((res, rej) => {
35 ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
36 .on('error', rej)
37 .on('end', () => res(imageName))
38 .thumbnail(options)
39 })
40}
41
42export {
43 convertWebPToJPG,
44 processGIF,
45 generateThumbnailFromVideo
46}
diff --git a/server/helpers/ffmpeg/ffmpeg-live.ts b/server/helpers/ffmpeg/ffmpeg-live.ts
new file mode 100644
index 000000000..ff571626c
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-live.ts
@@ -0,0 +1,161 @@
1import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg'
2import { join } from 'path'
3import { VIDEO_LIVE } from '@server/initializers/constants'
4import { AvailableEncoders } from '@shared/models'
5import { logger, loggerTagsFactory } from '../logger'
6import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons'
7import { getEncoderBuilderResult } from './ffmpeg-encoders'
8import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './ffmpeg-presets'
9import { computeFPS } from './ffprobe-utils'
10
11const lTags = loggerTagsFactory('ffmpeg')
12
13async function getLiveTranscodingCommand (options: {
14 inputUrl: string
15
16 outPath: string
17 masterPlaylistName: string
18
19 resolutions: number[]
20
21 // Input information
22 fps: number
23 bitrate: number
24 ratio: number
25
26 availableEncoders: AvailableEncoders
27 profile: string
28}) {
29 const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options
30
31 const command = getFFmpeg(inputUrl, 'live')
32
33 const varStreamMap: string[] = []
34
35 const complexFilter: FilterSpecification[] = [
36 {
37 inputs: '[v:0]',
38 filter: 'split',
39 options: resolutions.length,
40 outputs: resolutions.map(r => `vtemp${r}`)
41 }
42 ]
43
44 command.outputOption('-sc_threshold 0')
45
46 addDefaultEncoderGlobalParams(command)
47
48 for (let i = 0; i < resolutions.length; i++) {
49 const resolution = resolutions[i]
50 const resolutionFPS = computeFPS(fps, resolution)
51
52 const baseEncoderBuilderParams = {
53 input: inputUrl,
54
55 availableEncoders,
56 profile,
57
58 canCopyAudio: true,
59 canCopyVideo: true,
60
61 inputBitrate: bitrate,
62 inputRatio: ratio,
63
64 resolution,
65 fps: resolutionFPS,
66
67 streamNum: i,
68 videoType: 'live' as 'live'
69 }
70
71 {
72 const streamType: StreamType = 'video'
73 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
74 if (!builderResult) {
75 throw new Error('No available live video encoder found')
76 }
77
78 command.outputOption(`-map [vout${resolution}]`)
79
80 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
81
82 logger.debug(
83 'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile,
84 { builderResult, fps: resolutionFPS, resolution, ...lTags() }
85 )
86
87 command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
88 applyEncoderOptions(command, builderResult.result)
89
90 complexFilter.push({
91 inputs: `vtemp${resolution}`,
92 filter: getScaleFilter(builderResult.result),
93 options: `w=-2:h=${resolution}`,
94 outputs: `vout${resolution}`
95 })
96 }
97
98 {
99 const streamType: StreamType = 'audio'
100 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
101 if (!builderResult) {
102 throw new Error('No available live audio encoder found')
103 }
104
105 command.outputOption('-map a:0')
106
107 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
108
109 logger.debug(
110 'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile,
111 { builderResult, fps: resolutionFPS, resolution, ...lTags() }
112 )
113
114 command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
115 applyEncoderOptions(command, builderResult.result)
116 }
117
118 varStreamMap.push(`v:${i},a:${i}`)
119 }
120
121 command.complexFilter(complexFilter)
122
123 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
124
125 command.outputOption('-var_stream_map', varStreamMap.join(' '))
126
127 return command
128}
129
130function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) {
131 const command = getFFmpeg(inputUrl, 'live')
132
133 command.outputOption('-c:v copy')
134 command.outputOption('-c:a copy')
135 command.outputOption('-map 0:a?')
136 command.outputOption('-map 0:v?')
137
138 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
139
140 return command
141}
142
143// ---------------------------------------------------------------------------
144
145export {
146 getLiveTranscodingCommand,
147 getLiveMuxingCommand
148}
149
150// ---------------------------------------------------------------------------
151
152function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) {
153 command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
154 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
155 command.outputOption('-hls_flags delete_segments+independent_segments')
156 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
157 command.outputOption('-master_pl_name ' + masterPlaylistName)
158 command.outputOption(`-f hls`)
159
160 command.output(join(outPath, '%v.m3u8'))
161}
diff --git a/server/helpers/ffmpeg/ffmpeg-presets.ts b/server/helpers/ffmpeg/ffmpeg-presets.ts
new file mode 100644
index 000000000..99b39f79a
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-presets.ts
@@ -0,0 +1,156 @@
1import { FfmpegCommand } from 'fluent-ffmpeg'
2import { pick } from 'lodash'
3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { AvailableEncoders, EncoderOptions } from '@shared/models'
5import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-commons'
6import { getEncoderBuilderResult } from './ffmpeg-encoders'
7import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from './ffprobe-utils'
8
9const lTags = loggerTagsFactory('ffmpeg')
10
11// ---------------------------------------------------------------------------
12
13function addDefaultEncoderGlobalParams (command: FfmpegCommand) {
14 // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
15 command.outputOption('-max_muxing_queue_size 1024')
16 // strip all metadata
17 .outputOption('-map_metadata -1')
18 // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
19 .outputOption('-pix_fmt yuv420p')
20}
21
22function addDefaultEncoderParams (options: {
23 command: FfmpegCommand
24 encoder: 'libx264' | string
25 fps: number
26
27 streamNum?: number
28}) {
29 const { command, encoder, fps, streamNum } = options
30
31 if (encoder === 'libx264') {
32 // 3.1 is the minimal resource allocation for our highest supported resolution
33 command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
34
35 if (fps) {
36 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
37 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
38 // https://superuser.com/a/908325
39 command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
40 }
41 }
42}
43
44// ---------------------------------------------------------------------------
45
46async function presetVOD (options: {
47 command: FfmpegCommand
48 input: string
49
50 availableEncoders: AvailableEncoders
51 profile: string
52
53 canCopyAudio: boolean
54 canCopyVideo: boolean
55
56 resolution: number
57 fps: number
58
59 scaleFilterValue?: string
60}) {
61 const { command, input, profile, resolution, fps, scaleFilterValue } = options
62
63 let localCommand = command
64 .format('mp4')
65 .outputOption('-movflags faststart')
66
67 addDefaultEncoderGlobalParams(command)
68
69 const probe = await ffprobePromise(input)
70
71 // Audio encoder
72 const bitrate = await getVideoStreamBitrate(input, probe)
73 const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe)
74
75 let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
76
77 if (!await hasAudioStream(input, probe)) {
78 localCommand = localCommand.noAudio()
79 streamsToProcess = [ 'video' ]
80 }
81
82 for (const streamType of streamsToProcess) {
83 const builderResult = await getEncoderBuilderResult({
84 ...pick(options, [ 'availableEncoders', 'canCopyAudio', 'canCopyVideo' ]),
85
86 input,
87 inputBitrate: bitrate,
88 inputRatio: videoStreamDimensions?.ratio || 0,
89
90 profile,
91 resolution,
92 fps,
93 streamType,
94
95 videoType: 'vod' as 'vod'
96 })
97
98 if (!builderResult) {
99 throw new Error('No available encoder found for stream ' + streamType)
100 }
101
102 logger.debug(
103 'Apply ffmpeg params from %s for %s stream of input %s using %s profile.',
104 builderResult.encoder, streamType, input, profile,
105 { builderResult, resolution, fps, ...lTags() }
106 )
107
108 if (streamType === 'video') {
109 localCommand.videoCodec(builderResult.encoder)
110
111 if (scaleFilterValue) {
112 localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
113 }
114 } else if (streamType === 'audio') {
115 localCommand.audioCodec(builderResult.encoder)
116 }
117
118 applyEncoderOptions(localCommand, builderResult.result)
119 addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps })
120 }
121
122 return localCommand
123}
124
125function presetCopy (command: FfmpegCommand): FfmpegCommand {
126 return command
127 .format('mp4')
128 .videoCodec('copy')
129 .audioCodec('copy')
130}
131
132function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand {
133 return command
134 .format('mp4')
135 .audioCodec('copy')
136 .noVideo()
137}
138
139function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand {
140 return command
141 .inputOptions(options.inputOptions ?? [])
142 .outputOptions(options.outputOptions ?? [])
143}
144
145// ---------------------------------------------------------------------------
146
147export {
148 presetVOD,
149 presetCopy,
150 presetOnlyAudio,
151
152 addDefaultEncoderGlobalParams,
153 addDefaultEncoderParams,
154
155 applyEncoderOptions
156}
diff --git a/server/helpers/ffmpeg/ffmpeg-vod.ts b/server/helpers/ffmpeg/ffmpeg-vod.ts
new file mode 100644
index 000000000..c3622ceb1
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-vod.ts
@@ -0,0 +1,254 @@
1import { Job } from 'bull'
2import { FfmpegCommand } from 'fluent-ffmpeg'
3import { readFile, writeFile } from 'fs-extra'
4import { dirname } from 'path'
5import { pick } from '@shared/core-utils'
6import { AvailableEncoders, VideoResolution } from '@shared/models'
7import { logger, loggerTagsFactory } from '../logger'
8import { getFFmpeg, runCommand } from './ffmpeg-commons'
9import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets'
10import { computeFPS, getVideoStreamFPS } from './ffprobe-utils'
11import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
12
13const lTags = loggerTagsFactory('ffmpeg')
14
15// ---------------------------------------------------------------------------
16
17type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
18
19interface BaseTranscodeVODOptions {
20 type: TranscodeVODOptionsType
21
22 inputPath: string
23 outputPath: string
24
25 availableEncoders: AvailableEncoders
26 profile: string
27
28 resolution: number
29
30 isPortraitMode?: boolean
31
32 job?: Job
33}
34
35interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
36 type: 'hls'
37 copyCodecs: boolean
38 hlsPlaylist: {
39 videoFilename: string
40 }
41}
42
43interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
44 type: 'hls-from-ts'
45
46 isAAC: boolean
47
48 hlsPlaylist: {
49 videoFilename: string
50 }
51}
52
53interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
54 type: 'quick-transcode'
55}
56
57interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
58 type: 'video'
59}
60
61interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
62 type: 'merge-audio'
63 audioPath: string
64}
65
66interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions {
67 type: 'only-audio'
68}
69
70type TranscodeVODOptions =
71 HLSTranscodeOptions
72 | HLSFromTSTranscodeOptions
73 | VideoTranscodeOptions
74 | MergeAudioTranscodeOptions
75 | OnlyAudioTranscodeOptions
76 | QuickTranscodeOptions
77
78// ---------------------------------------------------------------------------
79
80const builders: {
81 [ type in TranscodeVODOptionsType ]: (c: FfmpegCommand, o?: TranscodeVODOptions) => Promise<FfmpegCommand> | FfmpegCommand
82} = {
83 'quick-transcode': buildQuickTranscodeCommand,
84 'hls': buildHLSVODCommand,
85 'hls-from-ts': buildHLSVODFromTSCommand,
86 'merge-audio': buildAudioMergeCommand,
87 'only-audio': buildOnlyAudioCommand,
88 'video': buildVODCommand
89}
90
91async function transcodeVOD (options: TranscodeVODOptions) {
92 logger.debug('Will run transcode.', { options, ...lTags() })
93
94 let command = getFFmpeg(options.inputPath, 'vod')
95 .output(options.outputPath)
96
97 command = await builders[options.type](command, options)
98
99 await runCommand({ command, job: options.job })
100
101 await fixHLSPlaylistIfNeeded(options)
102}
103
104// ---------------------------------------------------------------------------
105
106export {
107 transcodeVOD,
108
109 buildVODCommand,
110
111 TranscodeVODOptions,
112 TranscodeVODOptionsType
113}
114
115// ---------------------------------------------------------------------------
116
117async function buildVODCommand (command: FfmpegCommand, options: TranscodeVODOptions) {
118 let fps = await getVideoStreamFPS(options.inputPath)
119 fps = computeFPS(fps, options.resolution)
120
121 let scaleFilterValue: string
122
123 if (options.resolution !== undefined) {
124 scaleFilterValue = options.isPortraitMode === true
125 ? `w=${options.resolution}:h=-2`
126 : `w=-2:h=${options.resolution}`
127 }
128
129 command = await presetVOD({
130 ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
131
132 command,
133 input: options.inputPath,
134 canCopyAudio: true,
135 canCopyVideo: true,
136 fps,
137 scaleFilterValue
138 })
139
140 return command
141}
142
143function buildQuickTranscodeCommand (command: FfmpegCommand) {
144 command = presetCopy(command)
145
146 command = command.outputOption('-map_metadata -1') // strip all metadata
147 .outputOption('-movflags faststart')
148
149 return command
150}
151
152// ---------------------------------------------------------------------------
153// Audio transcoding
154// ---------------------------------------------------------------------------
155
156async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) {
157 command = command.loop(undefined)
158
159 const scaleFilterValue = getMergeAudioScaleFilterValue()
160 command = await presetVOD({
161 ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
162
163 command,
164 input: options.audioPath,
165 canCopyAudio: true,
166 canCopyVideo: true,
167 fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE,
168 scaleFilterValue
169 })
170
171 command.outputOption('-preset:v veryfast')
172
173 command = command.input(options.audioPath)
174 .outputOption('-tune stillimage')
175 .outputOption('-shortest')
176
177 return command
178}
179
180function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
181 command = presetOnlyAudio(command)
182
183 return command
184}
185
186// ---------------------------------------------------------------------------
187// HLS transcoding
188// ---------------------------------------------------------------------------
189
190async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) {
191 const videoPath = getHLSVideoPath(options)
192
193 if (options.copyCodecs) command = presetCopy(command)
194 else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
195 else command = await buildVODCommand(command, options)
196
197 addCommonHLSVODCommandOptions(command, videoPath)
198
199 return command
200}
201
202function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) {
203 const videoPath = getHLSVideoPath(options)
204
205 command.outputOption('-c copy')
206
207 if (options.isAAC) {
208 // Required for example when copying an AAC stream from an MPEG-TS
209 // Since it's a bitstream filter, we don't need to reencode the audio
210 command.outputOption('-bsf:a aac_adtstoasc')
211 }
212
213 addCommonHLSVODCommandOptions(command, videoPath)
214
215 return command
216}
217
218function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
219 return command.outputOption('-hls_time 4')
220 .outputOption('-hls_list_size 0')
221 .outputOption('-hls_playlist_type vod')
222 .outputOption('-hls_segment_filename ' + outputPath)
223 .outputOption('-hls_segment_type fmp4')
224 .outputOption('-f hls')
225 .outputOption('-hls_flags single_file')
226}
227
228async function fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
229 if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
230
231 const fileContent = await readFile(options.outputPath)
232
233 const videoFileName = options.hlsPlaylist.videoFilename
234 const videoFilePath = getHLSVideoPath(options)
235
236 // Fix wrong mapping with some ffmpeg versions
237 const newContent = fileContent.toString()
238 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
239
240 await writeFile(options.outputPath, newContent)
241}
242
243// ---------------------------------------------------------------------------
244// Helpers
245// ---------------------------------------------------------------------------
246
247function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
248 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
249}
250
251// Avoid "height not divisible by 2" error
252function getMergeAudioScaleFilterValue () {
253 return 'trunc(iw/2)*2:trunc(ih/2)*2'
254}
diff --git a/server/helpers/ffprobe-utils.ts b/server/helpers/ffmpeg/ffprobe-utils.ts
index 595112bce..07bcf01f4 100644
--- a/server/helpers/ffprobe-utils.ts
+++ b/server/helpers/ffmpeg/ffprobe-utils.ts
@@ -1,22 +1,21 @@
1import { FfprobeData } from 'fluent-ffmpeg' 1import { FfprobeData } from 'fluent-ffmpeg'
2import { getMaxBitrate } from '@shared/core-utils' 2import { getMaxBitrate } from '@shared/core-utils'
3import { VideoResolution, VideoTranscodingFPS } from '../../shared/models/videos'
4import { CONFIG } from '../initializers/config'
5import { VIDEO_TRANSCODING_FPS } from '../initializers/constants'
6import { logger } from './logger'
7import { 3import {
8 canDoQuickAudioTranscode,
9 ffprobePromise, 4 ffprobePromise,
10 getDurationFromVideoFile,
11 getAudioStream, 5 getAudioStream,
6 getVideoStreamDuration,
12 getMaxAudioBitrate, 7 getMaxAudioBitrate,
13 getMetadataFromFile, 8 buildFileMetadata,
14 getVideoFileBitrate, 9 getVideoStreamBitrate,
15 getVideoFileFPS, 10 getVideoStreamFPS,
16 getVideoFileResolution, 11 getVideoStream,
17 getVideoStreamFromFile, 12 getVideoStreamDimensionsInfo,
18 getVideoStreamSize 13 hasAudioStream
19} from '@shared/extra-utils/ffprobe' 14} from '@shared/extra-utils/ffprobe'
15import { VideoResolution, VideoTranscodingFPS } from '@shared/models'
16import { CONFIG } from '../../initializers/config'
17import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants'
18import { logger } from '../logger'
20 19
21/** 20/**
22 * 21 *
@@ -24,9 +23,12 @@ import {
24 * 23 *
25 */ 24 */
26 25
27async function getVideoStreamCodec (path: string) { 26// ---------------------------------------------------------------------------
28 const videoStream = await getVideoStreamFromFile(path) 27// Codecs
28// ---------------------------------------------------------------------------
29 29
30async function getVideoStreamCodec (path: string) {
31 const videoStream = await getVideoStream(path)
30 if (!videoStream) return '' 32 if (!videoStream) return ''
31 33
32 const videoCodec = videoStream.codec_tag_string 34 const videoCodec = videoStream.codec_tag_string
@@ -83,6 +85,10 @@ async function getAudioStreamCodec (path: string, existingProbe?: FfprobeData) {
83 return 'mp4a.40.2' // Fallback 85 return 'mp4a.40.2' // Fallback
84} 86}
85 87
88// ---------------------------------------------------------------------------
89// Resolutions
90// ---------------------------------------------------------------------------
91
86function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') { 92function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
87 const configResolutions = type === 'vod' 93 const configResolutions = type === 'vod'
88 ? CONFIG.TRANSCODING.RESOLUTIONS 94 ? CONFIG.TRANSCODING.RESOLUTIONS
@@ -112,6 +118,10 @@ function computeLowerResolutionsToTranscode (videoFileResolution: number, type:
112 return resolutionsEnabled 118 return resolutionsEnabled
113} 119}
114 120
121// ---------------------------------------------------------------------------
122// Can quick transcode
123// ---------------------------------------------------------------------------
124
115async function canDoQuickTranscode (path: string): Promise<boolean> { 125async function canDoQuickTranscode (path: string): Promise<boolean> {
116 if (CONFIG.TRANSCODING.PROFILE !== 'default') return false 126 if (CONFIG.TRANSCODING.PROFILE !== 'default') return false
117 127
@@ -121,17 +131,37 @@ async function canDoQuickTranscode (path: string): Promise<boolean> {
121 await canDoQuickAudioTranscode(path, probe) 131 await canDoQuickAudioTranscode(path, probe)
122} 132}
123 133
134async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
135 const parsedAudio = await getAudioStream(path, probe)
136
137 if (!parsedAudio.audioStream) return true
138
139 if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
140
141 const audioBitrate = parsedAudio.bitrate
142 if (!audioBitrate) return false
143
144 const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
145 if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
146
147 const channelLayout = parsedAudio.audioStream['channel_layout']
148 // Causes playback issues with Chrome
149 if (!channelLayout || channelLayout === 'unknown') return false
150
151 return true
152}
153
124async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> { 154async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
125 const videoStream = await getVideoStreamFromFile(path, probe) 155 const videoStream = await getVideoStream(path, probe)
126 const fps = await getVideoFileFPS(path, probe) 156 const fps = await getVideoStreamFPS(path, probe)
127 const bitRate = await getVideoFileBitrate(path, probe) 157 const bitRate = await getVideoStreamBitrate(path, probe)
128 const resolutionData = await getVideoFileResolution(path, probe) 158 const resolutionData = await getVideoStreamDimensionsInfo(path, probe)
129 159
130 // If ffprobe did not manage to guess the bitrate 160 // If ffprobe did not manage to guess the bitrate
131 if (!bitRate) return false 161 if (!bitRate) return false
132 162
133 // check video params 163 // check video params
134 if (videoStream == null) return false 164 if (!videoStream) return false
135 if (videoStream['codec_name'] !== 'h264') return false 165 if (videoStream['codec_name'] !== 'h264') return false
136 if (videoStream['pix_fmt'] !== 'yuv420p') return false 166 if (videoStream['pix_fmt'] !== 'yuv420p') return false
137 if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false 167 if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
@@ -140,6 +170,10 @@ async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Pro
140 return true 170 return true
141} 171}
142 172
173// ---------------------------------------------------------------------------
174// Framerate
175// ---------------------------------------------------------------------------
176
143function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) { 177function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) {
144 return VIDEO_TRANSCODING_FPS[type].slice(0) 178 return VIDEO_TRANSCODING_FPS[type].slice(0)
145 .sort((a, b) => fps % a - fps % b)[0] 179 .sort((a, b) => fps % a - fps % b)[0]
@@ -171,21 +205,26 @@ function computeFPS (fpsArg: number, resolution: VideoResolution) {
171// --------------------------------------------------------------------------- 205// ---------------------------------------------------------------------------
172 206
173export { 207export {
174 getVideoStreamCodec, 208 // Re export ffprobe utils
175 getAudioStreamCodec, 209 getVideoStreamDimensionsInfo,
176 getVideoStreamSize, 210 buildFileMetadata,
177 getVideoFileResolution,
178 getMetadataFromFile,
179 getMaxAudioBitrate, 211 getMaxAudioBitrate,
180 getVideoStreamFromFile, 212 getVideoStream,
181 getDurationFromVideoFile, 213 getVideoStreamDuration,
182 getAudioStream, 214 getAudioStream,
183 computeFPS, 215 hasAudioStream,
184 getVideoFileFPS, 216 getVideoStreamFPS,
185 ffprobePromise, 217 ffprobePromise,
218 getVideoStreamBitrate,
219
220 getVideoStreamCodec,
221 getAudioStreamCodec,
222
223 computeFPS,
186 getClosestFramerateStandard, 224 getClosestFramerateStandard,
225
187 computeLowerResolutionsToTranscode, 226 computeLowerResolutionsToTranscode,
188 getVideoFileBitrate, 227
189 canDoQuickTranscode, 228 canDoQuickTranscode,
190 canDoQuickVideoTranscode, 229 canDoQuickVideoTranscode,
191 canDoQuickAudioTranscode 230 canDoQuickAudioTranscode
diff --git a/server/helpers/ffmpeg/index.ts b/server/helpers/ffmpeg/index.ts
new file mode 100644
index 000000000..e3bb2013f
--- /dev/null
+++ b/server/helpers/ffmpeg/index.ts
@@ -0,0 +1,8 @@
1export * from './ffmpeg-commons'
2export * from './ffmpeg-edition'
3export * from './ffmpeg-encoders'
4export * from './ffmpeg-images'
5export * from './ffmpeg-live'
6export * from './ffmpeg-presets'
7export * from './ffmpeg-vod'
8export * from './ffprobe-utils'
diff --git a/server/helpers/image-utils.ts b/server/helpers/image-utils.ts
index b174ae436..9d0c09051 100644
--- a/server/helpers/image-utils.ts
+++ b/server/helpers/image-utils.ts
@@ -1,9 +1,12 @@
1import { copy, readFile, remove, rename } from 'fs-extra' 1import { copy, readFile, remove, rename } from 'fs-extra'
2import Jimp, { read } from 'jimp' 2import Jimp, { read as jimpRead } from 'jimp'
3import { join } from 'path'
3import { getLowercaseExtension } from '@shared/core-utils' 4import { getLowercaseExtension } from '@shared/core-utils'
4import { buildUUID } from '@shared/extra-utils' 5import { buildUUID } from '@shared/extra-utils'
5import { convertWebPToJPG, processGIF } from './ffmpeg-utils' 6import { convertWebPToJPG, generateThumbnailFromVideo, processGIF } from './ffmpeg/ffmpeg-images'
6import { logger } from './logger' 7import { logger, loggerTagsFactory } from './logger'
8
9const lTags = loggerTagsFactory('image-utils')
7 10
8function generateImageFilename (extension = '.jpg') { 11function generateImageFilename (extension = '.jpg') {
9 return buildUUID() + extension 12 return buildUUID() + extension
@@ -33,11 +36,46 @@ async function processImage (
33 if (keepOriginal !== true) await remove(path) 36 if (keepOriginal !== true) await remove(path)
34} 37}
35 38
39async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
40 const pendingImageName = 'pending-' + imageName
41 const pendingImagePath = join(folder, pendingImageName)
42
43 try {
44 await generateThumbnailFromVideo(fromPath, folder, imageName)
45
46 const destination = join(folder, imageName)
47 await processImage(pendingImagePath, destination, size)
48 } catch (err) {
49 logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })
50
51 try {
52 await remove(pendingImagePath)
53 } catch (err) {
54 logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
55 }
56 }
57}
58
59async function getImageSize (path: string) {
60 const inputBuffer = await readFile(path)
61
62 const image = await jimpRead(inputBuffer)
63
64 return {
65 width: image.getWidth(),
66 height: image.getHeight()
67 }
68}
69
36// --------------------------------------------------------------------------- 70// ---------------------------------------------------------------------------
37 71
38export { 72export {
39 generateImageFilename, 73 generateImageFilename,
40 processImage 74 generateImageFromVideoFile,
75
76 processImage,
77
78 getImageSize
41} 79}
42 80
43// --------------------------------------------------------------------------- 81// ---------------------------------------------------------------------------
@@ -47,7 +85,7 @@ async function jimpProcessor (path: string, destination: string, newSize: { widt
47 const inputBuffer = await readFile(path) 85 const inputBuffer = await readFile(path)
48 86
49 try { 87 try {
50 sourceImage = await read(inputBuffer) 88 sourceImage = await jimpRead(inputBuffer)
51 } catch (err) { 89 } catch (err) {
52 logger.debug('Cannot read %s with jimp. Try to convert the image using ffmpeg first.', path, { err }) 90 logger.debug('Cannot read %s with jimp. Try to convert the image using ffmpeg first.', path, { err })
53 91
@@ -55,7 +93,7 @@ async function jimpProcessor (path: string, destination: string, newSize: { widt
55 await convertWebPToJPG(path, newName) 93 await convertWebPToJPG(path, newName)
56 await rename(newName, path) 94 await rename(newName, path)
57 95
58 sourceImage = await read(path) 96 sourceImage = await jimpRead(path)
59 } 97 }
60 98
61 await remove(destination) 99 await remove(destination)
diff --git a/server/helpers/markdown.ts b/server/helpers/markdown.ts
index 25685ec6d..41c1186ec 100644
--- a/server/helpers/markdown.ts
+++ b/server/helpers/markdown.ts
@@ -8,7 +8,7 @@ const markdownItEmoji = require('markdown-it-emoji/light')
8const MarkdownItClass = require('markdown-it') 8const MarkdownItClass = require('markdown-it')
9 9
10const markdownItWithHTML = new MarkdownItClass('default', { linkify: true, breaks: true, html: true }) 10const markdownItWithHTML = new MarkdownItClass('default', { linkify: true, breaks: true, html: true })
11const markdownItWithoutHTML = new MarkdownItClass('default', { linkify: true, breaks: true, html: false }) 11const markdownItWithoutHTML = new MarkdownItClass('default', { linkify: false, breaks: true, html: false })
12 12
13const toSafeHtml = (text: string) => { 13const toSafeHtml = (text: string) => {
14 if (!text) return '' 14 if (!text) return ''
@@ -66,7 +66,7 @@ function plainTextPlugin (markdownIt: any) {
66 66
67 if (token.type === 'list_item_close') { 67 if (token.type === 'list_item_close') {
68 lastSeparator = ', ' 68 lastSeparator = ', '
69 } else if (/[a-zA-Z]+_close/.test(token.type)) { 69 } else if (token.type.endsWith('_close')) {
70 lastSeparator = ' ' 70 lastSeparator = ' '
71 } else if (token.content) { 71 } else if (token.content) {
72 text += lastSeparator 72 text += lastSeparator
diff --git a/server/helpers/webtorrent.ts b/server/helpers/webtorrent.ts
index 68d532c48..88bdb16b6 100644
--- a/server/helpers/webtorrent.ts
+++ b/server/helpers/webtorrent.ts
@@ -91,6 +91,16 @@ async function downloadWebTorrentVideo (target: { uri: string, torrentName?: str
91} 91}
92 92
93function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { 93function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
94 return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), videoPath => {
95 return createTorrentAndSetInfoHashFromPath(videoOrPlaylist, videoFile, videoPath)
96 })
97}
98
99async function createTorrentAndSetInfoHashFromPath (
100 videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
101 videoFile: MVideoFile,
102 filePath: string
103) {
94 const video = extractVideo(videoOrPlaylist) 104 const video = extractVideo(videoOrPlaylist)
95 105
96 const options = { 106 const options = {
@@ -101,24 +111,22 @@ function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlayli
101 urlList: buildUrlList(video, videoFile) 111 urlList: buildUrlList(video, videoFile)
102 } 112 }
103 113
104 return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), async videoPath => { 114 const torrentContent = await createTorrentPromise(filePath, options)
105 const torrentContent = await createTorrentPromise(videoPath, options)
106 115
107 const torrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution) 116 const torrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution)
108 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, torrentFilename) 117 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, torrentFilename)
109 logger.info('Creating torrent %s.', torrentPath) 118 logger.info('Creating torrent %s.', torrentPath)
110 119
111 await writeFile(torrentPath, torrentContent) 120 await writeFile(torrentPath, torrentContent)
112 121
113 // Remove old torrent file if it existed 122 // Remove old torrent file if it existed
114 if (videoFile.hasTorrent()) { 123 if (videoFile.hasTorrent()) {
115 await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename)) 124 await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename))
116 } 125 }
117 126
118 const parsedTorrent = parseTorrent(torrentContent) 127 const parsedTorrent = parseTorrent(torrentContent)
119 videoFile.infoHash = parsedTorrent.infoHash 128 videoFile.infoHash = parsedTorrent.infoHash
120 videoFile.torrentFilename = torrentFilename 129 videoFile.torrentFilename = torrentFilename
121 })
122} 130}
123 131
124async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { 132async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
@@ -177,7 +185,10 @@ function generateMagnetUri (
177export { 185export {
178 createTorrentPromise, 186 createTorrentPromise,
179 updateTorrentMetadata, 187 updateTorrentMetadata,
188
180 createTorrentAndSetInfoHash, 189 createTorrentAndSetInfoHash,
190 createTorrentAndSetInfoHashFromPath,
191
181 generateMagnetUri, 192 generateMagnetUri,
182 downloadWebTorrentVideo 193 downloadWebTorrentVideo
183} 194}
diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts
index 57ef0d218..635a32010 100644
--- a/server/initializers/checker-after-init.ts
+++ b/server/initializers/checker-after-init.ts
@@ -1,7 +1,7 @@
1import config from 'config' 1import config from 'config'
2import { uniq } from 'lodash' 2import { uniq } from 'lodash'
3import { URL } from 'url' 3import { URL } from 'url'
4import { getFFmpegVersion } from '@server/helpers/ffmpeg-utils' 4import { getFFmpegVersion } from '@server/helpers/ffmpeg'
5import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type' 5import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
6import { RecentlyAddedStrategy } from '../../shared/models/redundancy' 6import { RecentlyAddedStrategy } from '../../shared/models/redundancy'
7import { isProdInstance, isTestInstance, parseSemVersion } from '../helpers/core-utils' 7import { isProdInstance, isTestInstance, parseSemVersion } from '../helpers/core-utils'
@@ -31,8 +31,7 @@ async function checkActivityPubUrls () {
31 } 31 }
32} 32}
33 33
34// Some checks on configuration files 34// Some checks on configuration files or throw if there is an error
35// Return an error message, or null if everything is okay
36function checkConfig () { 35function checkConfig () {
37 36
38 // Moved configuration keys 37 // Moved configuration keys
@@ -40,61 +39,124 @@ function checkConfig () {
40 logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.') 39 logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
41 } 40 }
42 41
43 // Email verification 42 checkEmailConfig()
43 checkNSFWPolicyConfig()
44 checkLocalRedundancyConfig()
45 checkRemoteRedundancyConfig()
46 checkStorageConfig()
47 checkTranscodingConfig()
48 checkBroadcastMessageConfig()
49 checkSearchConfig()
50 checkLiveConfig()
51 checkObjectStorageConfig()
52 checkVideoEditorConfig()
53}
54
55// We get db by param to not import it in this file (import orders)
56async function clientsExist () {
57 const totalClients = await OAuthClientModel.countTotal()
58
59 return totalClients !== 0
60}
61
62// We get db by param to not import it in this file (import orders)
63async function usersExist () {
64 const totalUsers = await UserModel.countTotal()
65
66 return totalUsers !== 0
67}
68
69// We get db by param to not import it in this file (import orders)
70async function applicationExist () {
71 const totalApplication = await ApplicationModel.countTotal()
72
73 return totalApplication !== 0
74}
75
76async function checkFFmpegVersion () {
77 const version = await getFFmpegVersion()
78 const { major, minor } = parseSemVersion(version)
79
80 if (major < 4 || (major === 4 && minor < 1)) {
81 logger.warn('Your ffmpeg version (%s) is outdated. PeerTube supports ffmpeg >= 4.1. Please upgrade.', version)
82 }
83}
84
85// ---------------------------------------------------------------------------
86
87export {
88 checkConfig,
89 clientsExist,
90 checkFFmpegVersion,
91 usersExist,
92 applicationExist,
93 checkActivityPubUrls
94}
95
96// ---------------------------------------------------------------------------
97
98function checkEmailConfig () {
44 if (!isEmailEnabled()) { 99 if (!isEmailEnabled()) {
45 if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) { 100 if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
46 return 'Emailer is disabled but you require signup email verification.' 101 throw new Error('Emailer is disabled but you require signup email verification.')
47 } 102 }
48 103
49 if (CONFIG.CONTACT_FORM.ENABLED) { 104 if (CONFIG.CONTACT_FORM.ENABLED) {
50 logger.warn('Emailer is disabled so the contact form will not work.') 105 logger.warn('Emailer is disabled so the contact form will not work.')
51 } 106 }
52 } 107 }
108}
53 109
54 // NSFW policy 110function checkNSFWPolicyConfig () {
55 const defaultNSFWPolicy = CONFIG.INSTANCE.DEFAULT_NSFW_POLICY 111 const defaultNSFWPolicy = CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
56 { 112
57 const available = [ 'do_not_list', 'blur', 'display' ] 113 const available = [ 'do_not_list', 'blur', 'display' ]
58 if (available.includes(defaultNSFWPolicy) === false) { 114 if (available.includes(defaultNSFWPolicy) === false) {
59 return 'NSFW policy setting should be ' + available.join(' or ') + ' instead of ' + defaultNSFWPolicy 115 throw new Error('NSFW policy setting should be ' + available.join(' or ') + ' instead of ' + defaultNSFWPolicy)
60 }
61 } 116 }
117}
62 118
63 // Redundancies 119function checkLocalRedundancyConfig () {
64 const redundancyVideos = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES 120 const redundancyVideos = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES
121
65 if (isArray(redundancyVideos)) { 122 if (isArray(redundancyVideos)) {
66 const available = [ 'most-views', 'trending', 'recently-added' ] 123 const available = [ 'most-views', 'trending', 'recently-added' ]
124
67 for (const r of redundancyVideos) { 125 for (const r of redundancyVideos) {
68 if (available.includes(r.strategy) === false) { 126 if (available.includes(r.strategy) === false) {
69 return 'Videos redundancy should have ' + available.join(' or ') + ' strategy instead of ' + r.strategy 127 throw new Error('Videos redundancy should have ' + available.join(' or ') + ' strategy instead of ' + r.strategy)
70 } 128 }
71 129
72 // Lifetime should not be < 10 hours 130 // Lifetime should not be < 10 hours
73 if (!isTestInstance() && r.minLifetime < 1000 * 3600 * 10) { 131 if (!isTestInstance() && r.minLifetime < 1000 * 3600 * 10) {
74 return 'Video redundancy minimum lifetime should be >= 10 hours for strategy ' + r.strategy 132 throw new Error('Video redundancy minimum lifetime should be >= 10 hours for strategy ' + r.strategy)
75 } 133 }
76 } 134 }
77 135
78 const filtered = uniq(redundancyVideos.map(r => r.strategy)) 136 const filtered = uniq(redundancyVideos.map(r => r.strategy))
79 if (filtered.length !== redundancyVideos.length) { 137 if (filtered.length !== redundancyVideos.length) {
80 return 'Redundancy video entries should have unique strategies' 138 throw new Error('Redundancy video entries should have unique strategies')
81 } 139 }
82 140
83 const recentlyAddedStrategy = redundancyVideos.find(r => r.strategy === 'recently-added') as RecentlyAddedStrategy 141 const recentlyAddedStrategy = redundancyVideos.find(r => r.strategy === 'recently-added') as RecentlyAddedStrategy
84 if (recentlyAddedStrategy && isNaN(recentlyAddedStrategy.minViews)) { 142 if (recentlyAddedStrategy && isNaN(recentlyAddedStrategy.minViews)) {
85 return 'Min views in recently added strategy is not a number' 143 throw new Error('Min views in recently added strategy is not a number')
86 } 144 }
87 } else { 145 } else {
88 return 'Videos redundancy should be an array (you must uncomment lines containing - too)' 146 throw new Error('Videos redundancy should be an array (you must uncomment lines containing - too)')
89 } 147 }
148}
90 149
91 // Remote redundancies 150function checkRemoteRedundancyConfig () {
92 const acceptFrom = CONFIG.REMOTE_REDUNDANCY.VIDEOS.ACCEPT_FROM 151 const acceptFrom = CONFIG.REMOTE_REDUNDANCY.VIDEOS.ACCEPT_FROM
93 const acceptFromValues = new Set<VideoRedundancyConfigFilter>([ 'nobody', 'anybody', 'followings' ]) 152 const acceptFromValues = new Set<VideoRedundancyConfigFilter>([ 'nobody', 'anybody', 'followings' ])
153
94 if (acceptFromValues.has(acceptFrom) === false) { 154 if (acceptFromValues.has(acceptFrom) === false) {
95 return 'remote_redundancy.videos.accept_from has an incorrect value' 155 throw new Error('remote_redundancy.videos.accept_from has an incorrect value')
96 } 156 }
157}
97 158
159function checkStorageConfig () {
98 // Check storage directory locations 160 // Check storage directory locations
99 if (isProdInstance()) { 161 if (isProdInstance()) {
100 const configStorage = config.get('storage') 162 const configStorage = config.get('storage')
@@ -111,71 +173,76 @@ function checkConfig () {
111 if (CONFIG.STORAGE.VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) { 173 if (CONFIG.STORAGE.VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) {
112 logger.warn('Redundancy directory should be different than the videos folder.') 174 logger.warn('Redundancy directory should be different than the videos folder.')
113 } 175 }
176}
114 177
115 // Transcoding 178function checkTranscodingConfig () {
116 if (CONFIG.TRANSCODING.ENABLED) { 179 if (CONFIG.TRANSCODING.ENABLED) {
117 if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false && CONFIG.TRANSCODING.HLS.ENABLED === false) { 180 if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false && CONFIG.TRANSCODING.HLS.ENABLED === false) {
118 return 'You need to enable at least WebTorrent transcoding or HLS transcoding.' 181 throw new Error('You need to enable at least WebTorrent transcoding or HLS transcoding.')
119 } 182 }
120 183
121 if (CONFIG.TRANSCODING.CONCURRENCY <= 0) { 184 if (CONFIG.TRANSCODING.CONCURRENCY <= 0) {
122 return 'Transcoding concurrency should be > 0' 185 throw new Error('Transcoding concurrency should be > 0')
123 } 186 }
124 } 187 }
125 188
126 if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED || CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED) { 189 if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED || CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED) {
127 if (CONFIG.IMPORT.VIDEOS.CONCURRENCY <= 0) { 190 if (CONFIG.IMPORT.VIDEOS.CONCURRENCY <= 0) {
128 return 'Video import concurrency should be > 0' 191 throw new Error('Video import concurrency should be > 0')
129 } 192 }
130 } 193 }
194}
131 195
132 // Broadcast message 196function checkBroadcastMessageConfig () {
133 if (CONFIG.BROADCAST_MESSAGE.ENABLED) { 197 if (CONFIG.BROADCAST_MESSAGE.ENABLED) {
134 const currentLevel = CONFIG.BROADCAST_MESSAGE.LEVEL 198 const currentLevel = CONFIG.BROADCAST_MESSAGE.LEVEL
135 const available = [ 'info', 'warning', 'error' ] 199 const available = [ 'info', 'warning', 'error' ]
136 200
137 if (available.includes(currentLevel) === false) { 201 if (available.includes(currentLevel) === false) {
138 return 'Broadcast message level should be ' + available.join(' or ') + ' instead of ' + currentLevel 202 throw new Error('Broadcast message level should be ' + available.join(' or ') + ' instead of ' + currentLevel)
139 } 203 }
140 } 204 }
205}
141 206
142 // Search index 207function checkSearchConfig () {
143 if (CONFIG.SEARCH.SEARCH_INDEX.ENABLED === true) { 208 if (CONFIG.SEARCH.SEARCH_INDEX.ENABLED === true) {
144 if (CONFIG.SEARCH.REMOTE_URI.USERS === false) { 209 if (CONFIG.SEARCH.REMOTE_URI.USERS === false) {
145 return 'You cannot enable search index without enabling remote URI search for users.' 210 throw new Error('You cannot enable search index without enabling remote URI search for users.')
146 } 211 }
147 } 212 }
213}
148 214
149 // Live 215function checkLiveConfig () {
150 if (CONFIG.LIVE.ENABLED === true) { 216 if (CONFIG.LIVE.ENABLED === true) {
151 if (CONFIG.LIVE.ALLOW_REPLAY === true && CONFIG.TRANSCODING.ENABLED === false) { 217 if (CONFIG.LIVE.ALLOW_REPLAY === true && CONFIG.TRANSCODING.ENABLED === false) {
152 return 'Live allow replay cannot be enabled if transcoding is not enabled.' 218 throw new Error('Live allow replay cannot be enabled if transcoding is not enabled.')
153 } 219 }
154 220
155 if (CONFIG.LIVE.RTMP.ENABLED === false && CONFIG.LIVE.RTMPS.ENABLED === false) { 221 if (CONFIG.LIVE.RTMP.ENABLED === false && CONFIG.LIVE.RTMPS.ENABLED === false) {
156 return 'You must enable at least RTMP or RTMPS' 222 throw new Error('You must enable at least RTMP or RTMPS')
157 } 223 }
158 224
159 if (CONFIG.LIVE.RTMPS.ENABLED) { 225 if (CONFIG.LIVE.RTMPS.ENABLED) {
160 if (!CONFIG.LIVE.RTMPS.KEY_FILE) { 226 if (!CONFIG.LIVE.RTMPS.KEY_FILE) {
161 return 'You must specify a key file to enabled RTMPS' 227 throw new Error('You must specify a key file to enabled RTMPS')
162 } 228 }
163 229
164 if (!CONFIG.LIVE.RTMPS.CERT_FILE) { 230 if (!CONFIG.LIVE.RTMPS.CERT_FILE) {
165 return 'You must specify a cert file to enable RTMPS' 231 throw new Error('You must specify a cert file to enable RTMPS')
166 } 232 }
167 } 233 }
168 } 234 }
235}
169 236
170 // Object storage 237function checkObjectStorageConfig () {
171 if (CONFIG.OBJECT_STORAGE.ENABLED === true) { 238 if (CONFIG.OBJECT_STORAGE.ENABLED === true) {
172 239
173 if (!CONFIG.OBJECT_STORAGE.VIDEOS.BUCKET_NAME) { 240 if (!CONFIG.OBJECT_STORAGE.VIDEOS.BUCKET_NAME) {
174 return 'videos_bucket should be set when object storage support is enabled.' 241 throw new Error('videos_bucket should be set when object storage support is enabled.')
175 } 242 }
176 243
177 if (!CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME) { 244 if (!CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME) {
178 return 'streaming_playlists_bucket should be set when object storage support is enabled.' 245 throw new Error('streaming_playlists_bucket should be set when object storage support is enabled.')
179 } 246 }
180 247
181 if ( 248 if (
@@ -183,53 +250,18 @@ function checkConfig () {
183 CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.PREFIX 250 CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.PREFIX
184 ) { 251 ) {
185 if (CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === '') { 252 if (CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === '') {
186 return 'Object storage bucket prefixes should be set when the same bucket is used for both types of video.' 253 throw new Error('Object storage bucket prefixes should be set when the same bucket is used for both types of video.')
187 } else {
188 return 'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.'
189 } 254 }
255
256 throw new Error(
257 'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.'
258 )
190 } 259 }
191 } 260 }
192
193 return null
194}
195
196// We get db by param to not import it in this file (import orders)
197async function clientsExist () {
198 const totalClients = await OAuthClientModel.countTotal()
199
200 return totalClients !== 0
201}
202
203// We get db by param to not import it in this file (import orders)
204async function usersExist () {
205 const totalUsers = await UserModel.countTotal()
206
207 return totalUsers !== 0
208} 261}
209 262
210// We get db by param to not import it in this file (import orders) 263function checkVideoEditorConfig () {
211async function applicationExist () { 264 if (CONFIG.VIDEO_EDITOR.ENABLED === true && CONFIG.TRANSCODING.ENABLED === false) {
212 const totalApplication = await ApplicationModel.countTotal() 265 throw new Error('Video editor cannot be enabled if transcoding is disabled')
213
214 return totalApplication !== 0
215}
216
217async function checkFFmpegVersion () {
218 const version = await getFFmpegVersion()
219 const { major, minor } = parseSemVersion(version)
220
221 if (major < 4 || (major === 4 && minor < 1)) {
222 logger.warn('Your ffmpeg version (%s) is outdated. PeerTube supports ffmpeg >= 4.1. Please upgrade.', version)
223 } 266 }
224} 267}
225
226// ---------------------------------------------------------------------------
227
228export {
229 checkConfig,
230 clientsExist,
231 checkFFmpegVersion,
232 usersExist,
233 applicationExist,
234 checkActivityPubUrls
235}
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 458005b98..36401f95c 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -30,7 +30,7 @@ function checkMissedConfig () {
30 'transcoding.profile', 'transcoding.concurrency', 30 'transcoding.profile', 'transcoding.concurrency',
31 'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p', 31 'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p',
32 'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p', 32 'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
33 'transcoding.resolutions.2160p', 33 'transcoding.resolutions.2160p', 'video_editor.enabled',
34 'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'auto_blacklist.videos.of_users.enabled', 34 'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'auto_blacklist.videos.of_users.enabled',
35 'trending.videos.interval_days', 35 'trending.videos.interval_days',
36 'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth', 36 'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth',
@@ -49,7 +49,8 @@ function checkMissedConfig () {
49 'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url', 49 'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url',
50 'search.search_index.disable_local_search', 'search.search_index.is_default_search', 50 'search.search_index.disable_local_search', 'search.search_index.is_default_search',
51 'live.enabled', 'live.allow_replay', 'live.max_duration', 'live.max_user_lives', 'live.max_instance_lives', 51 'live.enabled', 'live.allow_replay', 'live.max_duration', 'live.max_user_lives', 'live.max_instance_lives',
52 'live.rtmp.enabled', 'live.rtmp.port', 'live.rtmps.enabled', 'live.rtmps.port', 'live.rtmps.key_file', 'live.rtmps.cert_file', 52 'live.rtmp.enabled', 'live.rtmp.port', 'live.rtmp.hostname',
53 'live.rtmps.enabled', 'live.rtmps.port', 'live.rtmps.hostname', 'live.rtmps.key_file', 'live.rtmps.cert_file',
53 'live.transcoding.enabled', 'live.transcoding.threads', 'live.transcoding.profile', 54 'live.transcoding.enabled', 'live.transcoding.threads', 'live.transcoding.profile',
54 'live.transcoding.resolutions.144p', 'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p', 55 'live.transcoding.resolutions.144p', 'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p',
55 'live.transcoding.resolutions.480p', 'live.transcoding.resolutions.720p', 'live.transcoding.resolutions.1080p', 56 'live.transcoding.resolutions.480p', 'live.transcoding.resolutions.720p', 'live.transcoding.resolutions.1080p',
@@ -116,12 +117,8 @@ function checkNodeVersion () {
116 117
117 logger.debug('Checking NodeJS version %s.', v) 118 logger.debug('Checking NodeJS version %s.', v)
118 119
119 if (major <= 10) {
120 throw new Error('Your NodeJS version ' + v + ' is not supported. Please upgrade.')
121 }
122
123 if (major <= 12) { 120 if (major <= 12) {
124 logger.warn('Your NodeJS version ' + v + ' is deprecated. Please upgrade.') 121 throw new Error('Your NodeJS version ' + v + ' is not supported. Please upgrade.')
125 } 122 }
126} 123}
127 124
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index fb6f7ae62..63056b41d 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -297,12 +297,14 @@ const CONFIG = {
297 297
298 RTMP: { 298 RTMP: {
299 get ENABLED () { return config.get<boolean>('live.rtmp.enabled') }, 299 get ENABLED () { return config.get<boolean>('live.rtmp.enabled') },
300 get PORT () { return config.get<number>('live.rtmp.port') } 300 get PORT () { return config.get<number>('live.rtmp.port') },
301 get HOSTNAME () { return config.get<number>('live.rtmp.hostname') }
301 }, 302 },
302 303
303 RTMPS: { 304 RTMPS: {
304 get ENABLED () { return config.get<boolean>('live.rtmps.enabled') }, 305 get ENABLED () { return config.get<boolean>('live.rtmps.enabled') },
305 get PORT () { return config.get<number>('live.rtmps.port') }, 306 get PORT () { return config.get<number>('live.rtmps.port') },
307 get HOSTNAME () { return config.get<number>('live.rtmps.hostname') },
306 get KEY_FILE () { return config.get<string>('live.rtmps.key_file') }, 308 get KEY_FILE () { return config.get<string>('live.rtmps.key_file') },
307 get CERT_FILE () { return config.get<string>('live.rtmps.cert_file') } 309 get CERT_FILE () { return config.get<string>('live.rtmps.cert_file') }
308 }, 310 },
@@ -324,6 +326,9 @@ const CONFIG = {
324 } 326 }
325 } 327 }
326 }, 328 },
329 VIDEO_EDITOR: {
330 get ENABLED () { return config.get<boolean>('video_editor.enabled') }
331 },
327 IMPORT: { 332 IMPORT: {
328 VIDEOS: { 333 VIDEOS: {
329 get CONCURRENCY () { return config.get<number>('import.videos.concurrency') }, 334 get CONCURRENCY () { return config.get<number>('import.videos.concurrency') },
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 1c47d43f0..3069e2353 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
@@ -152,6 +152,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
152 'activitypub-refresher': 1, 152 'activitypub-refresher': 1,
153 'video-redundancy': 1, 153 'video-redundancy': 1,
154 'video-live-ending': 1, 154 'video-live-ending': 1,
155 'video-edition': 1,
155 'move-to-object-storage': 3 156 'move-to-object-storage': 3
156} 157}
157// Excluded keys are jobs that can be configured by admins 158// Excluded keys are jobs that can be configured by admins
@@ -168,6 +169,7 @@ const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-im
168 'activitypub-refresher': 1, 169 'activitypub-refresher': 1,
169 'video-redundancy': 1, 170 'video-redundancy': 1,
170 'video-live-ending': 10, 171 'video-live-ending': 10,
172 'video-edition': 1,
171 'move-to-object-storage': 1 173 'move-to-object-storage': 1
172} 174}
173const JOB_TTL: { [id in JobType]: number } = { 175const JOB_TTL: { [id in JobType]: number } = {
@@ -178,6 +180,7 @@ const JOB_TTL: { [id in JobType]: number } = {
178 'activitypub-cleaner': 1000 * 3600, // 1 hour 180 'activitypub-cleaner': 1000 * 3600, // 1 hour
179 'video-file-import': 1000 * 3600, // 1 hour 181 'video-file-import': 1000 * 3600, // 1 hour
180 'video-transcoding': 1000 * 3600 * 48, // 2 days, transcoding could be long 182 'video-transcoding': 1000 * 3600 * 48, // 2 days, transcoding could be long
183 'video-edition': 1000 * 3600 * 10, // 10 hours
181 'video-import': 1000 * 3600 * 2, // 2 hours 184 'video-import': 1000 * 3600 * 2, // 2 hours
182 'email': 60000 * 10, // 10 minutes 185 'email': 60000 * 10, // 10 minutes
183 'actor-keys': 60000 * 20, // 20 minutes 186 'actor-keys': 60000 * 20, // 20 minutes
@@ -351,6 +354,10 @@ const CONSTRAINTS_FIELDS = {
351 }, 354 },
352 COMMONS: { 355 COMMONS: {
353 URL: { min: 5, max: 2000 } // Length 356 URL: { min: 5, max: 2000 } // Length
357 },
358 VIDEO_EDITOR: {
359 TASKS: { min: 1, max: 10 }, // Number of tasks
360 CUT_TIME: { min: 0 } // Value
354 } 361 }
355} 362}
356 363
@@ -365,6 +372,7 @@ const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = {
365 MIN: 1, 372 MIN: 1,
366 STANDARD: [ 24, 25, 30 ], 373 STANDARD: [ 24, 25, 30 ],
367 HD_STANDARD: [ 50, 60 ], 374 HD_STANDARD: [ 50, 60 ],
375 AUDIO_MERGE: 25,
368 AVERAGE: 30, 376 AVERAGE: 30,
369 MAX: 60, 377 MAX: 60,
370 KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum) 378 KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum)
@@ -434,7 +442,8 @@ const VIDEO_STATES: { [ id in VideoState ]: string } = {
434 [VideoState.LIVE_ENDED]: 'Livestream ended', 442 [VideoState.LIVE_ENDED]: 'Livestream ended',
435 [VideoState.TO_MOVE_TO_EXTERNAL_STORAGE]: 'To move to an external storage', 443 [VideoState.TO_MOVE_TO_EXTERNAL_STORAGE]: 'To move to an external storage',
436 [VideoState.TRANSCODING_FAILED]: 'Transcoding failed', 444 [VideoState.TRANSCODING_FAILED]: 'Transcoding failed',
437 [VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED]: 'External storage move failed' 445 [VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED]: 'External storage move failed',
446 [VideoState.TO_EDIT]: 'To edit*'
438} 447}
439 448
440const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = { 449const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = {
@@ -633,15 +642,23 @@ const PREVIEWS_SIZE = {
633 height: 480, 642 height: 480,
634 minWidth: 400 643 minWidth: 400
635} 644}
636const ACTOR_IMAGES_SIZE = { 645const ACTOR_IMAGES_SIZE: { [key in ActorImageType]: { width: number, height: number }[]} = {
637 AVATARS: { 646 [ActorImageType.AVATAR]: [
638 width: 120, 647 {
639 height: 120 648 width: 120,
640 }, 649 height: 120
641 BANNERS: { 650 },
642 width: 1920, 651 {
643 height: 317 // 6/1 ratio 652 width: 48,
644 } 653 height: 48
654 }
655 ],
656 [ActorImageType.BANNER]: [
657 {
658 width: 1920,
659 height: 317 // 6/1 ratio
660 }
661 ]
645} 662}
646 663
647const EMBED_SIZE = { 664const EMBED_SIZE = {
@@ -701,10 +718,12 @@ const MEMOIZE_TTL = {
701 OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours 718 OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours
702 INFO_HASH_EXISTS: 1000 * 3600 * 12, // 12 hours 719 INFO_HASH_EXISTS: 1000 * 3600 * 12, // 12 hours
703 LIVE_ABLE_TO_UPLOAD: 1000 * 60, // 1 minute 720 LIVE_ABLE_TO_UPLOAD: 1000 * 60, // 1 minute
704 LIVE_CHECK_SOCKET_HEALTH: 1000 * 60 // 1 minute 721 LIVE_CHECK_SOCKET_HEALTH: 1000 * 60, // 1 minute
722 MD_TO_PLAIN_TEXT_CLIENT_HTML: 1000 * 60 // 1 minute
705} 723}
706 724
707const MEMOIZE_LENGTH = { 725const MEMOIZE_LENGTH = {
726 MD_TO_PLAIN_TEXT_CLIENT_HTML: 100,
708 INFO_HASH_EXISTS: 200 727 INFO_HASH_EXISTS: 200
709} 728}
710 729
@@ -847,6 +866,16 @@ const FILES_CONTENT_HASH = {
847 866
848// --------------------------------------------------------------------------- 867// ---------------------------------------------------------------------------
849 868
869const VIDEO_FILTERS = {
870 WATERMARK: {
871 SIZE_RATIO: 1 / 10,
872 HORIZONTAL_MARGIN_RATIO: 1 / 20,
873 VERTICAL_MARGIN_RATIO: 1 / 20
874 }
875}
876
877// ---------------------------------------------------------------------------
878
850export { 879export {
851 WEBSERVER, 880 WEBSERVER,
852 API_VERSION, 881 API_VERSION,
@@ -885,6 +914,7 @@ export {
885 PLUGIN_GLOBAL_CSS_FILE_NAME, 914 PLUGIN_GLOBAL_CSS_FILE_NAME,
886 PLUGIN_GLOBAL_CSS_PATH, 915 PLUGIN_GLOBAL_CSS_PATH,
887 PRIVATE_RSA_KEY_SIZE, 916 PRIVATE_RSA_KEY_SIZE,
917 VIDEO_FILTERS,
888 ROUTE_CACHE_LIFETIME, 918 ROUTE_CACHE_LIFETIME,
889 SORTABLE_COLUMNS, 919 SORTABLE_COLUMNS,
890 HLS_STREAMING_PLAYLIST_DIRECTORY, 920 HLS_STREAMING_PLAYLIST_DIRECTORY,
@@ -1016,8 +1046,8 @@ function updateWebserverUrls () {
1016 WEBSERVER.HOSTNAME = CONFIG.WEBSERVER.HOSTNAME 1046 WEBSERVER.HOSTNAME = CONFIG.WEBSERVER.HOSTNAME
1017 WEBSERVER.PORT = CONFIG.WEBSERVER.PORT 1047 WEBSERVER.PORT = CONFIG.WEBSERVER.PORT
1018 1048
1019 WEBSERVER.RTMP_URL = 'rtmp://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.LIVE.RTMP.PORT + '/' + VIDEO_LIVE.RTMP.BASE_PATH 1049 WEBSERVER.RTMP_URL = 'rtmp://' + CONFIG.LIVE.RTMP.HOSTNAME + ':' + CONFIG.LIVE.RTMP.PORT + '/' + VIDEO_LIVE.RTMP.BASE_PATH
1020 WEBSERVER.RTMPS_URL = 'rtmps://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.LIVE.RTMPS.PORT + '/' + VIDEO_LIVE.RTMP.BASE_PATH 1050 WEBSERVER.RTMPS_URL = 'rtmps://' + CONFIG.LIVE.RTMPS.HOSTNAME + ':' + CONFIG.LIVE.RTMPS.PORT + '/' + VIDEO_LIVE.RTMP.BASE_PATH
1021} 1051}
1022 1052
1023function updateWebserverConfig () { 1053function updateWebserverConfig () {
diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts
index 7e321fb76..0517e0084 100644
--- a/server/initializers/installer.ts
+++ b/server/initializers/installer.ts
@@ -2,10 +2,9 @@ import { ensureDir, remove } from 'fs-extra'
2import passwordGenerator from 'password-generator' 2import passwordGenerator from 'password-generator'
3import { UserRole } from '@shared/models' 3import { UserRole } from '@shared/models'
4import { logger } from '../helpers/logger' 4import { logger } from '../helpers/logger'
5import { createApplicationActor, createUserAccountAndChannelAndPlaylist } from '../lib/user' 5import { buildUser, createApplicationActor, createUserAccountAndChannelAndPlaylist } from '../lib/user'
6import { ApplicationModel } from '../models/application/application' 6import { ApplicationModel } from '../models/application/application'
7import { OAuthClientModel } from '../models/oauth/oauth-client' 7import { OAuthClientModel } from '../models/oauth/oauth-client'
8import { UserModel } from '../models/user/user'
9import { applicationExist, clientsExist, usersExist } from './checker-after-init' 8import { applicationExist, clientsExist, usersExist } from './checker-after-init'
10import { CONFIG } from './config' 9import { CONFIG } from './config'
11import { FILES_CACHE, HLS_STREAMING_PLAYLIST_DIRECTORY, LAST_MIGRATION_VERSION, RESUMABLE_UPLOAD_DIRECTORY } from './constants' 10import { FILES_CACHE, HLS_STREAMING_PLAYLIST_DIRECTORY, LAST_MIGRATION_VERSION, RESUMABLE_UPLOAD_DIRECTORY } from './constants'
@@ -137,18 +136,15 @@ async function createOAuthAdminIfNotExist () {
137 password = passwordGenerator(16, true) 136 password = passwordGenerator(16, true)
138 } 137 }
139 138
140 const userData = { 139 const user = buildUser({
141 username, 140 username,
142 email, 141 email,
143 password, 142 password,
144 role, 143 role,
145 verified: true, 144 emailVerified: true,
146 nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
147 p2pEnabled: CONFIG.DEFAULTS.P2P.WEBAPP.ENABLED,
148 videoQuota: -1, 145 videoQuota: -1,
149 videoQuotaDaily: -1 146 videoQuotaDaily: -1
150 } 147 })
151 const user = new UserModel(userData)
152 148
153 await createUserAccountAndChannelAndPlaylist({ userToCreate: user, channelNames: undefined, validateUser: validatePassword }) 149 await createUserAccountAndChannelAndPlaylist({ userToCreate: user, channelNames: undefined, validateUser: validatePassword })
154 logger.info('Username: ' + username) 150 logger.info('Username: ' + username)
diff --git a/server/initializers/migrations/0075-video-resolutions.ts b/server/initializers/migrations/0075-video-resolutions.ts
index 6e8e47acb..8cd47496e 100644
--- a/server/initializers/migrations/0075-video-resolutions.ts
+++ b/server/initializers/migrations/0075-video-resolutions.ts
@@ -1,8 +1,8 @@
1import * as Sequelize from 'sequelize' 1import { readdir, rename } from 'fs-extra'
2import { join } from 'path' 2import { join } from 'path'
3import * as Sequelize from 'sequelize'
4import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg/ffprobe-utils'
3import { CONFIG } from '../../initializers/config' 5import { CONFIG } from '../../initializers/config'
4import { getVideoFileResolution } from '../../helpers/ffprobe-utils'
5import { readdir, rename } from 'fs-extra'
6 6
7function up (utils: { 7function up (utils: {
8 transaction: Sequelize.Transaction 8 transaction: Sequelize.Transaction
@@ -26,7 +26,7 @@ function up (utils: {
26 const uuid = matches[1] 26 const uuid = matches[1]
27 const ext = matches[2] 27 const ext = matches[2]
28 28
29 const p = getVideoFileResolution(join(videoFileDir, videoFile)) 29 const p = getVideoStreamDimensionsInfo(join(videoFileDir, videoFile))
30 .then(async ({ resolution }) => { 30 .then(async ({ resolution }) => {
31 const oldTorrentName = uuid + '.torrent' 31 const oldTorrentName = uuid + '.torrent'
32 const newTorrentName = uuid + '-' + resolution + '.torrent' 32 const newTorrentName = uuid + '-' + resolution + '.torrent'
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/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts
index 5d68f44e9..910fdeec1 100644
--- a/server/lib/auth/oauth-model.ts
+++ b/server/lib/auth/oauth-model.ts
@@ -5,14 +5,14 @@ import { ActorModel } from '@server/models/actor/actor'
5import { MOAuthClient } from '@server/types/models' 5import { MOAuthClient } from '@server/types/models'
6import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' 6import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
7import { MUser } from '@server/types/models/user/user' 7import { MUser } from '@server/types/models/user/user'
8import { UserAdminFlag } from '@shared/models/users/user-flag.model' 8import { pick } from '@shared/core-utils'
9import { UserRole } from '@shared/models/users/user-role' 9import { UserRole } from '@shared/models/users/user-role'
10import { logger } from '../../helpers/logger' 10import { logger } from '../../helpers/logger'
11import { CONFIG } from '../../initializers/config' 11import { CONFIG } from '../../initializers/config'
12import { OAuthClientModel } from '../../models/oauth/oauth-client' 12import { OAuthClientModel } from '../../models/oauth/oauth-client'
13import { OAuthTokenModel } from '../../models/oauth/oauth-token' 13import { OAuthTokenModel } from '../../models/oauth/oauth-token'
14import { UserModel } from '../../models/user/user' 14import { UserModel } from '../../models/user/user'
15import { createUserAccountAndChannelAndPlaylist } from '../user' 15import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user'
16import { TokensCache } from './tokens-cache' 16import { TokensCache } from './tokens-cache'
17 17
18type TokenInfo = { 18type TokenInfo = {
@@ -229,19 +229,13 @@ async function createUserFromExternal (pluginAuth: string, options: {
229 const actor = await ActorModel.loadLocalByName(options.username) 229 const actor = await ActorModel.loadLocalByName(options.username)
230 if (actor) return null 230 if (actor) return null
231 231
232 const userToCreate = new UserModel({ 232 const userToCreate = buildUser({
233 username: options.username, 233 ...pick(options, [ 'username', 'email', 'role' ]),
234
235 emailVerified: null,
234 password: null, 236 password: null,
235 email: options.email,
236 nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
237 p2pEnabled: CONFIG.DEFAULTS.P2P.WEBAPP.ENABLED,
238 autoPlayVideo: true,
239 role: options.role,
240 videoQuota: CONFIG.USER.VIDEO_QUOTA,
241 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY,
242 adminFlags: UserAdminFlag.NONE,
243 pluginAuth 237 pluginAuth
244 }) as MUser 238 })
245 239
246 const { user } = await createUserAccountAndChannelAndPlaylist({ 240 const { user } = await createUserAccountAndChannelAndPlaylist({
247 userToCreate, 241 userToCreate,
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 19354ab70..945bc712f 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -1,8 +1,10 @@
1import express from 'express' 1import express from 'express'
2import { readFile } from 'fs-extra' 2import { readFile } from 'fs-extra'
3import memoizee from 'memoizee'
3import { join } from 'path' 4import { join } from 'path'
4import validator from 'validator' 5import validator from 'validator'
5import { toCompleteUUID } from '@server/helpers/custom-validators/misc' 6import { toCompleteUUID } from '@server/helpers/custom-validators/misc'
7import { ActorImageModel } from '@server/models/actor/actor-image'
6import { root } from '@shared/core-utils' 8import { root } from '@shared/core-utils'
7import { escapeHTML } from '@shared/core-utils/renderer' 9import { escapeHTML } from '@shared/core-utils/renderer'
8import { sha256 } from '@shared/extra-utils' 10import { sha256 } from '@shared/extra-utils'
@@ -16,10 +18,11 @@ import { mdToOneLinePlainText } from '../helpers/markdown'
16import { CONFIG } from '../initializers/config' 18import { CONFIG } from '../initializers/config'
17import { 19import {
18 ACCEPT_HEADERS, 20 ACCEPT_HEADERS,
19 ACTOR_IMAGES_SIZE,
20 CUSTOM_HTML_TAG_COMMENTS, 21 CUSTOM_HTML_TAG_COMMENTS,
21 EMBED_SIZE, 22 EMBED_SIZE,
22 FILES_CONTENT_HASH, 23 FILES_CONTENT_HASH,
24 MEMOIZE_LENGTH,
25 MEMOIZE_TTL,
23 PLUGIN_GLOBAL_CSS_PATH, 26 PLUGIN_GLOBAL_CSS_PATH,
24 WEBSERVER 27 WEBSERVER
25} from '../initializers/constants' 28} from '../initializers/constants'
@@ -29,8 +32,14 @@ import { VideoModel } from '../models/video/video'
29import { VideoChannelModel } from '../models/video/video-channel' 32import { VideoChannelModel } from '../models/video/video-channel'
30import { VideoPlaylistModel } from '../models/video/video-playlist' 33import { VideoPlaylistModel } from '../models/video/video-playlist'
31import { MAccountActor, MChannelActor } from '../types/models' 34import { MAccountActor, MChannelActor } from '../types/models'
35import { getBiggestActorImage } from './actor-image'
32import { ServerConfigManager } from './server-config-manager' 36import { ServerConfigManager } from './server-config-manager'
33 37
38const getPlainTextDescriptionCached = memoizee(mdToOneLinePlainText, {
39 maxAge: MEMOIZE_TTL.MD_TO_PLAIN_TEXT_CLIENT_HTML,
40 max: MEMOIZE_LENGTH.MD_TO_PLAIN_TEXT_CLIENT_HTML
41})
42
34type Tags = { 43type Tags = {
35 ogType: string 44 ogType: string
36 twitterCard: 'player' | 'summary' | 'summary_large_image' 45 twitterCard: 'player' | 'summary' | 'summary_large_image'
@@ -103,7 +112,7 @@ class ClientHtml {
103 res.status(HttpStatusCode.NOT_FOUND_404) 112 res.status(HttpStatusCode.NOT_FOUND_404)
104 return html 113 return html
105 } 114 }
106 const description = mdToOneLinePlainText(video.description) 115 const description = getPlainTextDescriptionCached(video.description)
107 116
108 let customHtml = ClientHtml.addTitleTag(html, video.name) 117 let customHtml = ClientHtml.addTitleTag(html, video.name)
109 customHtml = ClientHtml.addDescriptionTag(customHtml, description) 118 customHtml = ClientHtml.addDescriptionTag(customHtml, description)
@@ -164,7 +173,7 @@ class ClientHtml {
164 return html 173 return html
165 } 174 }
166 175
167 const description = mdToOneLinePlainText(videoPlaylist.description) 176 const description = getPlainTextDescriptionCached(videoPlaylist.description)
168 177
169 let customHtml = ClientHtml.addTitleTag(html, videoPlaylist.name) 178 let customHtml = ClientHtml.addTitleTag(html, videoPlaylist.name)
170 customHtml = ClientHtml.addDescriptionTag(customHtml, description) 179 customHtml = ClientHtml.addDescriptionTag(customHtml, description)
@@ -263,7 +272,7 @@ class ClientHtml {
263 return ClientHtml.getIndexHTML(req, res) 272 return ClientHtml.getIndexHTML(req, res)
264 } 273 }
265 274
266 const description = mdToOneLinePlainText(entity.description) 275 const description = getPlainTextDescriptionCached(entity.description)
267 276
268 let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName()) 277 let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName())
269 customHtml = ClientHtml.addDescriptionTag(customHtml, description) 278 customHtml = ClientHtml.addDescriptionTag(customHtml, description)
@@ -273,10 +282,11 @@ class ClientHtml {
273 const siteName = CONFIG.INSTANCE.NAME 282 const siteName = CONFIG.INSTANCE.NAME
274 const title = entity.getDisplayName() 283 const title = entity.getDisplayName()
275 284
285 const avatar = getBiggestActorImage(entity.Actor.Avatars)
276 const image = { 286 const image = {
277 url: entity.Actor.getAvatarUrl(), 287 url: ActorImageModel.getImageUrl(avatar),
278 width: ACTOR_IMAGES_SIZE.AVATARS.width, 288 width: avatar?.width,
279 height: ACTOR_IMAGES_SIZE.AVATARS.height 289 height: avatar?.height
280 } 290 }
281 291
282 const ogType = 'website' 292 const ogType = 'website'
diff --git a/server/lib/hls.ts b/server/lib/hls.ts
index 985f50587..43043315b 100644
--- a/server/lib/hls.ts
+++ b/server/lib/hls.ts
@@ -4,7 +4,7 @@ import { basename, dirname, join } from 'path'
4import { MStreamingPlaylistFilesVideo, MVideo, MVideoUUID } from '@server/types/models' 4import { MStreamingPlaylistFilesVideo, MVideo, MVideoUUID } from '@server/types/models'
5import { sha256 } from '@shared/extra-utils' 5import { sha256 } from '@shared/extra-utils'
6import { VideoStorage } from '@shared/models' 6import { VideoStorage } from '@shared/models'
7import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamSize } from '../helpers/ffprobe-utils' 7import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamDimensionsInfo } from '../helpers/ffmpeg'
8import { logger } from '../helpers/logger' 8import { logger } from '../helpers/logger'
9import { doRequest, doRequestAndSaveToFile } from '../helpers/requests' 9import { doRequest, doRequestAndSaveToFile } from '../helpers/requests'
10import { generateRandomString } from '../helpers/utils' 10import { generateRandomString } from '../helpers/utils'
@@ -40,10 +40,10 @@ async function updateMasterHLSPlaylist (video: MVideo, playlist: MStreamingPlayl
40 const playlistFilename = getHlsResolutionPlaylistFilename(file.filename) 40 const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
41 41
42 await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => { 42 await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
43 const size = await getVideoStreamSize(videoFilePath) 43 const size = await getVideoStreamDimensionsInfo(videoFilePath)
44 44
45 const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file) 45 const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
46 const resolution = `RESOLUTION=${size.width}x${size.height}` 46 const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}`
47 47
48 let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}` 48 let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
49 if (file.fps) line += ',FRAME-RATE=' + file.fps 49 if (file.fps) line += ',FRAME-RATE=' + file.fps
diff --git a/server/lib/job-queue/handlers/video-edition.ts b/server/lib/job-queue/handlers/video-edition.ts
new file mode 100644
index 000000000..c5ba0452f
--- /dev/null
+++ b/server/lib/job-queue/handlers/video-edition.ts
@@ -0,0 +1,229 @@
1import { Job } from 'bull'
2import { move, remove } from 'fs-extra'
3import { join } from 'path'
4import { addIntroOutro, addWatermark, cutVideo } from '@server/helpers/ffmpeg'
5import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent'
6import { CONFIG } from '@server/initializers/config'
7import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
8import { generateWebTorrentVideoFilename } from '@server/lib/paths'
9import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
10import { isAbleToUploadVideo } from '@server/lib/user'
11import { addMoveToObjectStorageJob, addOptimizeOrMergeAudioJob } from '@server/lib/video'
12import { approximateIntroOutroAdditionalSize } from '@server/lib/video-editor'
13import { VideoPathManager } from '@server/lib/video-path-manager'
14import { buildNextVideoState } from '@server/lib/video-state'
15import { UserModel } from '@server/models/user/user'
16import { VideoModel } from '@server/models/video/video'
17import { VideoFileModel } from '@server/models/video/video-file'
18import { MVideo, MVideoFile, MVideoFullLight, MVideoId, MVideoWithAllFiles } from '@server/types/models'
19import { getLowercaseExtension, pick } from '@shared/core-utils'
20import {
21 buildFileMetadata,
22 buildUUID,
23 ffprobePromise,
24 getFileSize,
25 getVideoStreamDimensionsInfo,
26 getVideoStreamDuration,
27 getVideoStreamFPS
28} from '@shared/extra-utils'
29import {
30 VideoEditionPayload,
31 VideoEditionTaskPayload,
32 VideoEditorTask,
33 VideoEditorTaskCutPayload,
34 VideoEditorTaskIntroPayload,
35 VideoEditorTaskOutroPayload,
36 VideoEditorTaskWatermarkPayload,
37 VideoState
38} from '@shared/models'
39import { logger, loggerTagsFactory } from '../../../helpers/logger'
40
41const lTagsBase = loggerTagsFactory('video-edition')
42
43async function processVideoEdition (job: Job) {
44 const payload = job.data as VideoEditionPayload
45
46 logger.info('Process video edition of %s in job %d.', payload.videoUUID, job.id)
47
48 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID)
49
50 // No video, maybe deleted?
51 if (!video) {
52 logger.info('Can\'t process job %d, video does not exist.', job.id, lTagsBase(payload.videoUUID))
53 return undefined
54 }
55
56 await checkUserQuotaOrThrow(video, payload)
57
58 const inputFile = video.getMaxQualityFile()
59
60 const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => {
61 let tmpInputFilePath: string
62 let outputPath: string
63
64 for (const task of payload.tasks) {
65 const outputFilename = buildUUID() + inputFile.extname
66 outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename)
67
68 await processTask({
69 inputPath: tmpInputFilePath ?? originalFilePath,
70 video,
71 outputPath,
72 task
73 })
74
75 if (tmpInputFilePath) await remove(tmpInputFilePath)
76
77 // For the next iteration
78 tmpInputFilePath = outputPath
79 }
80
81 return outputPath
82 })
83
84 logger.info('Video edition ended for video %s.', video.uuid)
85
86 const newFile = await buildNewFile(video, editionResultPath)
87
88 const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile)
89 await move(editionResultPath, outputPath)
90
91 await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath)
92
93 await removeAllFiles(video, newFile)
94
95 await newFile.save()
96
97 video.state = buildNextVideoState()
98 video.duration = await getVideoStreamDuration(outputPath)
99 await video.save()
100
101 await federateVideoIfNeeded(video, false, undefined)
102
103 if (video.state === VideoState.TO_TRANSCODE) {
104 const user = await UserModel.loadByVideoId(video.id)
105
106 await addOptimizeOrMergeAudioJob(video, newFile, user, false)
107 } else if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
108 await addMoveToObjectStorageJob(video, false)
109 }
110}
111
112// ---------------------------------------------------------------------------
113
114export {
115 processVideoEdition
116}
117
118// ---------------------------------------------------------------------------
119
120type TaskProcessorOptions <T extends VideoEditionTaskPayload = VideoEditionTaskPayload> = {
121 inputPath: string
122 outputPath: string
123 video: MVideo
124 task: T
125}
126
127const taskProcessors: { [id in VideoEditorTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = {
128 'add-intro': processAddIntroOutro,
129 'add-outro': processAddIntroOutro,
130 'cut': processCut,
131 'add-watermark': processAddWatermark
132}
133
134async function processTask (options: TaskProcessorOptions) {
135 const { video, task } = options
136
137 logger.info('Processing %s task for video %s.', task.name, video.uuid, { task })
138
139 const processor = taskProcessors[options.task.name]
140 if (!process) throw new Error('Unknown task ' + task.name)
141
142 return processor(options)
143}
144
145function processAddIntroOutro (options: TaskProcessorOptions<VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload>) {
146 const { task } = options
147
148 return addIntroOutro({
149 ...pick(options, [ 'inputPath', 'outputPath' ]),
150
151 introOutroPath: task.options.file,
152 type: task.name === 'add-intro'
153 ? 'intro'
154 : 'outro',
155
156 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
157 profile: CONFIG.TRANSCODING.PROFILE
158 })
159}
160
161function processCut (options: TaskProcessorOptions<VideoEditorTaskCutPayload>) {
162 const { task } = options
163
164 return cutVideo({
165 ...pick(options, [ 'inputPath', 'outputPath' ]),
166
167 start: task.options.start,
168 end: task.options.end
169 })
170}
171
172function processAddWatermark (options: TaskProcessorOptions<VideoEditorTaskWatermarkPayload>) {
173 const { task } = options
174
175 return addWatermark({
176 ...pick(options, [ 'inputPath', 'outputPath' ]),
177
178 watermarkPath: task.options.file,
179
180 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
181 profile: CONFIG.TRANSCODING.PROFILE
182 })
183}
184
185async function buildNewFile (video: MVideoId, path: string) {
186 const videoFile = new VideoFileModel({
187 extname: getLowercaseExtension(path),
188 size: await getFileSize(path),
189 metadata: await buildFileMetadata(path),
190 videoStreamingPlaylistId: null,
191 videoId: video.id
192 })
193
194 const probe = await ffprobePromise(path)
195
196 videoFile.fps = await getVideoStreamFPS(path, probe)
197 videoFile.resolution = (await getVideoStreamDimensionsInfo(path, probe)).resolution
198
199 videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
200
201 return videoFile
202}
203
204async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) {
205 const hls = video.getHLSPlaylist()
206
207 if (hls) {
208 await video.removeStreamingPlaylistFiles(hls)
209 await hls.destroy()
210 }
211
212 for (const file of video.VideoFiles) {
213 if (file.id === webTorrentFileException.id) continue
214
215 await video.removeWebTorrentFileAndTorrent(file)
216 await file.destroy()
217 }
218}
219
220async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoEditionPayload) {
221 const user = await UserModel.loadByVideoId(video.id)
222
223 const filePathFinder = (i: number) => (payload.tasks[i] as VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload).options.file
224
225 const additionalBytes = await approximateIntroOutroAdditionalSize(video, payload.tasks, filePathFinder)
226 if (await isAbleToUploadVideo(user.id, additionalBytes) === false) {
227 throw new Error('Quota exceeded for this user to edit the video')
228 }
229}
diff --git a/server/lib/job-queue/handlers/video-file-import.ts b/server/lib/job-queue/handlers/video-file-import.ts
index 0d9e80cb8..6b2d60317 100644
--- a/server/lib/job-queue/handlers/video-file-import.ts
+++ b/server/lib/job-queue/handlers/video-file-import.ts
@@ -1,18 +1,18 @@
1import { Job } from 'bull' 1import { Job } from 'bull'
2import { copy, stat } from 'fs-extra' 2import { copy, stat } from 'fs-extra'
3import { getLowercaseExtension } from '@shared/core-utils'
4import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 3import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
5import { CONFIG } from '@server/initializers/config' 4import { CONFIG } from '@server/initializers/config'
6import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' 5import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
7import { generateWebTorrentVideoFilename } from '@server/lib/paths' 6import { generateWebTorrentVideoFilename } from '@server/lib/paths'
8import { addMoveToObjectStorageJob } from '@server/lib/video' 7import { addMoveToObjectStorageJob } from '@server/lib/video'
9import { VideoPathManager } from '@server/lib/video-path-manager' 8import { VideoPathManager } from '@server/lib/video-path-manager'
9import { VideoModel } from '@server/models/video/video'
10import { VideoFileModel } from '@server/models/video/video-file'
10import { MVideoFullLight } from '@server/types/models' 11import { MVideoFullLight } from '@server/types/models'
12import { getLowercaseExtension } from '@shared/core-utils'
11import { VideoFileImportPayload, VideoStorage } from '@shared/models' 13import { VideoFileImportPayload, VideoStorage } from '@shared/models'
12import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' 14import { getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
13import { logger } from '../../../helpers/logger' 15import { logger } from '../../../helpers/logger'
14import { VideoModel } from '../../../models/video/video'
15import { VideoFileModel } from '../../../models/video/video-file'
16 16
17async function processVideoFileImport (job: Job) { 17async function processVideoFileImport (job: Job) {
18 const payload = job.data as VideoFileImportPayload 18 const payload = job.data as VideoFileImportPayload
@@ -45,9 +45,9 @@ export {
45// --------------------------------------------------------------------------- 45// ---------------------------------------------------------------------------
46 46
47async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) { 47async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) {
48 const { resolution } = await getVideoFileResolution(inputFilePath) 48 const { resolution } = await getVideoStreamDimensionsInfo(inputFilePath)
49 const { size } = await stat(inputFilePath) 49 const { size } = await stat(inputFilePath)
50 const fps = await getVideoFileFPS(inputFilePath) 50 const fps = await getVideoStreamFPS(inputFilePath)
51 51
52 const fileExt = getLowercaseExtension(inputFilePath) 52 const fileExt = getLowercaseExtension(inputFilePath)
53 53
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts
index b6e05d8f5..b3ca28c2f 100644
--- a/server/lib/job-queue/handlers/video-import.ts
+++ b/server/lib/job-queue/handlers/video-import.ts
@@ -25,7 +25,7 @@ import {
25 VideoResolution, 25 VideoResolution,
26 VideoState 26 VideoState
27} from '@shared/models' 27} from '@shared/models'
28import { ffprobePromise, getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' 28import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
29import { logger } from '../../../helpers/logger' 29import { logger } from '../../../helpers/logger'
30import { getSecureTorrentName } from '../../../helpers/utils' 30import { getSecureTorrentName } from '../../../helpers/utils'
31import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent' 31import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent'
@@ -121,10 +121,10 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
121 121
122 const { resolution } = await isAudioFile(tempVideoPath, probe) 122 const { resolution } = await isAudioFile(tempVideoPath, probe)
123 ? { resolution: VideoResolution.H_NOVIDEO } 123 ? { resolution: VideoResolution.H_NOVIDEO }
124 : await getVideoFileResolution(tempVideoPath) 124 : await getVideoStreamDimensionsInfo(tempVideoPath)
125 125
126 const fps = await getVideoFileFPS(tempVideoPath, probe) 126 const fps = await getVideoStreamFPS(tempVideoPath, probe)
127 const duration = await getDurationFromVideoFile(tempVideoPath, probe) 127 const duration = await getVideoStreamDuration(tempVideoPath, probe)
128 128
129 // Prepare video file object for creation in database 129 // Prepare video file object for creation in database
130 const fileExt = getLowercaseExtension(tempVideoPath) 130 const fileExt = getLowercaseExtension(tempVideoPath)
diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts
index a04cfa2c9..497f6612a 100644
--- a/server/lib/job-queue/handlers/video-live-ending.ts
+++ b/server/lib/job-queue/handlers/video-live-ending.ts
@@ -1,12 +1,12 @@
1import { Job } from 'bull' 1import { Job } from 'bull'
2import { pathExists, readdir, remove } from 'fs-extra' 2import { pathExists, readdir, remove } from 'fs-extra'
3import { join } from 'path' 3import { join } from 'path'
4import { ffprobePromise, getAudioStream, getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils' 4import { ffprobePromise, getAudioStream, getVideoStreamDuration, getVideoStreamDimensionsInfo } from '@server/helpers/ffmpeg'
5import { VIDEO_LIVE } from '@server/initializers/constants' 5import { VIDEO_LIVE } from '@server/initializers/constants'
6import { buildConcatenatedName, cleanupLive, LiveSegmentShaStore } from '@server/lib/live' 6import { buildConcatenatedName, cleanupLive, LiveSegmentShaStore } from '@server/lib/live'
7import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveDirectory } from '@server/lib/paths' 7import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveDirectory } from '@server/lib/paths'
8import { generateVideoMiniature } from '@server/lib/thumbnail' 8import { generateVideoMiniature } from '@server/lib/thumbnail'
9import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/video-transcoding' 9import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding'
10import { VideoPathManager } from '@server/lib/video-path-manager' 10import { VideoPathManager } from '@server/lib/video-path-manager'
11import { moveToNextState } from '@server/lib/video-state' 11import { moveToNextState } from '@server/lib/video-state'
12import { VideoModel } from '@server/models/video/video' 12import { VideoModel } from '@server/models/video/video'
@@ -96,7 +96,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
96 const probe = await ffprobePromise(concatenatedTsFilePath) 96 const probe = await ffprobePromise(concatenatedTsFilePath)
97 const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe) 97 const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe)
98 98
99 const { resolution, isPortraitMode } = await getVideoFileResolution(concatenatedTsFilePath, probe) 99 const { resolution, isPortraitMode } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe)
100 100
101 const { resolutionPlaylistPath: outputPath } = await generateHlsPlaylistResolutionFromTS({ 101 const { resolutionPlaylistPath: outputPath } = await generateHlsPlaylistResolutionFromTS({
102 video: videoWithFiles, 102 video: videoWithFiles,
@@ -107,7 +107,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
107 }) 107 })
108 108
109 if (!durationDone) { 109 if (!durationDone) {
110 videoWithFiles.duration = await getDurationFromVideoFile(outputPath) 110 videoWithFiles.duration = await getVideoStreamDuration(outputPath)
111 await videoWithFiles.save() 111 await videoWithFiles.save()
112 112
113 durationDone = true 113 durationDone = true
diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts
index 5540b791d..512979734 100644
--- a/server/lib/job-queue/handlers/video-transcoding.ts
+++ b/server/lib/job-queue/handlers/video-transcoding.ts
@@ -1,5 +1,5 @@
1import { Job } from 'bull' 1import { Job } from 'bull'
2import { TranscodeOptionsType } from '@server/helpers/ffmpeg-utils' 2import { TranscodeVODOptionsType } from '@server/helpers/ffmpeg'
3import { addTranscodingJob, getTranscodingJobPriority } from '@server/lib/video' 3import { addTranscodingJob, getTranscodingJobPriority } from '@server/lib/video'
4import { VideoPathManager } from '@server/lib/video-path-manager' 4import { VideoPathManager } from '@server/lib/video-path-manager'
5import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state' 5import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state'
@@ -16,7 +16,7 @@ import {
16 VideoTranscodingPayload 16 VideoTranscodingPayload
17} from '@shared/models' 17} from '@shared/models'
18import { retryTransactionWrapper } from '../../../helpers/database-utils' 18import { retryTransactionWrapper } from '../../../helpers/database-utils'
19import { computeLowerResolutionsToTranscode } from '../../../helpers/ffprobe-utils' 19import { computeLowerResolutionsToTranscode } from '../../../helpers/ffmpeg'
20import { logger, loggerTagsFactory } from '../../../helpers/logger' 20import { logger, loggerTagsFactory } from '../../../helpers/logger'
21import { CONFIG } from '../../../initializers/config' 21import { CONFIG } from '../../../initializers/config'
22import { VideoModel } from '../../../models/video/video' 22import { VideoModel } from '../../../models/video/video'
@@ -25,7 +25,7 @@ import {
25 mergeAudioVideofile, 25 mergeAudioVideofile,
26 optimizeOriginalVideofile, 26 optimizeOriginalVideofile,
27 transcodeNewWebTorrentResolution 27 transcodeNewWebTorrentResolution
28} from '../../transcoding/video-transcoding' 28} from '../../transcoding/transcoding'
29 29
30type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<void> 30type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<void>
31 31
@@ -174,10 +174,10 @@ async function onHlsPlaylistGeneration (video: MVideoFullLight, user: MUser, pay
174async function onVideoFirstWebTorrentTranscoding ( 174async function onVideoFirstWebTorrentTranscoding (
175 videoArg: MVideoWithFile, 175 videoArg: MVideoWithFile,
176 payload: OptimizeTranscodingPayload | MergeAudioTranscodingPayload, 176 payload: OptimizeTranscodingPayload | MergeAudioTranscodingPayload,
177 transcodeType: TranscodeOptionsType, 177 transcodeType: TranscodeVODOptionsType,
178 user: MUserId 178 user: MUserId
179) { 179) {
180 const { resolution, isPortraitMode, audioStream } = await videoArg.getMaxQualityFileInfo() 180 const { resolution, isPortraitMode, audioStream } = await videoArg.probeMaxQualityFile()
181 181
182 // Maybe the video changed in database, refresh it 182 // Maybe the video changed in database, refresh it
183 const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoArg.uuid) 183 const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoArg.uuid)
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts
index 22bd1f5d2..e10a3bab5 100644
--- a/server/lib/job-queue/job-queue.ts
+++ b/server/lib/job-queue/job-queue.ts
@@ -14,6 +14,7 @@ import {
14 JobType, 14 JobType,
15 MoveObjectStoragePayload, 15 MoveObjectStoragePayload,
16 RefreshPayload, 16 RefreshPayload,
17 VideoEditionPayload,
17 VideoFileImportPayload, 18 VideoFileImportPayload,
18 VideoImportPayload, 19 VideoImportPayload,
19 VideoLiveEndingPayload, 20 VideoLiveEndingPayload,
@@ -31,6 +32,7 @@ import { refreshAPObject } from './handlers/activitypub-refresher'
31import { processActorKeys } from './handlers/actor-keys' 32import { processActorKeys } from './handlers/actor-keys'
32import { processEmail } from './handlers/email' 33import { processEmail } from './handlers/email'
33import { processMoveToObjectStorage } from './handlers/move-to-object-storage' 34import { processMoveToObjectStorage } from './handlers/move-to-object-storage'
35import { processVideoEdition } from './handlers/video-edition'
34import { processVideoFileImport } from './handlers/video-file-import' 36import { processVideoFileImport } from './handlers/video-file-import'
35import { processVideoImport } from './handlers/video-import' 37import { processVideoImport } from './handlers/video-import'
36import { processVideoLiveEnding } from './handlers/video-live-ending' 38import { processVideoLiveEnding } from './handlers/video-live-ending'
@@ -53,6 +55,7 @@ type CreateJobArgument =
53 { type: 'actor-keys', payload: ActorKeysPayload } | 55 { type: 'actor-keys', payload: ActorKeysPayload } |
54 { type: 'video-redundancy', payload: VideoRedundancyPayload } | 56 { type: 'video-redundancy', payload: VideoRedundancyPayload } |
55 { type: 'delete-resumable-upload-meta-file', payload: DeleteResumableUploadMetaFilePayload } | 57 { type: 'delete-resumable-upload-meta-file', payload: DeleteResumableUploadMetaFilePayload } |
58 { type: 'video-edition', payload: VideoEditionPayload } |
56 { type: 'move-to-object-storage', payload: MoveObjectStoragePayload } 59 { type: 'move-to-object-storage', payload: MoveObjectStoragePayload }
57 60
58export type CreateJobOptions = { 61export type CreateJobOptions = {
@@ -75,7 +78,8 @@ const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
75 'video-live-ending': processVideoLiveEnding, 78 'video-live-ending': processVideoLiveEnding,
76 'actor-keys': processActorKeys, 79 'actor-keys': processActorKeys,
77 'video-redundancy': processVideoRedundancy, 80 'video-redundancy': processVideoRedundancy,
78 'move-to-object-storage': processMoveToObjectStorage 81 'move-to-object-storage': processMoveToObjectStorage,
82 'video-edition': processVideoEdition
79} 83}
80 84
81const jobTypes: JobType[] = [ 85const jobTypes: JobType[] = [
@@ -93,7 +97,8 @@ const jobTypes: JobType[] = [
93 'video-redundancy', 97 'video-redundancy',
94 'actor-keys', 98 'actor-keys',
95 'video-live-ending', 99 'video-live-ending',
96 'move-to-object-storage' 100 'move-to-object-storage',
101 'video-edition'
97] 102]
98 103
99class JobQueue { 104class JobQueue {
diff --git a/server/lib/live/live-manager.ts b/server/lib/live/live-manager.ts
index 33e49acc1..21c34a9a4 100644
--- a/server/lib/live/live-manager.ts
+++ b/server/lib/live/live-manager.ts
@@ -5,10 +5,10 @@ import { createServer as createServerTLS, Server as ServerTLS } from 'tls'
5import { 5import {
6 computeLowerResolutionsToTranscode, 6 computeLowerResolutionsToTranscode,
7 ffprobePromise, 7 ffprobePromise,
8 getVideoFileBitrate, 8 getVideoStreamBitrate,
9 getVideoFileFPS, 9 getVideoStreamFPS,
10 getVideoFileResolution 10 getVideoStreamDimensionsInfo
11} from '@server/helpers/ffprobe-utils' 11} from '@server/helpers/ffmpeg'
12import { logger, loggerTagsFactory } from '@server/helpers/logger' 12import { logger, loggerTagsFactory } from '@server/helpers/logger'
13import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config' 13import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
14import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE } from '@server/initializers/constants' 14import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE } from '@server/initializers/constants'
@@ -226,9 +226,9 @@ class LiveManager {
226 const probe = await ffprobePromise(inputUrl) 226 const probe = await ffprobePromise(inputUrl)
227 227
228 const [ { resolution, ratio }, fps, bitrate ] = await Promise.all([ 228 const [ { resolution, ratio }, fps, bitrate ] = await Promise.all([
229 getVideoFileResolution(inputUrl, probe), 229 getVideoStreamDimensionsInfo(inputUrl, probe),
230 getVideoFileFPS(inputUrl, probe), 230 getVideoStreamFPS(inputUrl, probe),
231 getVideoFileBitrate(inputUrl, probe) 231 getVideoStreamBitrate(inputUrl, probe)
232 ]) 232 ])
233 233
234 logger.info( 234 logger.info(
diff --git a/server/lib/live/shared/muxing-session.ts b/server/lib/live/shared/muxing-session.ts
index 22a47942a..f5f473039 100644
--- a/server/lib/live/shared/muxing-session.ts
+++ b/server/lib/live/shared/muxing-session.ts
@@ -5,14 +5,14 @@ import { FfmpegCommand } from 'fluent-ffmpeg'
5import { appendFile, ensureDir, readFile, stat } from 'fs-extra' 5import { appendFile, ensureDir, readFile, stat } from 'fs-extra'
6import { basename, join } from 'path' 6import { basename, join } from 'path'
7import { EventEmitter } from 'stream' 7import { EventEmitter } from 'stream'
8import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg-utils' 8import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg'
9import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger' 9import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger'
10import { CONFIG } from '@server/initializers/config' 10import { CONFIG } from '@server/initializers/config'
11import { MEMOIZE_TTL, VIDEO_LIVE } from '@server/initializers/constants' 11import { MEMOIZE_TTL, VIDEO_LIVE } from '@server/initializers/constants'
12import { VideoFileModel } from '@server/models/video/video-file' 12import { VideoFileModel } from '@server/models/video/video-file'
13import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models' 13import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models'
14import { getLiveDirectory } from '../../paths' 14import { getLiveDirectory } from '../../paths'
15import { VideoTranscodingProfilesManager } from '../../transcoding/video-transcoding-profiles' 15import { VideoTranscodingProfilesManager } from '../../transcoding/default-transcoding-profiles'
16import { isAbleToUploadVideo } from '../../user' 16import { isAbleToUploadVideo } from '../../user'
17import { LiveQuotaStore } from '../live-quota-store' 17import { LiveQuotaStore } from '../live-quota-store'
18import { LiveSegmentShaStore } from '../live-segment-sha-store' 18import { LiveSegmentShaStore } from '../live-segment-sha-store'
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/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts
index 78e4a28ad..897271c0b 100644
--- a/server/lib/plugins/plugin-helpers-builder.ts
+++ b/server/lib/plugins/plugin-helpers-builder.ts
@@ -1,6 +1,6 @@
1import express from 'express' 1import express from 'express'
2import { join } from 'path' 2import { join } from 'path'
3import { ffprobePromise } from '@server/helpers/ffprobe-utils' 3import { ffprobePromise } from '@server/helpers/ffmpeg/ffprobe-utils'
4import { buildLogger } from '@server/helpers/logger' 4import { buildLogger } from '@server/helpers/logger'
5import { CONFIG } from '@server/initializers/config' 5import { CONFIG } from '@server/initializers/config'
6import { WEBSERVER } from '@server/initializers/constants' 6import { WEBSERVER } from '@server/initializers/constants'
diff --git a/server/lib/plugins/register-helpers.ts b/server/lib/plugins/register-helpers.ts
index d1756040a..f4d405676 100644
--- a/server/lib/plugins/register-helpers.ts
+++ b/server/lib/plugins/register-helpers.ts
@@ -21,7 +21,7 @@ import {
21 VideoPlaylistPrivacy, 21 VideoPlaylistPrivacy,
22 VideoPrivacy 22 VideoPrivacy
23} from '@shared/models' 23} from '@shared/models'
24import { VideoTranscodingProfilesManager } from '../transcoding/video-transcoding-profiles' 24import { VideoTranscodingProfilesManager } from '../transcoding/default-transcoding-profiles'
25import { buildPluginHelpers } from './plugin-helpers-builder' 25import { buildPluginHelpers } from './plugin-helpers-builder'
26 26
27export class RegisterHelpers { 27export class RegisterHelpers {
diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts
index d97f21eb7..38512f384 100644
--- a/server/lib/server-config-manager.ts
+++ b/server/lib/server-config-manager.ts
@@ -8,7 +8,7 @@ import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuth
8import { Hooks } from './plugins/hooks' 8import { Hooks } from './plugins/hooks'
9import { PluginManager } from './plugins/plugin-manager' 9import { PluginManager } from './plugins/plugin-manager'
10import { getThemeOrDefault } from './plugins/theme-utils' 10import { getThemeOrDefault } from './plugins/theme-utils'
11import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles' 11import { VideoTranscodingProfilesManager } from './transcoding/default-transcoding-profiles'
12 12
13/** 13/**
14 * 14 *
@@ -151,6 +151,9 @@ class ServerConfigManager {
151 port: CONFIG.LIVE.RTMP.PORT 151 port: CONFIG.LIVE.RTMP.PORT
152 } 152 }
153 }, 153 },
154 videoEditor: {
155 enabled: CONFIG.VIDEO_EDITOR.ENABLED
156 },
154 import: { 157 import: {
155 videos: { 158 videos: {
156 http: { 159 http: {
diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts
index 36270e5c1..aa2d7a813 100644
--- a/server/lib/thumbnail.ts
+++ b/server/lib/thumbnail.ts
@@ -1,7 +1,6 @@
1import { join } from 'path' 1import { join } from 'path'
2import { ThumbnailType } from '../../shared/models/videos/thumbnail.type' 2import { ThumbnailType } from '@shared/models'
3import { generateImageFromVideoFile } from '../helpers/ffmpeg-utils' 3import { generateImageFilename, generateImageFromVideoFile, processImage } from '../helpers/image-utils'
4import { generateImageFilename, processImage } from '../helpers/image-utils'
5import { downloadImage } from '../helpers/requests' 4import { downloadImage } from '../helpers/requests'
6import { CONFIG } from '../initializers/config' 5import { CONFIG } from '../initializers/config'
7import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants' 6import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants'
diff --git a/server/lib/transcoding/video-transcoding-profiles.ts b/server/lib/transcoding/default-transcoding-profiles.ts
index dcc8d4c5c..ba98a11ca 100644
--- a/server/lib/transcoding/video-transcoding-profiles.ts
+++ b/server/lib/transcoding/default-transcoding-profiles.ts
@@ -2,8 +2,14 @@
2import { logger } from '@server/helpers/logger' 2import { logger } from '@server/helpers/logger'
3import { getAverageBitrate, getMinLimitBitrate } from '@shared/core-utils' 3import { getAverageBitrate, getMinLimitBitrate } from '@shared/core-utils'
4import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '../../../shared/models/videos' 4import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '../../../shared/models/videos'
5import { buildStreamSuffix, resetSupportedEncoders } from '../../helpers/ffmpeg-utils' 5import {
6import { canDoQuickAudioTranscode, ffprobePromise, getAudioStream, getMaxAudioBitrate } from '../../helpers/ffprobe-utils' 6 buildStreamSuffix,
7 canDoQuickAudioTranscode,
8 ffprobePromise,
9 getAudioStream,
10 getMaxAudioBitrate,
11 resetSupportedEncoders
12} from '../../helpers/ffmpeg'
7 13
8/** 14/**
9 * 15 *
@@ -15,8 +21,14 @@ import { canDoQuickAudioTranscode, ffprobePromise, getAudioStream, getMaxAudioBi
15 * * https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate 21 * * https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
16 */ 22 */
17 23
24// ---------------------------------------------------------------------------
25// Default builders
26// ---------------------------------------------------------------------------
27
18const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => { 28const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
19 const { fps, inputRatio, inputBitrate, resolution } = options 29 const { fps, inputRatio, inputBitrate, resolution } = options
30
31 // TODO: remove in 4.2, fps is not optional anymore
20 if (!fps) return { outputOptions: [ ] } 32 if (!fps) return { outputOptions: [ ] }
21 33
22 const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution }) 34 const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
@@ -45,10 +57,10 @@ const defaultX264LiveOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOp
45 } 57 }
46} 58}
47 59
48const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum }) => { 60const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum, canCopyAudio }) => {
49 const probe = await ffprobePromise(input) 61 const probe = await ffprobePromise(input)
50 62
51 if (await canDoQuickAudioTranscode(input, probe)) { 63 if (canCopyAudio && await canDoQuickAudioTranscode(input, probe)) {
52 logger.debug('Copy audio stream %s by AAC encoder.', input) 64 logger.debug('Copy audio stream %s by AAC encoder.', input)
53 return { copy: true, outputOptions: [ ] } 65 return { copy: true, outputOptions: [ ] }
54 } 66 }
@@ -75,7 +87,10 @@ const defaultLibFDKAACVODOptionsBuilder: EncoderOptionsBuilder = ({ streamNum })
75 return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] } 87 return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] }
76} 88}
77 89
78// Used to get and update available encoders 90// ---------------------------------------------------------------------------
91// Profile manager to get and change default profiles
92// ---------------------------------------------------------------------------
93
79class VideoTranscodingProfilesManager { 94class VideoTranscodingProfilesManager {
80 private static instance: VideoTranscodingProfilesManager 95 private static instance: VideoTranscodingProfilesManager
81 96
diff --git a/server/lib/transcoding/video-transcoding.ts b/server/lib/transcoding/transcoding.ts
index 9942a067b..d55364e25 100644
--- a/server/lib/transcoding/video-transcoding.ts
+++ b/server/lib/transcoding/transcoding.ts
@@ -6,8 +6,15 @@ import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
6import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models' 6import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
7import { VideoResolution, VideoStorage } from '../../../shared/models/videos' 7import { VideoResolution, VideoStorage } from '../../../shared/models/videos'
8import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' 8import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
9import { transcode, TranscodeOptions, TranscodeOptionsType } from '../../helpers/ffmpeg-utils' 9import {
10import { canDoQuickTranscode, getDurationFromVideoFile, getMetadataFromFile, getVideoFileFPS } from '../../helpers/ffprobe-utils' 10 canDoQuickTranscode,
11 getVideoStreamDuration,
12 buildFileMetadata,
13 getVideoStreamFPS,
14 transcodeVOD,
15 TranscodeVODOptions,
16 TranscodeVODOptionsType
17} from '../../helpers/ffmpeg'
11import { CONFIG } from '../../initializers/config' 18import { CONFIG } from '../../initializers/config'
12import { P2P_MEDIA_LOADER_PEER_VERSION } from '../../initializers/constants' 19import { P2P_MEDIA_LOADER_PEER_VERSION } from '../../initializers/constants'
13import { VideoFileModel } from '../../models/video/video-file' 20import { VideoFileModel } from '../../models/video/video-file'
@@ -21,7 +28,7 @@ import {
21 getHlsResolutionPlaylistFilename 28 getHlsResolutionPlaylistFilename
22} from '../paths' 29} from '../paths'
23import { VideoPathManager } from '../video-path-manager' 30import { VideoPathManager } from '../video-path-manager'
24import { VideoTranscodingProfilesManager } from './video-transcoding-profiles' 31import { VideoTranscodingProfilesManager } from './default-transcoding-profiles'
25 32
26/** 33/**
27 * 34 *
@@ -38,13 +45,13 @@ function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVid
38 return VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile.withVideoOrPlaylist(video), async videoInputPath => { 45 return VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile.withVideoOrPlaylist(video), async videoInputPath => {
39 const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) 46 const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
40 47
41 const transcodeType: TranscodeOptionsType = await canDoQuickTranscode(videoInputPath) 48 const transcodeType: TranscodeVODOptionsType = await canDoQuickTranscode(videoInputPath)
42 ? 'quick-transcode' 49 ? 'quick-transcode'
43 : 'video' 50 : 'video'
44 51
45 const resolution = toEven(inputVideoFile.resolution) 52 const resolution = toEven(inputVideoFile.resolution)
46 53
47 const transcodeOptions: TranscodeOptions = { 54 const transcodeOptions: TranscodeVODOptions = {
48 type: transcodeType, 55 type: transcodeType,
49 56
50 inputPath: videoInputPath, 57 inputPath: videoInputPath,
@@ -59,7 +66,7 @@ function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVid
59 } 66 }
60 67
61 // Could be very long! 68 // Could be very long!
62 await transcode(transcodeOptions) 69 await transcodeVOD(transcodeOptions)
63 70
64 // Important to do this before getVideoFilename() to take in account the new filename 71 // Important to do this before getVideoFilename() to take in account the new filename
65 inputVideoFile.extname = newExtname 72 inputVideoFile.extname = newExtname
@@ -121,7 +128,7 @@ function transcodeNewWebTorrentResolution (video: MVideoFullLight, resolution: V
121 job 128 job
122 } 129 }
123 130
124 await transcode(transcodeOptions) 131 await transcodeVOD(transcodeOptions)
125 132
126 return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath) 133 return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath)
127 }) 134 })
@@ -158,7 +165,7 @@ function mergeAudioVideofile (video: MVideoFullLight, resolution: VideoResolutio
158 } 165 }
159 166
160 try { 167 try {
161 await transcode(transcodeOptions) 168 await transcodeVOD(transcodeOptions)
162 169
163 await remove(audioInputPath) 170 await remove(audioInputPath)
164 await remove(tmpPreviewPath) 171 await remove(tmpPreviewPath)
@@ -175,7 +182,7 @@ function mergeAudioVideofile (video: MVideoFullLight, resolution: VideoResolutio
175 const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, inputVideoFile) 182 const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, inputVideoFile)
176 // ffmpeg generated a new video file, so update the video duration 183 // ffmpeg generated a new video file, so update the video duration
177 // See https://trac.ffmpeg.org/ticket/5456 184 // See https://trac.ffmpeg.org/ticket/5456
178 video.duration = await getDurationFromVideoFile(videoTranscodedPath) 185 video.duration = await getVideoStreamDuration(videoTranscodedPath)
179 await video.save() 186 await video.save()
180 187
181 return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath) 188 return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
@@ -239,8 +246,8 @@ async function onWebTorrentVideoFileTranscoding (
239 outputPath: string 246 outputPath: string
240) { 247) {
241 const stats = await stat(transcodingPath) 248 const stats = await stat(transcodingPath)
242 const fps = await getVideoFileFPS(transcodingPath) 249 const fps = await getVideoStreamFPS(transcodingPath)
243 const metadata = await getMetadataFromFile(transcodingPath) 250 const metadata = await buildFileMetadata(transcodingPath)
244 251
245 await move(transcodingPath, outputPath, { overwrite: true }) 252 await move(transcodingPath, outputPath, { overwrite: true })
246 253
@@ -299,7 +306,7 @@ async function generateHlsPlaylistCommon (options: {
299 job 306 job
300 } 307 }
301 308
302 await transcode(transcodeOptions) 309 await transcodeVOD(transcodeOptions)
303 310
304 // Create or update the playlist 311 // Create or update the playlist
305 const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(video) 312 const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(video)
@@ -344,8 +351,8 @@ async function generateHlsPlaylistCommon (options: {
344 const stats = await stat(videoFilePath) 351 const stats = await stat(videoFilePath)
345 352
346 newVideoFile.size = stats.size 353 newVideoFile.size = stats.size
347 newVideoFile.fps = await getVideoFileFPS(videoFilePath) 354 newVideoFile.fps = await getVideoStreamFPS(videoFilePath)
348 newVideoFile.metadata = await getMetadataFromFile(videoFilePath) 355 newVideoFile.metadata = await buildFileMetadata(videoFilePath)
349 356
350 await createTorrentAndSetInfoHash(playlist, newVideoFile) 357 await createTorrentAndSetInfoHash(playlist, newVideoFile)
351 358
diff --git a/server/lib/user.ts b/server/lib/user.ts
index 0d292ac90..ea755f4be 100644
--- a/server/lib/user.ts
+++ b/server/lib/user.ts
@@ -1,9 +1,11 @@
1import { Transaction } from 'sequelize/types' 1import { Transaction } from 'sequelize/types'
2import { logger } from '@server/helpers/logger'
3import { CONFIG } from '@server/initializers/config'
2import { UserModel } from '@server/models/user/user' 4import { UserModel } from '@server/models/user/user'
3import { MActorDefault } from '@server/types/models/actor' 5import { MActorDefault } from '@server/types/models/actor'
4import { buildUUID } from '@shared/extra-utils' 6import { buildUUID } from '@shared/extra-utils'
5import { ActivityPubActorType } from '../../shared/models/activitypub' 7import { ActivityPubActorType } from '../../shared/models/activitypub'
6import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users' 8import { UserAdminFlag, UserNotificationSetting, UserNotificationSettingValue, UserRole } from '../../shared/models/users'
7import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants' 9import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants'
8import { sequelizeTypescript } from '../initializers/database' 10import { sequelizeTypescript } from '../initializers/database'
9import { AccountModel } from '../models/account/account' 11import { AccountModel } from '../models/account/account'
@@ -22,6 +24,53 @@ import { createWatchLaterPlaylist } from './video-playlist'
22 24
23type ChannelNames = { name: string, displayName: string } 25type ChannelNames = { name: string, displayName: string }
24 26
27function buildUser (options: {
28 username: string
29 password: string
30 email: string
31
32 role?: UserRole // Default to UserRole.User
33 adminFlags?: UserAdminFlag // Default to UserAdminFlag.NONE
34
35 emailVerified: boolean | null
36
37 videoQuota?: number // Default to CONFIG.USER.VIDEO_QUOTA
38 videoQuotaDaily?: number // Default to CONFIG.USER.VIDEO_QUOTA_DAILY
39
40 pluginAuth?: string
41}): MUser {
42 const {
43 username,
44 password,
45 email,
46 role = UserRole.USER,
47 emailVerified,
48 videoQuota = CONFIG.USER.VIDEO_QUOTA,
49 videoQuotaDaily = CONFIG.USER.VIDEO_QUOTA_DAILY,
50 adminFlags = UserAdminFlag.NONE,
51 pluginAuth
52 } = options
53
54 return new UserModel({
55 username,
56 password,
57 email,
58
59 nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
60 p2pEnabled: CONFIG.DEFAULTS.P2P.WEBAPP.ENABLED,
61 autoPlayVideo: true,
62
63 role,
64 emailVerified,
65 adminFlags,
66
67 videoQuota: videoQuota,
68 videoQuotaDaily: videoQuotaDaily,
69
70 pluginAuth
71 })
72}
73
25async function createUserAccountAndChannelAndPlaylist (parameters: { 74async function createUserAccountAndChannelAndPlaylist (parameters: {
26 userToCreate: MUser 75 userToCreate: MUser
27 userDisplayName?: string 76 userDisplayName?: string
@@ -117,7 +166,7 @@ async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) {
117 const email = isPendingEmail ? user.pendingEmail : user.email 166 const email = isPendingEmail ? user.pendingEmail : user.email
118 const username = user.username 167 const username = user.username
119 168
120 await Emailer.Instance.addVerifyEmailJob(username, email, url) 169 Emailer.Instance.addVerifyEmailJob(username, email, url)
121} 170}
122 171
123async function getOriginalVideoFileTotalFromUser (user: MUserId) { 172async function getOriginalVideoFileTotalFromUser (user: MUserId) {
@@ -159,6 +208,11 @@ async function isAbleToUploadVideo (userId: number, newVideoSize: number) {
159 const uploadedTotal = newVideoSize + totalBytes 208 const uploadedTotal = newVideoSize + totalBytes
160 const uploadedDaily = newVideoSize + totalBytesDaily 209 const uploadedDaily = newVideoSize + totalBytesDaily
161 210
211 logger.debug(
212 'Check user %d quota to upload another video.', userId,
213 { totalBytes, totalBytesDaily, videoQuota: user.videoQuota, videoQuotaDaily: user.videoQuotaDaily, newVideoSize }
214 )
215
162 if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota 216 if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota
163 if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily 217 if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily
164 218
@@ -174,7 +228,8 @@ export {
174 createUserAccountAndChannelAndPlaylist, 228 createUserAccountAndChannelAndPlaylist,
175 createLocalAccountWithoutKeys, 229 createLocalAccountWithoutKeys,
176 sendVerifyUserEmail, 230 sendVerifyUserEmail,
177 isAbleToUploadVideo 231 isAbleToUploadVideo,
232 buildUser
178} 233}
179 234
180// --------------------------------------------------------------------------- 235// ---------------------------------------------------------------------------
diff --git a/server/lib/video-editor.ts b/server/lib/video-editor.ts
new file mode 100644
index 000000000..99b0bd949
--- /dev/null
+++ b/server/lib/video-editor.ts
@@ -0,0 +1,32 @@
1import { MVideoFullLight } from "@server/types/models"
2import { getVideoStreamDuration } from "@shared/extra-utils"
3import { VideoEditorTask } from "@shared/models"
4
5function buildTaskFileFieldname (indice: number, fieldName = 'file') {
6 return `tasks[${indice}][options][${fieldName}]`
7}
8
9function getTaskFile (files: Express.Multer.File[], indice: number, fieldName = 'file') {
10 return files.find(f => f.fieldname === buildTaskFileFieldname(indice, fieldName))
11}
12
13async function approximateIntroOutroAdditionalSize (video: MVideoFullLight, tasks: VideoEditorTask[], fileFinder: (i: number) => string) {
14 let additionalDuration = 0
15
16 for (let i = 0; i < tasks.length; i++) {
17 const task = tasks[i]
18
19 if (task.name !== 'add-intro' && task.name !== 'add-outro') continue
20
21 const filePath = fileFinder(i)
22 additionalDuration += await getVideoStreamDuration(filePath)
23 }
24
25 return (video.getMaxQualityFile().size / video.duration) * additionalDuration
26}
27
28export {
29 approximateIntroOutroAdditionalSize,
30 buildTaskFileFieldname,
31 getTaskFile
32}
diff --git a/server/lib/video.ts b/server/lib/video.ts
index 2690f953d..ec4256c1a 100644
--- a/server/lib/video.ts
+++ b/server/lib/video.ts
@@ -81,7 +81,7 @@ async function setVideoTags (options: {
81 video.Tags = tagInstances 81 video.Tags = tagInstances
82} 82}
83 83
84async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoFile, user: MUserId) { 84async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoFile, user: MUserId, isNewVideo = true) {
85 let dataInput: VideoTranscodingPayload 85 let dataInput: VideoTranscodingPayload
86 86
87 if (videoFile.isAudio()) { 87 if (videoFile.isAudio()) {
@@ -90,13 +90,13 @@ async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoF
90 resolution: DEFAULT_AUDIO_RESOLUTION, 90 resolution: DEFAULT_AUDIO_RESOLUTION,
91 videoUUID: video.uuid, 91 videoUUID: video.uuid,
92 createHLSIfNeeded: true, 92 createHLSIfNeeded: true,
93 isNewVideo: true 93 isNewVideo
94 } 94 }
95 } else { 95 } else {
96 dataInput = { 96 dataInput = {
97 type: 'optimize-to-webtorrent', 97 type: 'optimize-to-webtorrent',
98 videoUUID: video.uuid, 98 videoUUID: video.uuid,
99 isNewVideo: true 99 isNewVideo
100 } 100 }
101 } 101 }
102 102
diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts
index 8b14feb3c..e87b2e39d 100644
--- a/server/middlewares/validators/config.ts
+++ b/server/middlewares/validators/config.ts
@@ -57,6 +57,8 @@ const customConfigUpdateValidator = [
57 body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'), 57 body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'),
58 body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'), 58 body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'),
59 59
60 body('videoEditor.enabled').isBoolean().withMessage('Should have a valid video editor enabled boolean'),
61
60 body('import.videos.concurrency').isInt({ min: 0 }).withMessage('Should have a valid import concurrency number'), 62 body('import.videos.concurrency').isInt({ min: 0 }).withMessage('Should have a valid import concurrency number'),
61 body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'), 63 body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'),
62 body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'), 64 body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'),
@@ -104,6 +106,7 @@ const customConfigUpdateValidator = [
104 if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return 106 if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return
105 if (!checkInvalidTranscodingConfig(req.body, res)) return 107 if (!checkInvalidTranscodingConfig(req.body, res)) return
106 if (!checkInvalidLiveConfig(req.body, res)) return 108 if (!checkInvalidLiveConfig(req.body, res)) return
109 if (!checkInvalidVideoEditorConfig(req.body, res)) return
107 110
108 return next() 111 return next()
109 } 112 }
@@ -159,3 +162,14 @@ function checkInvalidLiveConfig (customConfig: CustomConfig, res: express.Respon
159 162
160 return true 163 return true
161} 164}
165
166function checkInvalidVideoEditorConfig (customConfig: CustomConfig, res: express.Response) {
167 if (customConfig.videoEditor.enabled === false) return true
168
169 if (customConfig.videoEditor.enabled === true && customConfig.transcoding.enabled === false) {
170 res.fail({ message: 'You cannot enable video editor if transcoding is not enabled' })
171 return false
172 }
173
174 return true
175}
diff --git a/server/middlewares/validators/shared/utils.ts b/server/middlewares/validators/shared/utils.ts
index 104eace91..410de4d80 100644
--- a/server/middlewares/validators/shared/utils.ts
+++ b/server/middlewares/validators/shared/utils.ts
@@ -8,6 +8,7 @@ function areValidationErrors (req: express.Request, res: express.Response) {
8 8
9 if (!errors.isEmpty()) { 9 if (!errors.isEmpty()) {
10 logger.warn('Incorrect request parameters', { path: req.originalUrl, err: errors.mapped() }) 10 logger.warn('Incorrect request parameters', { path: req.originalUrl, err: errors.mapped() })
11
11 res.fail({ 12 res.fail({
12 message: 'Incorrect request parameters: ' + Object.keys(errors.mapped()).join(', '), 13 message: 'Incorrect request parameters: ' + Object.keys(errors.mapped()).join(', '),
13 instance: req.originalUrl, 14 instance: req.originalUrl,
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts
index fc978b63a..8807435f6 100644
--- a/server/middlewares/validators/shared/videos.ts
+++ b/server/middlewares/validators/shared/videos.ts
@@ -1,5 +1,6 @@
1import { Request, Response } from 'express' 1import { Request, Response } from 'express'
2import { loadVideo, VideoLoadType } from '@server/lib/model-loaders' 2import { loadVideo, VideoLoadType } from '@server/lib/model-loaders'
3import { isAbleToUploadVideo } from '@server/lib/user'
3import { authenticatePromiseIfNeeded } from '@server/middlewares/auth' 4import { authenticatePromiseIfNeeded } from '@server/middlewares/auth'
4import { VideoModel } from '@server/models/video/video' 5import { VideoModel } from '@server/models/video/video'
5import { VideoChannelModel } from '@server/models/video/video-channel' 6import { VideoChannelModel } from '@server/models/video/video-channel'
@@ -7,6 +8,7 @@ import { VideoFileModel } from '@server/models/video/video-file'
7import { 8import {
8 MUser, 9 MUser,
9 MUserAccountId, 10 MUserAccountId,
11 MUserId,
10 MVideo, 12 MVideo,
11 MVideoAccountLight, 13 MVideoAccountLight,
12 MVideoFormattableDetails, 14 MVideoFormattableDetails,
@@ -16,7 +18,7 @@ import {
16 MVideoThumbnail, 18 MVideoThumbnail,
17 MVideoWithRights 19 MVideoWithRights
18} from '@server/types/models' 20} from '@server/types/models'
19import { HttpStatusCode, UserRight } from '@shared/models' 21import { HttpStatusCode, ServerErrorCode, UserRight } from '@shared/models'
20 22
21async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { 23async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
22 const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined 24 const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
@@ -108,6 +110,11 @@ async function checkCanSeePrivateVideo (req: Request, res: Response, video: MVid
108 110
109 // Only the owner or a user that have blocklist rights can see the video 111 // Only the owner or a user that have blocklist rights can see the video
110 if (!user || !user.canGetVideo(video)) { 112 if (!user || !user.canGetVideo(video)) {
113 res.fail({
114 status: HttpStatusCode.FORBIDDEN_403,
115 message: 'Cannot fetch information of private/internal/blocklisted video'
116 })
117
111 return false 118 return false
112 } 119 }
113 120
@@ -139,13 +146,28 @@ function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right:
139 return true 146 return true
140} 147}
141 148
149async function checkUserQuota (user: MUserId, videoFileSize: number, res: Response) {
150 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
151 res.fail({
152 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
153 message: 'The user video quota is exceeded with this video.',
154 type: ServerErrorCode.QUOTA_REACHED
155 })
156 return false
157 }
158
159 return true
160}
161
142// --------------------------------------------------------------------------- 162// ---------------------------------------------------------------------------
143 163
144export { 164export {
145 doesVideoChannelOfAccountExist, 165 doesVideoChannelOfAccountExist,
146 doesVideoExist, 166 doesVideoExist,
147 doesVideoFileOfVideoExist, 167 doesVideoFileOfVideoExist,
168
148 checkUserCanManageVideo, 169 checkUserCanManageVideo,
149 checkCanSeeVideoIfPrivate, 170 checkCanSeeVideoIfPrivate,
150 checkCanSeePrivateVideo 171 checkCanSeePrivateVideo,
172 checkUserQuota
151} 173}
diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts
index f365d8ee1..faa082510 100644
--- a/server/middlewares/validators/videos/index.ts
+++ b/server/middlewares/validators/videos/index.ts
@@ -2,6 +2,7 @@ export * from './video-blacklist'
2export * from './video-captions' 2export * from './video-captions'
3export * from './video-channels' 3export * from './video-channels'
4export * from './video-comments' 4export * from './video-comments'
5export * from './video-editor'
5export * from './video-files' 6export * from './video-files'
6export * from './video-imports' 7export * from './video-imports'
7export * from './video-live' 8export * from './video-live'
diff --git a/server/middlewares/validators/videos/video-captions.ts b/server/middlewares/validators/videos/video-captions.ts
index a399871e1..441c6b4be 100644
--- a/server/middlewares/validators/videos/video-captions.ts
+++ b/server/middlewares/validators/videos/video-captions.ts
@@ -1,6 +1,6 @@
1import express from 'express' 1import express from 'express'
2import { body, param } from 'express-validator' 2import { body, param } from 'express-validator'
3import { HttpStatusCode, UserRight } from '@shared/models' 3import { UserRight } from '@shared/models'
4import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions' 4import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions'
5import { cleanUpReqFiles } from '../../../helpers/express-utils' 5import { cleanUpReqFiles } from '../../../helpers/express-utils'
6import { logger } from '../../../helpers/logger' 6import { logger } from '../../../helpers/logger'
@@ -74,13 +74,7 @@ const listVideoCaptionsValidator = [
74 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return 74 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
75 75
76 const video = res.locals.onlyVideo 76 const video = res.locals.onlyVideo
77 77 if (!await checkCanSeeVideoIfPrivate(req, res, video)) return
78 if (!await checkCanSeeVideoIfPrivate(req, res, video)) {
79 return res.fail({
80 status: HttpStatusCode.FORBIDDEN_403,
81 message: 'Cannot list captions of private/internal/blocklisted video'
82 })
83 }
84 78
85 return next() 79 return next()
86 } 80 }
diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts
index 91e85711d..698afdbd1 100644
--- a/server/middlewares/validators/videos/video-comments.ts
+++ b/server/middlewares/validators/videos/video-comments.ts
@@ -54,12 +54,7 @@ const listVideoCommentThreadsValidator = [
54 if (areValidationErrors(req, res)) return 54 if (areValidationErrors(req, res)) return
55 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return 55 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
56 56
57 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) { 57 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) return
58 return res.fail({
59 status: HttpStatusCode.FORBIDDEN_403,
60 message: 'Cannot list comments of private/internal/blocklisted video'
61 })
62 }
63 58
64 return next() 59 return next()
65 } 60 }
@@ -78,12 +73,7 @@ const listVideoThreadCommentsValidator = [
78 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return 73 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
79 if (!await doesVideoCommentThreadExist(req.params.threadId, res.locals.onlyVideo, res)) return 74 if (!await doesVideoCommentThreadExist(req.params.threadId, res.locals.onlyVideo, res)) return
80 75
81 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) { 76 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) return
82 return res.fail({
83 status: HttpStatusCode.FORBIDDEN_403,
84 message: 'Cannot list threads of private/internal/blocklisted video'
85 })
86 }
87 77
88 return next() 78 return next()
89 } 79 }
@@ -101,12 +91,7 @@ const addVideoCommentThreadValidator = [
101 if (areValidationErrors(req, res)) return 91 if (areValidationErrors(req, res)) return
102 if (!await doesVideoExist(req.params.videoId, res)) return 92 if (!await doesVideoExist(req.params.videoId, res)) return
103 93
104 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.videoAll)) { 94 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.videoAll)) return
105 return res.fail({
106 status: HttpStatusCode.FORBIDDEN_403,
107 message: 'Cannot access to this ressource'
108 })
109 }
110 95
111 if (!isVideoCommentsEnabled(res.locals.videoAll, res)) return 96 if (!isVideoCommentsEnabled(res.locals.videoAll, res)) return
112 if (!await isVideoCommentAccepted(req, res, res.locals.videoAll, false)) return 97 if (!await isVideoCommentAccepted(req, res, res.locals.videoAll, false)) return
@@ -128,12 +113,7 @@ const addVideoCommentReplyValidator = [
128 if (areValidationErrors(req, res)) return 113 if (areValidationErrors(req, res)) return
129 if (!await doesVideoExist(req.params.videoId, res)) return 114 if (!await doesVideoExist(req.params.videoId, res)) return
130 115
131 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.videoAll)) { 116 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.videoAll)) return
132 return res.fail({
133 status: HttpStatusCode.FORBIDDEN_403,
134 message: 'Cannot access to this ressource'
135 })
136 }
137 117
138 if (!isVideoCommentsEnabled(res.locals.videoAll, res)) return 118 if (!isVideoCommentsEnabled(res.locals.videoAll, res)) return
139 if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return 119 if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return
diff --git a/server/middlewares/validators/videos/video-editor.ts b/server/middlewares/validators/videos/video-editor.ts
new file mode 100644
index 000000000..9be97be93
--- /dev/null
+++ b/server/middlewares/validators/videos/video-editor.ts
@@ -0,0 +1,112 @@
1import express from 'express'
2import { body, param } from 'express-validator'
3import { isIdOrUUIDValid } from '@server/helpers/custom-validators/misc'
4import {
5 isEditorCutTaskValid,
6 isEditorTaskAddIntroOutroValid,
7 isEditorTaskAddWatermarkValid,
8 isValidEditorTasksArray
9} from '@server/helpers/custom-validators/video-editor'
10import { cleanUpReqFiles } from '@server/helpers/express-utils'
11import { CONFIG } from '@server/initializers/config'
12import { approximateIntroOutroAdditionalSize, getTaskFile } from '@server/lib/video-editor'
13import { isAudioFile } from '@shared/extra-utils'
14import { HttpStatusCode, UserRight, VideoEditorCreateEdition, VideoEditorTask, VideoState } from '@shared/models'
15import { logger } from '../../../helpers/logger'
16import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared'
17
18const videosEditorAddEditionValidator = [
19 param('videoId').custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid'),
20
21 body('tasks').custom(isValidEditorTasksArray).withMessage('Should have a valid array of tasks'),
22
23 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
24 logger.debug('Checking videosEditorAddEditionValidator parameters.', { parameters: req.params, body: req.body, files: req.files })
25
26 if (CONFIG.VIDEO_EDITOR.ENABLED !== true) {
27 res.fail({
28 status: HttpStatusCode.BAD_REQUEST_400,
29 message: 'Video editor is disabled on this instance'
30 })
31
32 return cleanUpReqFiles(req)
33 }
34
35 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
36
37 const body: VideoEditorCreateEdition = req.body
38 const files = req.files as Express.Multer.File[]
39
40 for (let i = 0; i < body.tasks.length; i++) {
41 const task = body.tasks[i]
42
43 if (!checkTask(req, task, i)) {
44 res.fail({
45 status: HttpStatusCode.BAD_REQUEST_400,
46 message: `Task ${task.name} is invalid`
47 })
48
49 return cleanUpReqFiles(req)
50 }
51
52 if (task.name === 'add-intro' || task.name === 'add-outro') {
53 const filePath = getTaskFile(files, i).path
54
55 // Our concat filter needs a video stream
56 if (await isAudioFile(filePath)) {
57 res.fail({
58 status: HttpStatusCode.BAD_REQUEST_400,
59 message: `Task ${task.name} is invalid: file does not contain a video stream`
60 })
61
62 return cleanUpReqFiles(req)
63 }
64 }
65 }
66
67 if (!await doesVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req)
68
69 const video = res.locals.videoAll
70 if (video.state === VideoState.TO_TRANSCODE || video.state === VideoState.TO_EDIT) {
71 res.fail({
72 status: HttpStatusCode.CONFLICT_409,
73 message: 'Cannot edit video that is already waiting for transcoding/edition'
74 })
75
76 return cleanUpReqFiles(req)
77 }
78
79 const user = res.locals.oauth.token.User
80 if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
81
82 // Try to make an approximation of bytes added by the intro/outro
83 const additionalBytes = await approximateIntroOutroAdditionalSize(video, body.tasks, i => getTaskFile(files, i).path)
84 if (await checkUserQuota(user, additionalBytes, res) === false) return cleanUpReqFiles(req)
85
86 return next()
87 }
88]
89
90// ---------------------------------------------------------------------------
91
92export {
93 videosEditorAddEditionValidator
94}
95
96// ---------------------------------------------------------------------------
97
98const taskCheckers: {
99 [id in VideoEditorTask['name']]: (task: VideoEditorTask, indice?: number, files?: Express.Multer.File[]) => boolean
100} = {
101 'cut': isEditorCutTaskValid,
102 'add-intro': isEditorTaskAddIntroOutroValid,
103 'add-outro': isEditorTaskAddIntroOutroValid,
104 'add-watermark': isEditorTaskAddWatermarkValid
105}
106
107function checkTask (req: express.Request, task: VideoEditorTask, indice?: number) {
108 const checker = taskCheckers[task.name]
109 if (!checker) return false
110
111 return checker(task, indice, req.files as Express.Multer.File[])
112}
diff --git a/server/middlewares/validators/videos/video-ownership-changes.ts b/server/middlewares/validators/videos/video-ownership-changes.ts
index 95e4cebce..6dcdc05f5 100644
--- a/server/middlewares/validators/videos/video-ownership-changes.ts
+++ b/server/middlewares/validators/videos/video-ownership-changes.ts
@@ -3,20 +3,13 @@ import { param } from 'express-validator'
3import { isIdValid } from '@server/helpers/custom-validators/misc' 3import { isIdValid } from '@server/helpers/custom-validators/misc'
4import { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership' 4import { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership'
5import { logger } from '@server/helpers/logger' 5import { logger } from '@server/helpers/logger'
6import { isAbleToUploadVideo } from '@server/lib/user'
7import { AccountModel } from '@server/models/account/account' 6import { AccountModel } from '@server/models/account/account'
8import { MVideoWithAllFiles } from '@server/types/models' 7import { MVideoWithAllFiles } from '@server/types/models'
9import { 8import { HttpStatusCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@shared/models'
10 HttpStatusCode,
11 ServerErrorCode,
12 UserRight,
13 VideoChangeOwnershipAccept,
14 VideoChangeOwnershipStatus,
15 VideoState
16} from '@shared/models'
17import { 9import {
18 areValidationErrors, 10 areValidationErrors,
19 checkUserCanManageVideo, 11 checkUserCanManageVideo,
12 checkUserQuota,
20 doesChangeVideoOwnershipExist, 13 doesChangeVideoOwnershipExist,
21 doesVideoChannelOfAccountExist, 14 doesVideoChannelOfAccountExist,
22 doesVideoExist, 15 doesVideoExist,
@@ -113,15 +106,7 @@ async function checkCanAccept (video: MVideoWithAllFiles, res: express.Response)
113 106
114 const user = res.locals.oauth.token.User 107 const user = res.locals.oauth.token.User
115 108
116 if (!await isAbleToUploadVideo(user.id, video.getMaxQualityFile().size)) { 109 if (!await checkUserQuota(user, video.getMaxQualityFile().size, res)) return false
117 res.fail({
118 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
119 message: 'The user video quota is exceeded with this video.',
120 type: ServerErrorCode.QUOTA_REACHED
121 })
122
123 return false
124 }
125 110
126 return true 111 return true
127} 112}
diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts
index f5fee845e..241b9ed7b 100644
--- a/server/middlewares/validators/videos/video-playlists.ts
+++ b/server/middlewares/validators/videos/video-playlists.ts
@@ -27,7 +27,7 @@ import {
27 isVideoPlaylistTimestampValid, 27 isVideoPlaylistTimestampValid,
28 isVideoPlaylistTypeValid 28 isVideoPlaylistTypeValid
29} from '../../../helpers/custom-validators/video-playlists' 29} from '../../../helpers/custom-validators/video-playlists'
30import { isVideoImage } from '../../../helpers/custom-validators/videos' 30import { isVideoImageValid } from '../../../helpers/custom-validators/videos'
31import { cleanUpReqFiles } from '../../../helpers/express-utils' 31import { cleanUpReqFiles } from '../../../helpers/express-utils'
32import { logger } from '../../../helpers/logger' 32import { logger } from '../../../helpers/logger'
33import { CONSTRAINTS_FIELDS } from '../../../initializers/constants' 33import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
@@ -390,7 +390,7 @@ export {
390function getCommonPlaylistEditAttributes () { 390function getCommonPlaylistEditAttributes () {
391 return [ 391 return [
392 body('thumbnailfile') 392 body('thumbnailfile')
393 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')) 393 .custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile'))
394 .withMessage( 394 .withMessage(
395 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' + 395 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
396 CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ') 396 CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ')
diff --git a/server/middlewares/validators/videos/video-rates.ts b/server/middlewares/validators/videos/video-rates.ts
index 923bf3eaf..1a9736034 100644
--- a/server/middlewares/validators/videos/video-rates.ts
+++ b/server/middlewares/validators/videos/video-rates.ts
@@ -21,12 +21,7 @@ const videoUpdateRateValidator = [
21 if (areValidationErrors(req, res)) return 21 if (areValidationErrors(req, res)) return
22 if (!await doesVideoExist(req.params.id, res)) return 22 if (!await doesVideoExist(req.params.id, res)) return
23 23
24 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.videoAll)) { 24 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.videoAll)) return
25 return res.fail({
26 status: HttpStatusCode.FORBIDDEN_403,
27 message: 'Cannot access to this ressource'
28 })
29 }
30 25
31 return next() 26 return next()
32 } 27 }
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index b3ffb7007..26597cf7b 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -3,7 +3,6 @@ import { body, header, param, query, ValidationChain } from 'express-validator'
3import { isTestInstance } from '@server/helpers/core-utils' 3import { isTestInstance } from '@server/helpers/core-utils'
4import { getResumableUploadPath } from '@server/helpers/upload' 4import { getResumableUploadPath } from '@server/helpers/upload'
5import { Redis } from '@server/lib/redis' 5import { Redis } from '@server/lib/redis'
6import { isAbleToUploadVideo } from '@server/lib/user'
7import { getServerActor } from '@server/models/application/application' 6import { getServerActor } from '@server/models/application/application'
8import { ExpressPromiseHandler } from '@server/types/express-handler' 7import { ExpressPromiseHandler } from '@server/types/express-handler'
9import { MUserAccountId, MVideoFullLight } from '@server/types/models' 8import { MUserAccountId, MVideoFullLight } from '@server/types/models'
@@ -13,7 +12,7 @@ import {
13 exists, 12 exists,
14 isBooleanValid, 13 isBooleanValid,
15 isDateValid, 14 isDateValid,
16 isFileFieldValid, 15 isFileValid,
17 isIdValid, 16 isIdValid,
18 isUUIDValid, 17 isUUIDValid,
19 toArray, 18 toArray,
@@ -23,24 +22,24 @@ import {
23} from '../../../helpers/custom-validators/misc' 22} from '../../../helpers/custom-validators/misc'
24import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' 23import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
25import { 24import {
25 areVideoTagsValid,
26 isScheduleVideoUpdatePrivacyValid, 26 isScheduleVideoUpdatePrivacyValid,
27 isVideoCategoryValid, 27 isVideoCategoryValid,
28 isVideoDescriptionValid, 28 isVideoDescriptionValid,
29 isVideoFileMimeTypeValid, 29 isVideoFileMimeTypeValid,
30 isVideoFileSizeValid, 30 isVideoFileSizeValid,
31 isVideoFilterValid, 31 isVideoFilterValid,
32 isVideoImage, 32 isVideoImageValid,
33 isVideoIncludeValid, 33 isVideoIncludeValid,
34 isVideoLanguageValid, 34 isVideoLanguageValid,
35 isVideoLicenceValid, 35 isVideoLicenceValid,
36 isVideoNameValid, 36 isVideoNameValid,
37 isVideoOriginallyPublishedAtValid, 37 isVideoOriginallyPublishedAtValid,
38 isVideoPrivacyValid, 38 isVideoPrivacyValid,
39 isVideoSupportValid, 39 isVideoSupportValid
40 isVideoTagsValid
41} from '../../../helpers/custom-validators/videos' 40} from '../../../helpers/custom-validators/videos'
42import { cleanUpReqFiles } from '../../../helpers/express-utils' 41import { cleanUpReqFiles } from '../../../helpers/express-utils'
43import { getDurationFromVideoFile } from '../../../helpers/ffprobe-utils' 42import { getVideoStreamDuration } from '../../../helpers/ffmpeg'
44import { logger } from '../../../helpers/logger' 43import { logger } from '../../../helpers/logger'
45import { deleteFileAndCatch } from '../../../helpers/utils' 44import { deleteFileAndCatch } from '../../../helpers/utils'
46import { getVideoWithAttributes } from '../../../helpers/video' 45import { getVideoWithAttributes } from '../../../helpers/video'
@@ -53,6 +52,7 @@ import {
53 areValidationErrors, 52 areValidationErrors,
54 checkCanSeePrivateVideo, 53 checkCanSeePrivateVideo,
55 checkUserCanManageVideo, 54 checkUserCanManageVideo,
55 checkUserQuota,
56 doesVideoChannelOfAccountExist, 56 doesVideoChannelOfAccountExist,
57 doesVideoExist, 57 doesVideoExist,
58 doesVideoFileOfVideoExist, 58 doesVideoFileOfVideoExist,
@@ -61,7 +61,7 @@ import {
61 61
62const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ 62const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
63 body('videofile') 63 body('videofile')
64 .custom((value, { req }) => isFileFieldValid(req.files, 'videofile')) 64 .custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null }))
65 .withMessage('Should have a file'), 65 .withMessage('Should have a file'),
66 body('name') 66 body('name')
67 .trim() 67 .trim()
@@ -299,12 +299,11 @@ const videosCustomGetValidator = (
299 299
300 // Video private or blacklisted 300 // Video private or blacklisted
301 if (video.requiresAuth()) { 301 if (video.requiresAuth()) {
302 if (await checkCanSeePrivateVideo(req, res, video, authenticateInQuery)) return next() 302 if (await checkCanSeePrivateVideo(req, res, video, authenticateInQuery)) {
303 return next()
304 }
303 305
304 return res.fail({ 306 return
305 status: HttpStatusCode.FORBIDDEN_403,
306 message: 'Cannot get this private/internal or blocklisted video'
307 })
308 } 307 }
309 308
310 // Video is public, anyone can access it 309 // Video is public, anyone can access it
@@ -375,12 +374,12 @@ const videosOverviewValidator = [
375function getCommonVideoEditAttributes () { 374function getCommonVideoEditAttributes () {
376 return [ 375 return [
377 body('thumbnailfile') 376 body('thumbnailfile')
378 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( 377 .custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage(
379 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' + 378 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
380 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') 379 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
381 ), 380 ),
382 body('previewfile') 381 body('previewfile')
383 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage( 382 .custom((value, { req }) => isVideoImageValid(req.files, 'previewfile')).withMessage(
384 'This preview file is not supported or too large. Please, make sure it is of the following type: ' + 383 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
385 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') 384 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
386 ), 385 ),
@@ -420,7 +419,7 @@ function getCommonVideoEditAttributes () {
420 body('tags') 419 body('tags')
421 .optional() 420 .optional()
422 .customSanitizer(toValueOrNull) 421 .customSanitizer(toValueOrNull)
423 .custom(isVideoTagsValid) 422 .custom(areVideoTagsValid)
424 .withMessage( 423 .withMessage(
425 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` + 424 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
426 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each` 425 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
@@ -612,14 +611,7 @@ async function commonVideoChecksPass (parameters: {
612 return false 611 return false
613 } 612 }
614 613
615 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) { 614 if (await checkUserQuota(user, videoFileSize, res) === false) return false
616 res.fail({
617 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
618 message: 'The user video quota is exceeded with this video.',
619 type: ServerErrorCode.QUOTA_REACHED
620 })
621 return false
622 }
623 615
624 return true 616 return true
625} 617}
@@ -654,7 +646,7 @@ export async function isVideoAccepted (
654} 646}
655 647
656async function addDurationToVideo (videoFile: { path: string, duration?: number }) { 648async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
657 const duration: number = await getDurationFromVideoFile(videoFile.path) 649 const duration: number = await getVideoStreamDuration(videoFile.path)
658 650
659 if (isNaN(duration)) throw new Error(`Couldn't get video duration`) 651 if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
660 652
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/video/sql/shared/abstract-run-query.ts b/server/models/shared/abstract-run-query.ts
index 8e7a7642d..c39b7bcfe 100644
--- a/server/models/video/sql/shared/abstract-run-query.ts
+++ b/server/models/shared/abstract-run-query.ts
@@ -7,18 +7,20 @@ import { QueryTypes, Sequelize, Transaction } from 'sequelize'
7 */ 7 */
8 8
9export class AbstractRunQuery { 9export class AbstractRunQuery {
10 protected sequelize: Sequelize
11
12 protected query: string 10 protected query: string
13 protected replacements: any = {} 11 protected replacements: any = {}
14 12
15 protected runQuery (options: { transaction?: Transaction, logging?: boolean } = {}) { 13 constructor (protected readonly sequelize: Sequelize) {
14
15 }
16
17 protected runQuery (options: { nest?: boolean, transaction?: Transaction, logging?: boolean } = {}) {
16 const queryOptions = { 18 const queryOptions = {
17 transaction: options.transaction, 19 transaction: options.transaction,
18 logging: options.logging, 20 logging: options.logging,
19 replacements: this.replacements, 21 replacements: this.replacements,
20 type: QueryTypes.SELECT as QueryTypes.SELECT, 22 type: QueryTypes.SELECT as QueryTypes.SELECT,
21 nest: false 23 nest: options.nest ?? false
22 } 24 }
23 25
24 return this.sequelize.query<any>(this.query, queryOptions) 26 return this.sequelize.query<any>(this.query, queryOptions)
diff --git a/server/models/shared/index.ts b/server/models/shared/index.ts
index 5b97510e0..04528929c 100644
--- a/server/models/shared/index.ts
+++ b/server/models/shared/index.ts
@@ -1,2 +1,4 @@
1export * from './abstract-run-query'
2export * from './model-builder'
1export * from './query' 3export * from './query'
2export * from './update' 4export * 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..6a6a71e3a
--- /dev/null
+++ b/server/models/user/sql/user-notitication-list-query-builder.ts
@@ -0,0 +1,264 @@
1import { Sequelize } from 'sequelize'
2import { AbstractRunQuery, 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}
14
15export class UserNotificationListQueryBuilder extends AbstractRunQuery {
16 private innerQuery: string
17
18 constructor (
19 protected readonly sequelize: Sequelize,
20 private readonly options: ListNotificationsOptions
21 ) {
22 super(sequelize)
23 }
24
25 async listNotifications () {
26 this.buildQuery()
27
28 const results = await this.runQuery({ nest: true })
29 const modelBuilder = new ModelBuilder<UserNotificationModelForApi>(this.sequelize)
30
31 return modelBuilder.createModels(results, 'UserNotification')
32 }
33
34 private buildInnerQuery () {
35 this.innerQuery = `SELECT * FROM "userNotification" AS "UserNotificationModel" ` +
36 `${this.getWhere()} ` +
37 `${this.getOrder()} ` +
38 `LIMIT :limit OFFSET :offset `
39
40 this.replacements.limit = this.options.limit
41 this.replacements.offset = this.options.offset
42 }
43
44 private buildQuery () {
45 this.buildInnerQuery()
46
47 this.query = `
48 ${this.getSelect()}
49 FROM (${this.innerQuery}) "UserNotificationModel"
50 ${this.getJoins()}
51 ${this.getOrder()}`
52 }
53
54 private getWhere () {
55 let base = '"UserNotificationModel"."userId" = :userId '
56 this.replacements.userId = this.options.userId
57
58 if (this.options.unread === true) {
59 base += 'AND "UserNotificationModel"."read" IS FALSE '
60 } else if (this.options.unread === false) {
61 base += 'AND "UserNotificationModel"."read" IS TRUE '
62 }
63
64 return `WHERE ${base}`
65 }
66
67 private getOrder () {
68 const orders = getSort(this.options.sort)
69
70 return 'ORDER BY ' + orders.map(o => `"UserNotificationModel"."${o[0]}" ${o[1]}`).join(', ')
71 }
72
73 private getSelect () {
74 return `SELECT
75 "UserNotificationModel"."id",
76 "UserNotificationModel"."type",
77 "UserNotificationModel"."read",
78 "UserNotificationModel"."createdAt",
79 "UserNotificationModel"."updatedAt",
80 "Video"."id" AS "Video.id",
81 "Video"."uuid" AS "Video.uuid",
82 "Video"."name" AS "Video.name",
83 "Video->VideoChannel"."id" AS "Video.VideoChannel.id",
84 "Video->VideoChannel"."name" AS "Video.VideoChannel.name",
85 "Video->VideoChannel->Actor"."id" AS "Video.VideoChannel.Actor.id",
86 "Video->VideoChannel->Actor"."preferredUsername" AS "Video.VideoChannel.Actor.preferredUsername",
87 "Video->VideoChannel->Actor->Avatars"."id" AS "Video.VideoChannel.Actor.Avatars.id",
88 "Video->VideoChannel->Actor->Avatars"."width" AS "Video.VideoChannel.Actor.Avatars.width",
89 "Video->VideoChannel->Actor->Avatars"."filename" AS "Video.VideoChannel.Actor.Avatars.filename",
90 "Video->VideoChannel->Actor->Server"."id" AS "Video.VideoChannel.Actor.Server.id",
91 "Video->VideoChannel->Actor->Server"."host" AS "Video.VideoChannel.Actor.Server.host",
92 "VideoComment"."id" AS "VideoComment.id",
93 "VideoComment"."originCommentId" AS "VideoComment.originCommentId",
94 "VideoComment->Account"."id" AS "VideoComment.Account.id",
95 "VideoComment->Account"."name" AS "VideoComment.Account.name",
96 "VideoComment->Account->Actor"."id" AS "VideoComment.Account.Actor.id",
97 "VideoComment->Account->Actor"."preferredUsername" AS "VideoComment.Account.Actor.preferredUsername",
98 "VideoComment->Account->Actor->Avatars"."id" AS "VideoComment.Account.Actor.Avatars.id",
99 "VideoComment->Account->Actor->Avatars"."width" AS "VideoComment.Account.Actor.Avatars.width",
100 "VideoComment->Account->Actor->Avatars"."filename" AS "VideoComment.Account.Actor.Avatars.filename",
101 "VideoComment->Account->Actor->Server"."id" AS "VideoComment.Account.Actor.Server.id",
102 "VideoComment->Account->Actor->Server"."host" AS "VideoComment.Account.Actor.Server.host",
103 "VideoComment->Video"."id" AS "VideoComment.Video.id",
104 "VideoComment->Video"."uuid" AS "VideoComment.Video.uuid",
105 "VideoComment->Video"."name" AS "VideoComment.Video.name",
106 "Abuse"."id" AS "Abuse.id",
107 "Abuse"."state" AS "Abuse.state",
108 "Abuse->VideoAbuse"."id" AS "Abuse.VideoAbuse.id",
109 "Abuse->VideoAbuse->Video"."id" AS "Abuse.VideoAbuse.Video.id",
110 "Abuse->VideoAbuse->Video"."uuid" AS "Abuse.VideoAbuse.Video.uuid",
111 "Abuse->VideoAbuse->Video"."name" AS "Abuse.VideoAbuse.Video.name",
112 "Abuse->VideoCommentAbuse"."id" AS "Abuse.VideoCommentAbuse.id",
113 "Abuse->VideoCommentAbuse->VideoComment"."id" AS "Abuse.VideoCommentAbuse.VideoComment.id",
114 "Abuse->VideoCommentAbuse->VideoComment"."originCommentId" AS "Abuse.VideoCommentAbuse.VideoComment.originCommentId",
115 "Abuse->VideoCommentAbuse->VideoComment->Video"."id" AS "Abuse.VideoCommentAbuse.VideoComment.Video.id",
116 "Abuse->VideoCommentAbuse->VideoComment->Video"."name" AS "Abuse.VideoCommentAbuse.VideoComment.Video.name",
117 "Abuse->VideoCommentAbuse->VideoComment->Video"."uuid" AS "Abuse.VideoCommentAbuse.VideoComment.Video.uuid",
118 "Abuse->FlaggedAccount"."id" AS "Abuse.FlaggedAccount.id",
119 "Abuse->FlaggedAccount"."name" AS "Abuse.FlaggedAccount.name",
120 "Abuse->FlaggedAccount"."description" AS "Abuse.FlaggedAccount.description",
121 "Abuse->FlaggedAccount"."actorId" AS "Abuse.FlaggedAccount.actorId",
122 "Abuse->FlaggedAccount"."userId" AS "Abuse.FlaggedAccount.userId",
123 "Abuse->FlaggedAccount"."applicationId" AS "Abuse.FlaggedAccount.applicationId",
124 "Abuse->FlaggedAccount"."createdAt" AS "Abuse.FlaggedAccount.createdAt",
125 "Abuse->FlaggedAccount"."updatedAt" AS "Abuse.FlaggedAccount.updatedAt",
126 "Abuse->FlaggedAccount->Actor"."id" AS "Abuse.FlaggedAccount.Actor.id",
127 "Abuse->FlaggedAccount->Actor"."preferredUsername" AS "Abuse.FlaggedAccount.Actor.preferredUsername",
128 "Abuse->FlaggedAccount->Actor->Avatars"."id" AS "Abuse.FlaggedAccount.Actor.Avatars.id",
129 "Abuse->FlaggedAccount->Actor->Avatars"."width" AS "Abuse.FlaggedAccount.Actor.Avatars.width",
130 "Abuse->FlaggedAccount->Actor->Avatars"."filename" AS "Abuse.FlaggedAccount.Actor.Avatars.filename",
131 "Abuse->FlaggedAccount->Actor->Server"."id" AS "Abuse.FlaggedAccount.Actor.Server.id",
132 "Abuse->FlaggedAccount->Actor->Server"."host" AS "Abuse.FlaggedAccount.Actor.Server.host",
133 "VideoBlacklist"."id" AS "VideoBlacklist.id",
134 "VideoBlacklist->Video"."id" AS "VideoBlacklist.Video.id",
135 "VideoBlacklist->Video"."uuid" AS "VideoBlacklist.Video.uuid",
136 "VideoBlacklist->Video"."name" AS "VideoBlacklist.Video.name",
137 "VideoImport"."id" AS "VideoImport.id",
138 "VideoImport"."magnetUri" AS "VideoImport.magnetUri",
139 "VideoImport"."targetUrl" AS "VideoImport.targetUrl",
140 "VideoImport"."torrentName" AS "VideoImport.torrentName",
141 "VideoImport->Video"."id" AS "VideoImport.Video.id",
142 "VideoImport->Video"."uuid" AS "VideoImport.Video.uuid",
143 "VideoImport->Video"."name" AS "VideoImport.Video.name",
144 "Plugin"."id" AS "Plugin.id",
145 "Plugin"."name" AS "Plugin.name",
146 "Plugin"."type" AS "Plugin.type",
147 "Plugin"."latestVersion" AS "Plugin.latestVersion",
148 "Application"."id" AS "Application.id",
149 "Application"."latestPeerTubeVersion" AS "Application.latestPeerTubeVersion",
150 "ActorFollow"."id" AS "ActorFollow.id",
151 "ActorFollow"."state" AS "ActorFollow.state",
152 "ActorFollow->ActorFollower"."id" AS "ActorFollow.ActorFollower.id",
153 "ActorFollow->ActorFollower"."preferredUsername" AS "ActorFollow.ActorFollower.preferredUsername",
154 "ActorFollow->ActorFollower->Account"."id" AS "ActorFollow.ActorFollower.Account.id",
155 "ActorFollow->ActorFollower->Account"."name" AS "ActorFollow.ActorFollower.Account.name",
156 "ActorFollow->ActorFollower->Avatars"."id" AS "ActorFollow.ActorFollower.Avatars.id",
157 "ActorFollow->ActorFollower->Avatars"."width" AS "ActorFollow.ActorFollower.Avatars.width",
158 "ActorFollow->ActorFollower->Avatars"."filename" AS "ActorFollow.ActorFollower.Avatars.filename",
159 "ActorFollow->ActorFollower->Server"."id" AS "ActorFollow.ActorFollower.Server.id",
160 "ActorFollow->ActorFollower->Server"."host" AS "ActorFollow.ActorFollower.Server.host",
161 "ActorFollow->ActorFollowing"."id" AS "ActorFollow.ActorFollowing.id",
162 "ActorFollow->ActorFollowing"."preferredUsername" AS "ActorFollow.ActorFollowing.preferredUsername",
163 "ActorFollow->ActorFollowing"."type" AS "ActorFollow.ActorFollowing.type",
164 "ActorFollow->ActorFollowing->VideoChannel"."id" AS "ActorFollow.ActorFollowing.VideoChannel.id",
165 "ActorFollow->ActorFollowing->VideoChannel"."name" AS "ActorFollow.ActorFollowing.VideoChannel.name",
166 "ActorFollow->ActorFollowing->Account"."id" AS "ActorFollow.ActorFollowing.Account.id",
167 "ActorFollow->ActorFollowing->Account"."name" AS "ActorFollow.ActorFollowing.Account.name",
168 "ActorFollow->ActorFollowing->Server"."id" AS "ActorFollow.ActorFollowing.Server.id",
169 "ActorFollow->ActorFollowing->Server"."host" AS "ActorFollow.ActorFollowing.Server.host",
170 "Account"."id" AS "Account.id",
171 "Account"."name" AS "Account.name",
172 "Account->Actor"."id" AS "Account.Actor.id",
173 "Account->Actor"."preferredUsername" AS "Account.Actor.preferredUsername",
174 "Account->Actor->Avatars"."id" AS "Account.Actor.Avatars.id",
175 "Account->Actor->Avatars"."width" AS "Account.Actor.Avatars.width",
176 "Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename",
177 "Account->Actor->Server"."id" AS "Account.Actor.Server.id",
178 "Account->Actor->Server"."host" AS "Account.Actor.Server.host"`
179 }
180
181 private getJoins () {
182 return `
183 LEFT JOIN (
184 "video" AS "Video"
185 INNER JOIN "videoChannel" AS "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id"
186 INNER JOIN "actor" AS "Video->VideoChannel->Actor" ON "Video->VideoChannel"."actorId" = "Video->VideoChannel->Actor"."id"
187 LEFT JOIN "actorImage" AS "Video->VideoChannel->Actor->Avatars"
188 ON "Video->VideoChannel->Actor"."id" = "Video->VideoChannel->Actor->Avatars"."actorId"
189 AND "Video->VideoChannel->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
190 LEFT JOIN "server" AS "Video->VideoChannel->Actor->Server"
191 ON "Video->VideoChannel->Actor"."serverId" = "Video->VideoChannel->Actor->Server"."id"
192 ) ON "UserNotificationModel"."videoId" = "Video"."id"
193
194 LEFT JOIN (
195 "videoComment" AS "VideoComment"
196 INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id"
197 INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id"
198 LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars"
199 ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId"
200 AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
201 LEFT JOIN "server" AS "VideoComment->Account->Actor->Server"
202 ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id"
203 INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id"
204 ) ON "UserNotificationModel"."commentId" = "VideoComment"."id"
205
206 LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id"
207 LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId"
208 LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id"
209 LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId"
210 LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
211 ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
212 LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
213 ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
214 LEFT JOIN (
215 "account" AS "Abuse->FlaggedAccount"
216 INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id"
217 LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars"
218 ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId"
219 AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
220 LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server"
221 ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id"
222 ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
223
224 LEFT JOIN (
225 "videoBlacklist" AS "VideoBlacklist"
226 INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
227 ) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
228
229 LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id"
230 LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
231
232 LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id"
233
234 LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id"
235
236 LEFT JOIN (
237 "actorFollow" AS "ActorFollow"
238 INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id"
239 INNER JOIN "account" AS "ActorFollow->ActorFollower->Account"
240 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId"
241 LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars"
242 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId"
243 AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR}
244 LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server"
245 ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id"
246 INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
247 LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
248 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId"
249 LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
250 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId"
251 LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server"
252 ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id"
253 ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
254
255 LEFT JOIN (
256 "account" AS "Account"
257 INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
258 LEFT JOIN "actorImage" AS "Account->Actor->Avatars"
259 ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId"
260 AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
261 LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"
262 ) ON "UserNotificationModel"."accountId" = "Account"."id"`
263 }
264}
diff --git a/server/models/user/user-notification.ts b/server/models/user/user-notification.ts
index edad10a55..6209cb4bf 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,10 +243,12 @@ 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
439 } 253 }
440 254
@@ -445,8 +259,8 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
445 .then(count => count || 0), 259 .then(count => count || 0),
446 260
447 count === 0 261 count === 0
448 ? [] 262 ? [] as UserNotificationModelForApi[]
449 : UserNotificationModel.scope(ScopeNames.WITH_ALL).findAll(query) 263 : new UserNotificationListQueryBuilder(this.sequelize, query).listNotifications()
450 ]).then(([ total, data ]) => ({ total, data })) 264 ]).then(([ total, data ]) => ({ total, data }))
451 } 265 }
452 266
@@ -524,25 +338,31 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
524 338
525 toFormattedJSON (this: UserNotificationModelForApi): UserNotification { 339 toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
526 const video = this.Video 340 const video = this.Video
527 ? Object.assign(this.formatVideo(this.Video), { channel: this.formatActor(this.Video.VideoChannel) }) 341 ? {
342 ...this.formatVideo(this.Video),
343
344 channel: this.formatActor(this.Video.VideoChannel)
345 }
528 : undefined 346 : undefined
529 347
530 const videoImport = this.VideoImport 348 const videoImport = this.VideoImport
531 ? { 349 ? {
532 id: this.VideoImport.id, 350 id: this.VideoImport.id,
533 video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined, 351 video: this.VideoImport.Video
352 ? this.formatVideo(this.VideoImport.Video)
353 : undefined,
534 torrentName: this.VideoImport.torrentName, 354 torrentName: this.VideoImport.torrentName,
535 magnetUri: this.VideoImport.magnetUri, 355 magnetUri: this.VideoImport.magnetUri,
536 targetUrl: this.VideoImport.targetUrl 356 targetUrl: this.VideoImport.targetUrl
537 } 357 }
538 : undefined 358 : undefined
539 359
540 const comment = this.Comment 360 const comment = this.VideoComment
541 ? { 361 ? {
542 id: this.Comment.id, 362 id: this.VideoComment.id,
543 threadId: this.Comment.getThreadId(), 363 threadId: this.VideoComment.getThreadId(),
544 account: this.formatActor(this.Comment.Account), 364 account: this.formatActor(this.VideoComment.Account),
545 video: this.formatVideo(this.Comment.Video) 365 video: this.formatVideo(this.VideoComment.Video)
546 } 366 }
547 : undefined 367 : undefined
548 368
@@ -570,8 +390,9 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
570 id: this.ActorFollow.ActorFollower.Account.id, 390 id: this.ActorFollow.ActorFollower.Account.id,
571 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(), 391 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
572 name: this.ActorFollow.ActorFollower.preferredUsername, 392 name: this.ActorFollow.ActorFollower.preferredUsername,
573 avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getStaticPath() } : undefined, 393 host: this.ActorFollow.ActorFollower.getHost(),
574 host: this.ActorFollow.ActorFollower.getHost() 394
395 ...this.formatAvatars(this.ActorFollow.ActorFollower.Avatars)
575 }, 396 },
576 following: { 397 following: {
577 type: actorFollowingType[this.ActorFollow.ActorFollowing.type], 398 type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
@@ -612,7 +433,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
612 } 433 }
613 } 434 }
614 435
615 formatVideo (this: UserNotificationModelForApi, video: UserNotificationIncludes.VideoInclude) { 436 formatVideo (video: UserNotificationIncludes.VideoInclude) {
616 return { 437 return {
617 id: video.id, 438 id: video.id,
618 uuid: video.uuid, 439 uuid: video.uuid,
@@ -621,7 +442,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
621 } 442 }
622 } 443 }
623 444
624 formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) { 445 formatAbuse (abuse: UserNotificationIncludes.AbuseInclude) {
625 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment 446 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
626 ? { 447 ? {
627 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(), 448 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
@@ -637,9 +458,13 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
637 } 458 }
638 : undefined 459 : undefined
639 460
640 const videoAbuse = abuse.VideoAbuse?.Video ? this.formatVideo(abuse.VideoAbuse.Video) : undefined 461 const videoAbuse = abuse.VideoAbuse?.Video
462 ? this.formatVideo(abuse.VideoAbuse.Video)
463 : undefined
641 464
642 const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount) ? this.formatActor(abuse.FlaggedAccount) : undefined 465 const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount)
466 ? this.formatActor(abuse.FlaggedAccount)
467 : undefined
643 468
644 return { 469 return {
645 id: abuse.id, 470 id: abuse.id,
@@ -651,19 +476,32 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
651 } 476 }
652 477
653 formatActor ( 478 formatActor (
654 this: UserNotificationModelForApi,
655 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor 479 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
656 ) { 480 ) {
657 const avatar = accountOrChannel.Actor.Avatar
658 ? { path: accountOrChannel.Actor.Avatar.getStaticPath() }
659 : undefined
660
661 return { 481 return {
662 id: accountOrChannel.id, 482 id: accountOrChannel.id,
663 displayName: accountOrChannel.getDisplayName(), 483 displayName: accountOrChannel.getDisplayName(),
664 name: accountOrChannel.Actor.preferredUsername, 484 name: accountOrChannel.Actor.preferredUsername,
665 host: accountOrChannel.Actor.getHost(), 485 host: accountOrChannel.Actor.getHost(),
666 avatar 486
487 ...this.formatAvatars(accountOrChannel.Actor.Avatars)
488 }
489 }
490
491 formatAvatars (avatars: UserNotificationIncludes.ActorImageInclude[]) {
492 if (!avatars || avatars.length === 0) return { avatar: undefined, avatars: [] }
493
494 return {
495 avatar: this.formatAvatar(getBiggestActorImage(avatars)),
496
497 avatars: avatars.map(a => this.formatAvatar(a))
498 }
499 }
500
501 formatAvatar (a: UserNotificationIncludes.ActorImageInclude) {
502 return {
503 path: a.getStaticPath(),
504 width: a.width
667 } 505 }
668 } 506 }
669} 507}
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-video-query-builder.ts b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
index a6afb04e4..b79d20ade 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,7 +1,9 @@
1import { Sequelize } from 'sequelize'
2import validator from 'validator'
1import { createSafeIn } from '@server/models/utils' 3import { createSafeIn } from '@server/models/utils'
2import { MUserAccountId } from '@server/types/models' 4import { MUserAccountId } from '@server/types/models'
3import validator from 'validator' 5import { ActorImageType } from '@shared/models'
4import { AbstractRunQuery } from './abstract-run-query' 6import { AbstractRunQuery } from '../../../../shared/abstract-run-query'
5import { VideoTableAttributes } from './video-table-attributes' 7import { VideoTableAttributes } from './video-table-attributes'
6 8
7/** 9/**
@@ -18,8 +20,11 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
18 20
19 protected tables: VideoTableAttributes 21 protected tables: VideoTableAttributes
20 22
21 constructor (protected readonly mode: 'list' | 'get') { 23 constructor (
22 super() 24 protected readonly sequelize: Sequelize,
25 protected readonly mode: 'list' | 'get'
26 ) {
27 super(sequelize)
23 28
24 this.tables = new VideoTableAttributes(this.mode) 29 this.tables = new VideoTableAttributes(this.mode)
25 } 30 }
@@ -42,8 +47,9 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
42 ) 47 )
43 48
44 this.addJoin( 49 this.addJoin(
45 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatar" ' + 50 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatars" ' +
46 'ON "VideoChannel->Actor"."avatarId" = "VideoChannel->Actor->Avatar"."id"' 51 'ON "VideoChannel->Actor"."id" = "VideoChannel->Actor->Avatars"."actorId" ' +
52 `AND "VideoChannel->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
47 ) 53 )
48 54
49 this.attributes = { 55 this.attributes = {
@@ -51,7 +57,7 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
51 57
52 ...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()), 58 ...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()),
53 ...this.buildActorInclude('VideoChannel->Actor'), 59 ...this.buildActorInclude('VideoChannel->Actor'),
54 ...this.buildAvatarInclude('VideoChannel->Actor->Avatar'), 60 ...this.buildAvatarInclude('VideoChannel->Actor->Avatars'),
55 ...this.buildServerInclude('VideoChannel->Actor->Server') 61 ...this.buildServerInclude('VideoChannel->Actor->Server')
56 } 62 }
57 } 63 }
@@ -68,8 +74,9 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
68 ) 74 )
69 75
70 this.addJoin( 76 this.addJoin(
71 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatar" ' + 77 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatars" ' +
72 'ON "VideoChannel->Account->Actor"."avatarId" = "VideoChannel->Account->Actor->Avatar"."id"' 78 'ON "VideoChannel->Account"."actorId"= "VideoChannel->Account->Actor->Avatars"."actorId" ' +
79 `AND "VideoChannel->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
73 ) 80 )
74 81
75 this.attributes = { 82 this.attributes = {
@@ -77,7 +84,7 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
77 84
78 ...this.buildAttributesObject('VideoChannel->Account', this.tables.getAccountAttributes()), 85 ...this.buildAttributesObject('VideoChannel->Account', this.tables.getAccountAttributes()),
79 ...this.buildActorInclude('VideoChannel->Account->Actor'), 86 ...this.buildActorInclude('VideoChannel->Account->Actor'),
80 ...this.buildAvatarInclude('VideoChannel->Account->Actor->Avatar'), 87 ...this.buildAvatarInclude('VideoChannel->Account->Actor->Avatars'),
81 ...this.buildServerInclude('VideoChannel->Account->Actor->Server') 88 ...this.buildServerInclude('VideoChannel->Account->Actor->Server')
82 } 89 }
83 } 90 }
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..50c12f627 100644
--- a/server/models/video/sql/shared/video-file-query-builder.ts
+++ b/server/models/video/sql/video/shared/video-file-query-builder.ts
@@ -12,7 +12,7 @@ export class VideoFileQueryBuilder extends AbstractVideoQueryBuilder {
12 protected attributes: { [key: string]: string } 12 protected attributes: { [key: string]: string }
13 13
14 constructor (protected readonly sequelize: Sequelize) { 14 constructor (protected readonly sequelize: Sequelize) {
15 super('get') 15 super(sequelize, 'get')
16 } 16 }
17 17
18 queryWebTorrentVideos (options: BuildVideoGetQueryOptions) { 18 queryWebTorrentVideos (options: BuildVideoGetQueryOptions) {
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..0a2beb7db 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>
@@ -50,8 +51,8 @@ export class VideoModelBuilder {
50 private readonly buildOpts = { raw: true, isNewRecord: false } 51 private readonly buildOpts = { raw: true, isNewRecord: false }
51 52
52 constructor ( 53 constructor (
53 readonly mode: 'get' | 'list', 54 private readonly mode: 'get' | 'list',
54 readonly tables: VideoTableAttributes 55 private readonly tables: VideoTableAttributes
55 ) { 56 ) {
56 57
57 } 58 }
@@ -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,20 @@ 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}.Avatars`
238 const id = row[`${avatarPrefix}.id`]
239 const key = `${row.id}${id}`
240
241 if (!id || this.actorImagesDone.has(key)) return
242
243 const attributes = this.grab(row, this.tables.getAvatarAttributes(), avatarPrefix)
244 const avatarModel = new ActorImageModel(attributes, this.buildOpts)
245 actor.Avatars.push(avatarModel)
246
247 this.actorImagesDone.add(key)
248 }
249
229 private addThumbnail (row: SQLRow, videoModel: VideoModel) { 250 private addThumbnail (row: SQLRow, videoModel: VideoModel) {
230 const id = row['Thumbnails.id'] 251 const id = row['Thumbnails.id']
231 if (!id || this.thumbnailsDone.has(id)) return 252 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..f4d9e99fd 100644
--- a/server/models/video/sql/shared/video-table-attributes.ts
+++ b/server/models/video/sql/video/shared/video-table-attributes.ts
@@ -6,7 +6,7 @@
6 */ 6 */
7export class VideoTableAttributes { 7export class VideoTableAttributes {
8 8
9 constructor (readonly mode: 'get' | 'list') { 9 constructor (private readonly mode: 'get' | 'list') {
10 10
11 } 11 }
12 12
@@ -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..b0879c9ac 100644
--- a/server/models/video/sql/video-model-get-query-builder.ts
+++ b/server/models/video/sql/video/video-model-get-query-builder.ts
@@ -110,7 +110,7 @@ export class VideosModelGetQuerySubBuilder extends AbstractVideoQueryBuilder {
110 ]) 110 ])
111 111
112 constructor (protected readonly sequelize: Sequelize) { 112 constructor (protected readonly sequelize: Sequelize) {
113 super('get') 113 super(sequelize, 'get')
114 } 114 }
115 115
116 queryVideos (options: BuildVideoGetQueryOptions) { 116 queryVideos (options: BuildVideoGetQueryOptions) {
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..19aff631d 100644
--- a/server/models/video/sql/videos-id-list-query-builder.ts
+++ b/server/models/video/sql/video/videos-id-list-query-builder.ts
@@ -5,7 +5,7 @@ import { WEBSERVER } from '@server/initializers/constants'
5import { buildDirectionAndField, createSafeIn } from '@server/models/utils' 5import { buildDirectionAndField, createSafeIn } from '@server/models/utils'
6import { MUserAccountId, MUserId } from '@server/types/models' 6import { MUserAccountId, MUserId } from '@server/types/models'
7import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models' 7import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models'
8import { AbstractRunQuery } from './shared/abstract-run-query' 8import { AbstractRunQuery } from '../../../shared/abstract-run-query'
9 9
10/** 10/**
11 * 11 *
@@ -93,7 +93,7 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
93 private offset = '' 93 private offset = ''
94 94
95 constructor (protected readonly sequelize: Sequelize) { 95 constructor (protected readonly sequelize: Sequelize) {
96 super() 96 super(sequelize)
97 } 97 }
98 98
99 queryVideoIds (options: BuildVideosListQueryOptions) { 99 queryVideoIds (options: BuildVideosListQueryOptions) {
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..2a4afc389 100644
--- a/server/models/video/sql/videos-model-list-query-builder.ts
+++ b/server/models/video/sql/video/videos-model-list-query-builder.ts
@@ -19,7 +19,7 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder {
19 private readonly videoModelBuilder: VideoModelBuilder 19 private readonly videoModelBuilder: VideoModelBuilder
20 20
21 constructor (protected readonly sequelize: Sequelize) { 21 constructor (protected readonly sequelize: Sequelize) {
22 super('list') 22 super(sequelize, 'list')
23 23
24 this.videoModelBuilder = new VideoModelBuilder(this.mode, this.tables) 24 this.videoModelBuilder = new VideoModelBuilder(this.mode, this.tables)
25 } 25 }
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..a4093ce3b 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -61,7 +61,7 @@ import {
61 isVideoStateValid, 61 isVideoStateValid,
62 isVideoSupportValid 62 isVideoSupportValid
63} from '../../helpers/custom-validators/videos' 63} from '../../helpers/custom-validators/videos'
64import { getVideoFileResolution } from '../../helpers/ffprobe-utils' 64import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg'
65import { logger } from '../../helpers/logger' 65import { logger } from '../../helpers/logger'
66import { CONFIG } from '../../initializers/config' 66import { CONFIG } from '../../initializers/config'
67import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants' 67import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
@@ -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 ]
@@ -1679,7 +1683,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1679 return peertubeTruncate(this.description, { length: maxLength }) 1683 return peertubeTruncate(this.description, { length: maxLength })
1680 } 1684 }
1681 1685
1682 getMaxQualityFileInfo () { 1686 probeMaxQualityFile () {
1683 const file = this.getMaxQualityFile() 1687 const file = this.getMaxQualityFile()
1684 const videoOrPlaylist = file.getVideoOrStreamingPlaylist() 1688 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1685 1689
@@ -1691,7 +1695,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1691 return { 1695 return {
1692 audioStream, 1696 audioStream,
1693 1697
1694 ...await getVideoFileResolution(originalFilePath, probe) 1698 ...await getVideoStreamDimensionsInfo(originalFilePath, probe)
1695 } 1699 }
1696 }) 1700 })
1697 } 1701 }
diff --git a/server/tests/api/activitypub/refresher.ts b/server/tests/api/activitypub/refresher.ts
index 71e1c40ba..bb81d4565 100644
--- a/server/tests/api/activitypub/refresher.ts
+++ b/server/tests/api/activitypub/refresher.ts
@@ -25,12 +25,16 @@ describe('Test AP refresher', function () {
25 before(async function () { 25 before(async function () {
26 this.timeout(60000) 26 this.timeout(60000)
27 27
28 servers = await createMultipleServers(2, { transcoding: { enabled: false } }) 28 servers = await createMultipleServers(2)
29 29
30 // Get the access tokens 30 // Get the access tokens
31 await setAccessTokensToServers(servers) 31 await setAccessTokensToServers(servers)
32 await setDefaultVideoChannel(servers) 32 await setDefaultVideoChannel(servers)
33 33
34 for (const server of servers) {
35 await server.config.disableTranscoding()
36 }
37
34 { 38 {
35 videoUUID1 = (await servers[1].videos.quickUpload({ name: 'video1' })).uuid 39 videoUUID1 = (await servers[1].videos.quickUpload({ name: 'video1' })).uuid
36 videoUUID2 = (await servers[1].videos.quickUpload({ name: 'video2' })).uuid 40 videoUUID2 = (await servers[1].videos.quickUpload({ name: 'video2' })).uuid
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 3cccb612a..ce067a892 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -145,6 +145,9 @@ describe('Test config API validators', function () {
145 } 145 }
146 } 146 }
147 }, 147 },
148 videoEditor: {
149 enabled: true
150 },
148 import: { 151 import: {
149 videos: { 152 videos: {
150 concurrency: 1, 153 concurrency: 1,
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index e052296db..c088b52cd 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -25,6 +25,7 @@ import './video-blacklist'
25import './video-captions' 25import './video-captions'
26import './video-channels' 26import './video-channels'
27import './video-comments' 27import './video-comments'
28import './video-editor'
28import './video-imports' 29import './video-imports'
29import './video-playlists' 30import './video-playlists'
30import './videos' 31import './videos'
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/check-params/video-editor.ts b/server/tests/api/check-params/video-editor.ts
new file mode 100644
index 000000000..db284a3cc
--- /dev/null
+++ b/server/tests/api/check-params/video-editor.ts
@@ -0,0 +1,385 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import { HttpStatusCode, VideoEditorTask } from '@shared/models'
5import {
6 cleanupTests,
7 createSingleServer,
8 PeerTubeServer,
9 setAccessTokensToServers,
10 VideoEditorCommand,
11 waitJobs
12} from '@shared/server-commands'
13
14describe('Test video editor API validator', function () {
15 let server: PeerTubeServer
16 let command: VideoEditorCommand
17 let userAccessToken: string
18 let videoUUID: string
19
20 // ---------------------------------------------------------------
21
22 before(async function () {
23 this.timeout(120_000)
24
25 server = await createSingleServer(1)
26
27 await setAccessTokensToServers([ server ])
28 userAccessToken = await server.users.generateUserAndToken('user1')
29
30 await server.config.enableMinimumTranscoding()
31
32 const { uuid } = await server.videos.quickUpload({ name: 'video' })
33 videoUUID = uuid
34
35 command = server.videoEditor
36
37 await waitJobs([ server ])
38 })
39
40 describe('Task creation', function () {
41
42 describe('Config settings', function () {
43
44 it('Should fail if editor is disabled', async function () {
45 await server.config.updateExistingSubConfig({
46 newConfig: {
47 videoEditor: {
48 enabled: false
49 }
50 }
51 })
52
53 await command.createEditionTasks({
54 videoId: videoUUID,
55 tasks: VideoEditorCommand.getComplexTask(),
56 expectedStatus: HttpStatusCode.BAD_REQUEST_400
57 })
58 })
59
60 it('Should fail to enable editor if transcoding is disabled', async function () {
61 await server.config.updateExistingSubConfig({
62 newConfig: {
63 videoEditor: {
64 enabled: true
65 },
66 transcoding: {
67 enabled: false
68 }
69 },
70 expectedStatus: HttpStatusCode.BAD_REQUEST_400
71 })
72 })
73
74 it('Should succeed to enable video editor', async function () {
75 await server.config.updateExistingSubConfig({
76 newConfig: {
77 videoEditor: {
78 enabled: true
79 },
80 transcoding: {
81 enabled: true
82 }
83 }
84 })
85 })
86 })
87
88 describe('Common tasks', function () {
89
90 it('Should fail without token', async function () {
91 await command.createEditionTasks({
92 token: null,
93 videoId: videoUUID,
94 tasks: VideoEditorCommand.getComplexTask(),
95 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
96 })
97 })
98
99 it('Should fail with another user token', async function () {
100 await command.createEditionTasks({
101 token: userAccessToken,
102 videoId: videoUUID,
103 tasks: VideoEditorCommand.getComplexTask(),
104 expectedStatus: HttpStatusCode.FORBIDDEN_403
105 })
106 })
107
108 it('Should fail with an invalid video', async function () {
109 await command.createEditionTasks({
110 videoId: 'tintin',
111 tasks: VideoEditorCommand.getComplexTask(),
112 expectedStatus: HttpStatusCode.BAD_REQUEST_400
113 })
114 })
115
116 it('Should fail with an unknown video', async function () {
117 await command.createEditionTasks({
118 videoId: 42,
119 tasks: VideoEditorCommand.getComplexTask(),
120 expectedStatus: HttpStatusCode.NOT_FOUND_404
121 })
122 })
123
124 it('Should fail with an already in transcoding state video', async function () {
125 await server.jobs.pauseJobQueue()
126
127 const { uuid } = await server.videos.quickUpload({ name: 'transcoded video' })
128
129 await command.createEditionTasks({
130 videoId: uuid,
131 tasks: VideoEditorCommand.getComplexTask(),
132 expectedStatus: HttpStatusCode.CONFLICT_409
133 })
134
135 await server.jobs.resumeJobQueue()
136 })
137
138 it('Should fail with a bad complex task', async function () {
139 await command.createEditionTasks({
140 videoId: videoUUID,
141 tasks: [
142 {
143 name: 'cut',
144 options: {
145 start: 1,
146 end: 2
147 }
148 },
149 {
150 name: 'hadock',
151 options: {
152 start: 1,
153 end: 2
154 }
155 }
156 ] as any,
157 expectedStatus: HttpStatusCode.BAD_REQUEST_400
158 })
159 })
160
161 it('Should fail without task', async function () {
162 await command.createEditionTasks({
163 videoId: videoUUID,
164 tasks: [],
165 expectedStatus: HttpStatusCode.BAD_REQUEST_400
166 })
167 })
168
169 it('Should fail with too many tasks', async function () {
170 const tasks: VideoEditorTask[] = []
171
172 for (let i = 0; i < 110; i++) {
173 tasks.push({
174 name: 'cut',
175 options: {
176 start: 1
177 }
178 })
179 }
180
181 await command.createEditionTasks({
182 videoId: videoUUID,
183 tasks,
184 expectedStatus: HttpStatusCode.BAD_REQUEST_400
185 })
186 })
187
188 it('Should succeed with correct parameters', async function () {
189 await server.jobs.pauseJobQueue()
190
191 await command.createEditionTasks({
192 videoId: videoUUID,
193 tasks: VideoEditorCommand.getComplexTask(),
194 expectedStatus: HttpStatusCode.NO_CONTENT_204
195 })
196 })
197
198 it('Should fail with a video that is already waiting for edition', async function () {
199 this.timeout(120000)
200
201 await command.createEditionTasks({
202 videoId: videoUUID,
203 tasks: VideoEditorCommand.getComplexTask(),
204 expectedStatus: HttpStatusCode.CONFLICT_409
205 })
206
207 await server.jobs.resumeJobQueue()
208
209 await waitJobs([ server ])
210 })
211 })
212
213 describe('Cut task', function () {
214
215 async function cut (start: number, end: number, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
216 await command.createEditionTasks({
217 videoId: videoUUID,
218 tasks: [
219 {
220 name: 'cut',
221 options: {
222 start,
223 end
224 }
225 }
226 ],
227 expectedStatus
228 })
229 }
230
231 it('Should fail with bad start/end', async function () {
232 const invalid = [
233 'tintin',
234 -1,
235 undefined
236 ]
237
238 for (const value of invalid) {
239 await cut(value as any, undefined)
240 await cut(undefined, value as any)
241 }
242 })
243
244 it('Should fail with the same start/end', async function () {
245 await cut(2, 2)
246 })
247
248 it('Should fail with inconsistents start/end', async function () {
249 await cut(2, 1)
250 })
251
252 it('Should fail without start and end', async function () {
253 await cut(undefined, undefined)
254 })
255
256 it('Should succeed with the correct params', async function () {
257 this.timeout(120000)
258
259 await cut(0, 2, HttpStatusCode.NO_CONTENT_204)
260
261 await waitJobs([ server ])
262 })
263 })
264
265 describe('Watermark task', function () {
266
267 async function addWatermark (file: string, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
268 await command.createEditionTasks({
269 videoId: videoUUID,
270 tasks: [
271 {
272 name: 'add-watermark',
273 options: {
274 file
275 }
276 }
277 ],
278 expectedStatus
279 })
280 }
281
282 it('Should fail without waterkmark', async function () {
283 await addWatermark(undefined)
284 })
285
286 it('Should fail with an invalid watermark', async function () {
287 await addWatermark('video_short.mp4')
288 })
289
290 it('Should succeed with the correct params', async function () {
291 this.timeout(120000)
292
293 await addWatermark('thumbnail.jpg', HttpStatusCode.NO_CONTENT_204)
294
295 await waitJobs([ server ])
296 })
297 })
298
299 describe('Intro/Outro task', function () {
300
301 async function addIntroOutro (type: 'add-intro' | 'add-outro', file: string, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
302 await command.createEditionTasks({
303 videoId: videoUUID,
304 tasks: [
305 {
306 name: type,
307 options: {
308 file
309 }
310 }
311 ],
312 expectedStatus
313 })
314 }
315
316 it('Should fail without file', async function () {
317 await addIntroOutro('add-intro', undefined)
318 await addIntroOutro('add-outro', undefined)
319 })
320
321 it('Should fail with an invalid file', async function () {
322 await addIntroOutro('add-intro', 'thumbnail.jpg')
323 await addIntroOutro('add-outro', 'thumbnail.jpg')
324 })
325
326 it('Should fail with a file that does not contain video stream', async function () {
327 await addIntroOutro('add-intro', 'sample.ogg')
328 await addIntroOutro('add-outro', 'sample.ogg')
329
330 })
331
332 it('Should succeed with the correct params', async function () {
333 this.timeout(120000)
334
335 await addIntroOutro('add-intro', 'video_very_short_240p.mp4', HttpStatusCode.NO_CONTENT_204)
336 await waitJobs([ server ])
337
338 await addIntroOutro('add-outro', 'video_very_short_240p.mp4', HttpStatusCode.NO_CONTENT_204)
339 await waitJobs([ server ])
340 })
341
342 it('Should check total quota when creating the task', async function () {
343 this.timeout(120000)
344
345 const user = await server.users.create({ username: 'user_quota_1' })
346 const token = await server.login.getAccessToken('user_quota_1')
347 const { uuid } = await server.videos.quickUpload({ token, name: 'video_quota_1', fixture: 'video_short.mp4' })
348
349 const addIntroOutroByUser = (type: 'add-intro' | 'add-outro', expectedStatus: HttpStatusCode) => {
350 return command.createEditionTasks({
351 token,
352 videoId: uuid,
353 tasks: [
354 {
355 name: type,
356 options: {
357 file: 'video_short.mp4'
358 }
359 }
360 ],
361 expectedStatus
362 })
363 }
364
365 await waitJobs([ server ])
366
367 const { videoQuotaUsed } = await server.users.getMyQuotaUsed({ token })
368 await server.users.update({ userId: user.id, videoQuota: Math.round(videoQuotaUsed * 2.5) })
369
370 // Still valid
371 await addIntroOutroByUser('add-intro', HttpStatusCode.NO_CONTENT_204)
372
373 await waitJobs([ server ])
374
375 // Too much quota
376 await addIntroOutroByUser('add-intro', HttpStatusCode.PAYLOAD_TOO_LARGE_413)
377 await addIntroOutroByUser('add-outro', HttpStatusCode.PAYLOAD_TOO_LARGE_413)
378 })
379 })
380 })
381
382 after(async function () {
383 await cleanupTests([ server ])
384 })
385})
diff --git a/server/tests/api/index.ts b/server/tests/api/index.ts
index 19301c0b9..61352a134 100644
--- a/server/tests/api/index.ts
+++ b/server/tests/api/index.ts
@@ -7,5 +7,6 @@ import './notifications'
7import './redundancy' 7import './redundancy'
8import './search' 8import './search'
9import './server' 9import './server'
10import './transcoding'
10import './users' 11import './users'
11import './videos' 12import './videos'
diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts
index 3f9355d2d..d756a02c1 100644
--- a/server/tests/api/live/live.ts
+++ b/server/tests/api/live/live.ts
@@ -3,7 +3,7 @@
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { basename, join } from 'path' 5import { basename, join } from 'path'
6import { ffprobePromise, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils' 6import { ffprobePromise, getVideoStream } from '@server/helpers/ffmpeg'
7import { checkLiveCleanupAfterSave, checkLiveSegmentHash, checkResolutionsInMasterPlaylist, testImage } from '@server/tests/shared' 7import { checkLiveCleanupAfterSave, checkLiveSegmentHash, checkResolutionsInMasterPlaylist, testImage } from '@server/tests/shared'
8import { wait } from '@shared/core-utils' 8import { wait } from '@shared/core-utils'
9import { 9import {
@@ -562,7 +562,7 @@ describe('Test live', function () {
562 const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename)) 562 const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename))
563 563
564 const probe = await ffprobePromise(segmentPath) 564 const probe = await ffprobePromise(segmentPath)
565 const videoStream = await getVideoStreamFromFile(segmentPath, probe) 565 const videoStream = await getVideoStream(segmentPath, probe)
566 566
567 expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[videoStream.height]) 567 expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[videoStream.height])
568 expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[videoStream.height]) 568 expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[videoStream.height])
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..cd4c053d2 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
@@ -24,12 +26,16 @@ describe('Test channels search', function () {
24 26
25 const servers = await Promise.all([ 27 const servers = await Promise.all([
26 createSingleServer(1), 28 createSingleServer(1),
27 createSingleServer(2, { transcoding: { enabled: false } }) 29 createSingleServer(2)
28 ]) 30 ])
29 server = servers[0] 31 server = servers[0]
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)
37
38 await servers[1].config.disableTranscoding()
33 39
34 { 40 {
35 await server.users.create({ username: 'user1' }) 41 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..b18d40b2a 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(2, '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(2, '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..d9f12d316 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
@@ -27,13 +29,17 @@ describe('Test playlists search', function () {
27 29
28 const servers = await Promise.all([ 30 const servers = await Promise.all([
29 createSingleServer(1), 31 createSingleServer(1),
30 createSingleServer(2, { transcoding: { enabled: false } }) 32 createSingleServer(2)
31 ]) 33 ])
32 server = servers[0] 34 server = servers[0]
33 remoteServer = servers[1] 35 remoteServer = servers[1]
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 ])
41
42 await servers[1].config.disableTranscoding()
37 43
38 { 44 {
39 const videoId = (await server.videos.upload()).uuid 45 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/config.ts b/server/tests/api/server/config.ts
index 2356f701c..565b2953a 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -97,6 +97,8 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
97 expect(data.live.transcoding.resolutions['1440p']).to.be.false 97 expect(data.live.transcoding.resolutions['1440p']).to.be.false
98 expect(data.live.transcoding.resolutions['2160p']).to.be.false 98 expect(data.live.transcoding.resolutions['2160p']).to.be.false
99 99
100 expect(data.videoEditor.enabled).to.be.false
101
100 expect(data.import.videos.concurrency).to.equal(2) 102 expect(data.import.videos.concurrency).to.equal(2)
101 expect(data.import.videos.http.enabled).to.be.true 103 expect(data.import.videos.http.enabled).to.be.true
102 expect(data.import.videos.torrent.enabled).to.be.true 104 expect(data.import.videos.torrent.enabled).to.be.true
@@ -197,6 +199,8 @@ function checkUpdatedConfig (data: CustomConfig) {
197 expect(data.live.transcoding.resolutions['1080p']).to.be.true 199 expect(data.live.transcoding.resolutions['1080p']).to.be.true
198 expect(data.live.transcoding.resolutions['2160p']).to.be.true 200 expect(data.live.transcoding.resolutions['2160p']).to.be.true
199 201
202 expect(data.videoEditor.enabled).to.be.true
203
200 expect(data.import.videos.concurrency).to.equal(4) 204 expect(data.import.videos.concurrency).to.equal(4)
201 expect(data.import.videos.http.enabled).to.be.false 205 expect(data.import.videos.http.enabled).to.be.false
202 expect(data.import.videos.torrent.enabled).to.be.false 206 expect(data.import.videos.torrent.enabled).to.be.false
@@ -341,6 +345,9 @@ const newCustomConfig: CustomConfig = {
341 } 345 }
342 } 346 }
343 }, 347 },
348 videoEditor: {
349 enabled: true
350 },
344 import: { 351 import: {
345 videos: { 352 videos: {
346 concurrency: 4, 353 concurrency: 4,
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/server/stats.ts b/server/tests/api/server/stats.ts
index f0334532b..2296c0cb9 100644
--- a/server/tests/api/server/stats.ts
+++ b/server/tests/api/server/stats.ts
@@ -230,13 +230,7 @@ describe('Test stats (excluding redundancy)', function () {
230 it('Should have the correct AP stats', async function () { 230 it('Should have the correct AP stats', async function () {
231 this.timeout(60000) 231 this.timeout(60000)
232 232
233 await servers[0].config.updateCustomSubConfig({ 233 await servers[0].config.disableTranscoding()
234 newConfig: {
235 transcoding: {
236 enabled: false
237 }
238 }
239 })
240 234
241 const first = await servers[1].stats.get() 235 const first = await servers[1].stats.get()
242 236
diff --git a/server/tests/api/videos/audio-only.ts b/server/tests/api/transcoding/audio-only.ts
index e58360ffe..e7e73d382 100644
--- a/server/tests/api/videos/audio-only.ts
+++ b/server/tests/api/transcoding/audio-only.ts
@@ -2,7 +2,7 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { getAudioStream, getVideoStreamSize } from '@server/helpers/ffprobe-utils' 5import { getAudioStream, getVideoStreamDimensionsInfo } from '@server/helpers/ffmpeg'
6import { 6import {
7 cleanupTests, 7 cleanupTests,
8 createMultipleServers, 8 createMultipleServers,
@@ -91,9 +91,8 @@ describe('Test audio only video transcoding', function () {
91 expect(audioStream['codec_name']).to.be.equal('aac') 91 expect(audioStream['codec_name']).to.be.equal('aac')
92 expect(audioStream['bit_rate']).to.be.at.most(384 * 8000) 92 expect(audioStream['bit_rate']).to.be.at.most(384 * 8000)
93 93
94 const size = await getVideoStreamSize(path) 94 const size = await getVideoStreamDimensionsInfo(path)
95 expect(size.height).to.equal(0) 95 expect(size).to.not.exist
96 expect(size.width).to.equal(0)
97 } 96 }
98 }) 97 })
99 98
diff --git a/server/tests/api/videos/video-create-transcoding.ts b/server/tests/api/transcoding/create-transcoding.ts
index a4defdf51..a4defdf51 100644
--- a/server/tests/api/videos/video-create-transcoding.ts
+++ b/server/tests/api/transcoding/create-transcoding.ts
diff --git a/server/tests/api/videos/video-hls.ts b/server/tests/api/transcoding/hls.ts
index 218ec08ae..218ec08ae 100644
--- a/server/tests/api/videos/video-hls.ts
+++ b/server/tests/api/transcoding/hls.ts
diff --git a/server/tests/api/transcoding/index.ts b/server/tests/api/transcoding/index.ts
new file mode 100644
index 000000000..8a0a1d787
--- /dev/null
+++ b/server/tests/api/transcoding/index.ts
@@ -0,0 +1,5 @@
1export * from './audio-only'
2export * from './create-transcoding'
3export * from './hls'
4export * from './transcoder'
5export * from './video-editor'
diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/transcoding/transcoder.ts
index d24a8f4e1..245c4c012 100644
--- a/server/tests/api/videos/video-transcoder.ts
+++ b/server/tests/api/transcoding/transcoder.ts
@@ -3,10 +3,17 @@
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { omit } from 'lodash' 5import { omit } from 'lodash'
6import { canDoQuickTranscode } from '@server/helpers/ffprobe-utils' 6import { canDoQuickTranscode } from '@server/helpers/ffmpeg'
7import { generateHighBitrateVideo, generateVideoWithFramerate } from '@server/tests/shared' 7import { generateHighBitrateVideo, generateVideoWithFramerate, getAllFiles } from '@server/tests/shared'
8import { buildAbsoluteFixturePath, getMaxBitrate, getMinLimitBitrate } from '@shared/core-utils' 8import { buildAbsoluteFixturePath, getMaxBitrate, getMinLimitBitrate } from '@shared/core-utils'
9import { getAudioStream, getMetadataFromFile, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@shared/extra-utils' 9import {
10 getAudioStream,
11 buildFileMetadata,
12 getVideoStreamBitrate,
13 getVideoStreamFPS,
14 getVideoStreamDimensionsInfo,
15 hasAudioStream
16} from '@shared/extra-utils'
10import { HttpStatusCode, VideoState } from '@shared/models' 17import { HttpStatusCode, VideoState } from '@shared/models'
11import { 18import {
12 cleanupTests, 19 cleanupTests,
@@ -287,8 +294,7 @@ describe('Test video transcoding', function () {
287 const file = videoDetails.files.find(f => f.resolution.id === 240) 294 const file = videoDetails.files.find(f => f.resolution.id === 240)
288 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 295 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
289 296
290 const probe = await getAudioStream(path) 297 expect(await hasAudioStream(path)).to.be.false
291 expect(probe).to.not.have.property('audioStream')
292 } 298 }
293 }) 299 })
294 300
@@ -478,14 +484,14 @@ describe('Test video transcoding', function () {
478 for (const resolution of [ 144, 240, 360, 480 ]) { 484 for (const resolution of [ 144, 240, 360, 480 ]) {
479 const file = videoDetails.files.find(f => f.resolution.id === resolution) 485 const file = videoDetails.files.find(f => f.resolution.id === resolution)
480 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 486 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
481 const fps = await getVideoFileFPS(path) 487 const fps = await getVideoStreamFPS(path)
482 488
483 expect(fps).to.be.below(31) 489 expect(fps).to.be.below(31)
484 } 490 }
485 491
486 const file = videoDetails.files.find(f => f.resolution.id === 720) 492 const file = videoDetails.files.find(f => f.resolution.id === 720)
487 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 493 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
488 const fps = await getVideoFileFPS(path) 494 const fps = await getVideoStreamFPS(path)
489 495
490 expect(fps).to.be.above(58).and.below(62) 496 expect(fps).to.be.above(58).and.below(62)
491 } 497 }
@@ -499,7 +505,7 @@ describe('Test video transcoding', function () {
499 { 505 {
500 tempFixturePath = await generateVideoWithFramerate(59) 506 tempFixturePath = await generateVideoWithFramerate(59)
501 507
502 const fps = await getVideoFileFPS(tempFixturePath) 508 const fps = await getVideoStreamFPS(tempFixturePath)
503 expect(fps).to.be.equal(59) 509 expect(fps).to.be.equal(59)
504 } 510 }
505 511
@@ -522,14 +528,14 @@ describe('Test video transcoding', function () {
522 { 528 {
523 const file = video.files.find(f => f.resolution.id === 240) 529 const file = video.files.find(f => f.resolution.id === 240)
524 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 530 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
525 const fps = await getVideoFileFPS(path) 531 const fps = await getVideoStreamFPS(path)
526 expect(fps).to.be.equal(25) 532 expect(fps).to.be.equal(25)
527 } 533 }
528 534
529 { 535 {
530 const file = video.files.find(f => f.resolution.id === 720) 536 const file = video.files.find(f => f.resolution.id === 720)
531 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 537 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
532 const fps = await getVideoFileFPS(path) 538 const fps = await getVideoStreamFPS(path)
533 expect(fps).to.be.equal(59) 539 expect(fps).to.be.equal(59)
534 } 540 }
535 } 541 }
@@ -563,9 +569,9 @@ describe('Test video transcoding', function () {
563 const file = video.files.find(f => f.resolution.id === resolution) 569 const file = video.files.find(f => f.resolution.id === resolution)
564 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 570 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
565 571
566 const bitrate = await getVideoFileBitrate(path) 572 const bitrate = await getVideoStreamBitrate(path)
567 const fps = await getVideoFileFPS(path) 573 const fps = await getVideoStreamFPS(path)
568 const dataResolution = await getVideoFileResolution(path) 574 const dataResolution = await getVideoStreamDimensionsInfo(path)
569 575
570 expect(resolution).to.equal(resolution) 576 expect(resolution).to.equal(resolution)
571 577
@@ -613,7 +619,7 @@ describe('Test video transcoding', function () {
613 const file = video.files.find(f => f.resolution.id === r) 619 const file = video.files.find(f => f.resolution.id === r)
614 620
615 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 621 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
616 const bitrate = await getVideoFileBitrate(path) 622 const bitrate = await getVideoStreamBitrate(path)
617 623
618 const inputBitrate = 60_000 624 const inputBitrate = 60_000
619 const limit = getMinLimitBitrate({ fps: 10, ratio: 1, resolution: r }) 625 const limit = getMinLimitBitrate({ fps: 10, ratio: 1, resolution: r })
@@ -637,7 +643,7 @@ describe('Test video transcoding', function () {
637 const video = await servers[1].videos.get({ id: videoUUID }) 643 const video = await servers[1].videos.get({ id: videoUUID })
638 const file = video.files.find(f => f.resolution.id === 240) 644 const file = video.files.find(f => f.resolution.id === 240)
639 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 645 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
640 const metadata = await getMetadataFromFile(path) 646 const metadata = await buildFileMetadata(path)
641 647
642 // expected format properties 648 // expected format properties
643 for (const p of [ 649 for (const p of [
@@ -668,8 +674,7 @@ describe('Test video transcoding', function () {
668 for (const server of servers) { 674 for (const server of servers) {
669 const videoDetails = await server.videos.get({ id: videoUUID }) 675 const videoDetails = await server.videos.get({ id: videoUUID })
670 676
671 const videoFiles = videoDetails.files 677 const videoFiles = getAllFiles(videoDetails)
672 .concat(videoDetails.streamingPlaylists[0].files)
673 expect(videoFiles).to.have.lengthOf(10) 678 expect(videoFiles).to.have.lengthOf(10)
674 679
675 for (const file of videoFiles) { 680 for (const file of videoFiles) {
diff --git a/server/tests/api/transcoding/video-editor.ts b/server/tests/api/transcoding/video-editor.ts
new file mode 100644
index 000000000..a9b6950cc
--- /dev/null
+++ b/server/tests/api/transcoding/video-editor.ts
@@ -0,0 +1,368 @@
1import { expect } from 'chai'
2import { expectStartWith, getAllFiles } from '@server/tests/shared'
3import { areObjectStorageTestsDisabled } from '@shared/core-utils'
4import { VideoEditorTask } from '@shared/models'
5import {
6 cleanupTests,
7 createMultipleServers,
8 doubleFollow,
9 ObjectStorageCommand,
10 PeerTubeServer,
11 setAccessTokensToServers,
12 setDefaultVideoChannel,
13 VideoEditorCommand,
14 waitJobs
15} from '@shared/server-commands'
16
17describe('Test video editor', function () {
18 let servers: PeerTubeServer[] = []
19 let videoUUID: string
20
21 async function checkDuration (server: PeerTubeServer, duration: number) {
22 const video = await server.videos.get({ id: videoUUID })
23
24 expect(video.duration).to.be.approximately(duration, 1)
25
26 for (const file of video.files) {
27 const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
28
29 for (const stream of metadata.streams) {
30 expect(Math.round(stream.duration)).to.be.approximately(duration, 1)
31 }
32 }
33 }
34
35 async function renewVideo (fixture = 'video_short.webm') {
36 const video = await servers[0].videos.quickUpload({ name: 'video', fixture })
37 videoUUID = video.uuid
38
39 await waitJobs(servers)
40 }
41
42 async function createTasks (tasks: VideoEditorTask[]) {
43 await servers[0].videoEditor.createEditionTasks({ videoId: videoUUID, tasks })
44 await waitJobs(servers)
45 }
46
47 before(async function () {
48 this.timeout(120_000)
49
50 servers = await createMultipleServers(2)
51
52 await setAccessTokensToServers(servers)
53 await setDefaultVideoChannel(servers)
54
55 await doubleFollow(servers[0], servers[1])
56
57 await servers[0].config.enableMinimumTranscoding()
58
59 await servers[0].config.updateExistingSubConfig({
60 newConfig: {
61 videoEditor: {
62 enabled: true
63 }
64 }
65 })
66 })
67
68 describe('Cutting', function () {
69
70 it('Should cut the beginning of the video', async function () {
71 this.timeout(120_000)
72
73 await renewVideo()
74 await waitJobs(servers)
75
76 const beforeTasks = new Date()
77
78 await createTasks([
79 {
80 name: 'cut',
81 options: {
82 start: 2
83 }
84 }
85 ])
86
87 for (const server of servers) {
88 await checkDuration(server, 3)
89
90 const video = await server.videos.get({ id: videoUUID })
91 expect(new Date(video.publishedAt)).to.be.below(beforeTasks)
92 }
93 })
94
95 it('Should cut the end of the video', async function () {
96 this.timeout(120_000)
97 await renewVideo()
98
99 await createTasks([
100 {
101 name: 'cut',
102 options: {
103 end: 2
104 }
105 }
106 ])
107
108 for (const server of servers) {
109 await checkDuration(server, 2)
110 }
111 })
112
113 it('Should cut start/end of the video', async function () {
114 this.timeout(120_000)
115 await renewVideo('video_short1.webm') // 10 seconds video duration
116
117 await createTasks([
118 {
119 name: 'cut',
120 options: {
121 start: 2,
122 end: 6
123 }
124 }
125 ])
126
127 for (const server of servers) {
128 await checkDuration(server, 4)
129 }
130 })
131 })
132
133 describe('Intro/Outro', function () {
134
135 it('Should add an intro', async function () {
136 this.timeout(120_000)
137 await renewVideo()
138
139 await createTasks([
140 {
141 name: 'add-intro',
142 options: {
143 file: 'video_short.webm'
144 }
145 }
146 ])
147
148 for (const server of servers) {
149 await checkDuration(server, 10)
150 }
151 })
152
153 it('Should add an outro', async function () {
154 this.timeout(120_000)
155 await renewVideo()
156
157 await createTasks([
158 {
159 name: 'add-outro',
160 options: {
161 file: 'video_very_short_240p.mp4'
162 }
163 }
164 ])
165
166 for (const server of servers) {
167 await checkDuration(server, 7)
168 }
169 })
170
171 it('Should add an intro/outro', async function () {
172 this.timeout(120_000)
173 await renewVideo()
174
175 await createTasks([
176 {
177 name: 'add-intro',
178 options: {
179 file: 'video_very_short_240p.mp4'
180 }
181 },
182 {
183 name: 'add-outro',
184 options: {
185 // Different frame rate
186 file: 'video_short2.webm'
187 }
188 }
189 ])
190
191 for (const server of servers) {
192 await checkDuration(server, 12)
193 }
194 })
195
196 it('Should add an intro to a video without audio', async function () {
197 this.timeout(120_000)
198 await renewVideo('video_short_no_audio.mp4')
199
200 await createTasks([
201 {
202 name: 'add-intro',
203 options: {
204 file: 'video_very_short_240p.mp4'
205 }
206 }
207 ])
208
209 for (const server of servers) {
210 await checkDuration(server, 7)
211 }
212 })
213
214 it('Should add an outro without audio to a video with audio', async function () {
215 this.timeout(120_000)
216 await renewVideo()
217
218 await createTasks([
219 {
220 name: 'add-outro',
221 options: {
222 file: 'video_short_no_audio.mp4'
223 }
224 }
225 ])
226
227 for (const server of servers) {
228 await checkDuration(server, 10)
229 }
230 })
231
232 it('Should add an outro without audio to a video with audio', async function () {
233 this.timeout(120_000)
234 await renewVideo('video_short_no_audio.mp4')
235
236 await createTasks([
237 {
238 name: 'add-outro',
239 options: {
240 file: 'video_short_no_audio.mp4'
241 }
242 }
243 ])
244
245 for (const server of servers) {
246 await checkDuration(server, 10)
247 }
248 })
249 })
250
251 describe('Watermark', function () {
252
253 it('Should add a watermark to the video', async function () {
254 this.timeout(120_000)
255 await renewVideo()
256
257 const video = await servers[0].videos.get({ id: videoUUID })
258 const oldFileUrls = getAllFiles(video).map(f => f.fileUrl)
259
260 await createTasks([
261 {
262 name: 'add-watermark',
263 options: {
264 file: 'thumbnail.png'
265 }
266 }
267 ])
268
269 for (const server of servers) {
270 const video = await server.videos.get({ id: videoUUID })
271 const fileUrls = getAllFiles(video).map(f => f.fileUrl)
272
273 for (const oldUrl of oldFileUrls) {
274 expect(fileUrls).to.not.include(oldUrl)
275 }
276 }
277 })
278 })
279
280 describe('Complex tasks', function () {
281 it('Should run a complex task', async function () {
282 this.timeout(240_000)
283 await renewVideo()
284
285 await createTasks(VideoEditorCommand.getComplexTask())
286
287 for (const server of servers) {
288 await checkDuration(server, 9)
289 }
290 })
291 })
292
293 describe('HLS only video edition', function () {
294
295 before(async function () {
296 // Disable webtorrent
297 await servers[0].config.updateExistingSubConfig({
298 newConfig: {
299 transcoding: {
300 webtorrent: {
301 enabled: false
302 }
303 }
304 }
305 })
306 })
307
308 it('Should run a complex task on HLS only video', async function () {
309 this.timeout(240_000)
310 await renewVideo()
311
312 await createTasks(VideoEditorCommand.getComplexTask())
313
314 for (const server of servers) {
315 const video = await server.videos.get({ id: videoUUID })
316 expect(video.files).to.have.lengthOf(0)
317
318 await checkDuration(server, 9)
319 }
320 })
321 })
322
323 describe('Object storage video edition', function () {
324 if (areObjectStorageTestsDisabled()) return
325
326 before(async function () {
327 await ObjectStorageCommand.prepareDefaultBuckets()
328
329 await servers[0].kill()
330 await servers[0].run(ObjectStorageCommand.getDefaultConfig())
331
332 await servers[0].config.enableMinimumTranscoding()
333 })
334
335 it('Should run a complex task on a video in object storage', async function () {
336 this.timeout(240_000)
337 await renewVideo()
338
339 const video = await servers[0].videos.get({ id: videoUUID })
340 const oldFileUrls = getAllFiles(video).map(f => f.fileUrl)
341
342 await createTasks(VideoEditorCommand.getComplexTask())
343
344 for (const server of servers) {
345 const video = await server.videos.get({ id: videoUUID })
346 const files = getAllFiles(video)
347
348 for (const f of files) {
349 expect(oldFileUrls).to.not.include(f.fileUrl)
350 }
351
352 for (const webtorrentFile of video.files) {
353 expectStartWith(webtorrentFile.fileUrl, ObjectStorageCommand.getWebTorrentBaseUrl())
354 }
355
356 for (const hlsFile of video.streamingPlaylists[0].files) {
357 expectStartWith(hlsFile.fileUrl, ObjectStorageCommand.getPlaylistBaseUrl())
358 }
359
360 await checkDuration(server, 9)
361 }
362 })
363 })
364
365 after(async function () {
366 await cleanupTests(servers)
367 })
368})
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/index.ts b/server/tests/api/videos/index.ts
index bedb9b8b6..7dc826353 100644
--- a/server/tests/api/videos/index.ts
+++ b/server/tests/api/videos/index.ts
@@ -1,4 +1,3 @@
1import './audio-only'
2import './multiple-servers' 1import './multiple-servers'
3import './resumable-upload' 2import './resumable-upload'
4import './single-server' 3import './single-server'
@@ -6,17 +5,14 @@ import './video-captions'
6import './video-change-ownership' 5import './video-change-ownership'
7import './video-channels' 6import './video-channels'
8import './video-comments' 7import './video-comments'
9import './video-create-transcoding'
10import './video-description' 8import './video-description'
11import './video-files' 9import './video-files'
12import './video-hls'
13import './video-imports' 10import './video-imports'
14import './video-nsfw' 11import './video-nsfw'
15import './video-playlists' 12import './video-playlists'
16import './video-playlist-thumbnails' 13import './video-playlist-thumbnails'
17import './video-privacy' 14import './video-privacy'
18import './video-schedule-update' 15import './video-schedule-update'
19import './video-transcoder'
20import './videos-common-filters' 16import './videos-common-filters'
21import './videos-history' 17import './videos-history'
22import './videos-overview' 18import './videos-overview'
diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts
index ecdd36613..05ccee8ad 100644
--- a/server/tests/api/videos/multiple-servers.ts
+++ b/server/tests/api/videos/multiple-servers.ts
@@ -17,8 +17,11 @@ import {
17 cleanupTests, 17 cleanupTests,
18 createMultipleServers, 18 createMultipleServers,
19 doubleFollow, 19 doubleFollow,
20 makeGetRequest,
20 PeerTubeServer, 21 PeerTubeServer,
21 setAccessTokensToServers, 22 setAccessTokensToServers,
23 setDefaultAccountAvatar,
24 setDefaultChannelAvatar,
22 waitJobs, 25 waitJobs,
23 webtorrentAdd 26 webtorrentAdd
24} from '@shared/server-commands' 27} from '@shared/server-commands'
@@ -46,6 +49,9 @@ describe('Test multiple servers', function () {
46 description: 'super channel' 49 description: 'super channel'
47 } 50 }
48 await servers[0].channels.create({ attributes: videoChannel }) 51 await servers[0].channels.create({ attributes: videoChannel })
52 await setDefaultChannelAvatar(servers[0], videoChannel.name)
53 await setDefaultAccountAvatar(servers)
54
49 const { data } = await servers[0].channels.list({ start: 0, count: 1 }) 55 const { data } = await servers[0].channels.list({ start: 0, count: 1 })
50 videoChannelId = data[0].id 56 videoChannelId = data[0].id
51 } 57 }
@@ -133,6 +139,22 @@ describe('Test multiple servers', function () {
133 139
134 await completeVideoCheck(server, video, checkAttributes) 140 await completeVideoCheck(server, video, checkAttributes)
135 publishedAt = video.publishedAt as string 141 publishedAt = video.publishedAt as string
142
143 expect(video.channel.avatars).to.have.lengthOf(2)
144 expect(video.account.avatars).to.have.lengthOf(2)
145
146 for (const image of [ ...video.channel.avatars, ...video.account.avatars ]) {
147 expect(image.createdAt).to.exist
148 expect(image.updatedAt).to.exist
149 expect(image.width).to.be.above(20).and.below(1000)
150 expect(image.path).to.exist
151
152 await makeGetRequest({
153 url: server.url,
154 path: image.path,
155 expectedStatus: HttpStatusCode.OK_200
156 })
157 }
136 } 158 }
137 }) 159 })
138 160
@@ -207,7 +229,7 @@ describe('Test multiple servers', function () {
207 }, 229 },
208 { 230 {
209 resolution: 720, 231 resolution: 720,
210 size: 788000 232 size: 750000
211 } 233 }
212 ], 234 ],
213 thumbnailfile: 'thumbnail', 235 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-playlist-thumbnails.ts b/server/tests/api/videos/video-playlist-thumbnails.ts
index 5fdb0fc03..3944dc344 100644
--- a/server/tests/api/videos/video-playlist-thumbnails.ts
+++ b/server/tests/api/videos/video-playlist-thumbnails.ts
@@ -45,12 +45,16 @@ describe('Playlist thumbnail', function () {
45 before(async function () { 45 before(async function () {
46 this.timeout(120000) 46 this.timeout(120000)
47 47
48 servers = await createMultipleServers(2, { transcoding: { enabled: false } }) 48 servers = await createMultipleServers(2)
49 49
50 // Get the access tokens 50 // Get the access tokens
51 await setAccessTokensToServers(servers) 51 await setAccessTokensToServers(servers)
52 await setDefaultVideoChannel(servers) 52 await setDefaultVideoChannel(servers)
53 53
54 for (const server of servers) {
55 await server.config.disableTranscoding()
56 }
57
54 // Server 1 and server 2 follow each other 58 // Server 1 and server 2 follow each other
55 await doubleFollow(servers[0], servers[1]) 59 await doubleFollow(servers[0], servers[1])
56 60
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts
index 34327334f..c33a63df0 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'
@@ -74,11 +75,16 @@ describe('Test video playlists', function () {
74 before(async function () { 75 before(async function () {
75 this.timeout(120000) 76 this.timeout(120000)
76 77
77 servers = await createMultipleServers(3, { transcoding: { enabled: false } }) 78 servers = await createMultipleServers(3)
78 79
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)
84
85 for (const server of servers) {
86 await server.config.disableTranscoding()
87 }
82 88
83 // Server 1 and server 2 follow each other 89 // Server 1 and server 2 follow each other
84 await doubleFollow(servers[0], servers[1]) 90 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/cli/update-host.ts b/server/tests/cli/update-host.ts
index da89ff153..7c49efd20 100644
--- a/server/tests/cli/update-host.ts
+++ b/server/tests/cli/update-host.ts
@@ -12,6 +12,7 @@ import {
12 setAccessTokensToServers, 12 setAccessTokensToServers,
13 waitJobs 13 waitJobs
14} from '@shared/server-commands' 14} from '@shared/server-commands'
15import { getAllFiles } from '../shared'
15 16
16describe('Test update host scripts', function () { 17describe('Test update host scripts', function () {
17 let server: PeerTubeServer 18 let server: PeerTubeServer
@@ -108,7 +109,7 @@ describe('Test update host scripts', function () {
108 109
109 for (const video of data) { 110 for (const video of data) {
110 const videoDetails = await server.videos.get({ id: video.id }) 111 const videoDetails = await server.videos.get({ id: video.id })
111 const files = videoDetails.files.concat(videoDetails.streamingPlaylists[0].files) 112 const files = getAllFiles(videoDetails)
112 113
113 expect(files).to.have.lengthOf(8) 114 expect(files).to.have.lengthOf(8)
114 115
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/fixtures/peertube-plugin-test/main.js b/server/tests/fixtures/peertube-plugin-test/main.js
index 90951d611..7715ab6e8 100644
--- a/server/tests/fixtures/peertube-plugin-test/main.js
+++ b/server/tests/fixtures/peertube-plugin-test/main.js
@@ -45,6 +45,16 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
45 }) 45 })
46 46
47 registerHook({ 47 registerHook({
48 target: 'filter:api.video-playlist.videos.list.params',
49 handler: obj => addToCount(obj)
50 })
51
52 registerHook({
53 target: 'filter:api.video-playlist.videos.list.result',
54 handler: obj => addToTotal(obj)
55 })
56
57 registerHook({
48 target: 'filter:api.accounts.videos.list.params', 58 target: 'filter:api.accounts.videos.list.params',
49 handler: obj => addToCount(obj) 59 handler: obj => addToCount(obj)
50 }) 60 })
diff --git a/server/tests/plugins/filter-hooks.ts b/server/tests/plugins/filter-hooks.ts
index 52ba396e5..e0f25ca26 100644
--- a/server/tests/plugins/filter-hooks.ts
+++ b/server/tests/plugins/filter-hooks.ts
@@ -22,6 +22,7 @@ describe('Test plugin filter hooks', function () {
22 let servers: PeerTubeServer[] 22 let servers: PeerTubeServer[]
23 let videoUUID: string 23 let videoUUID: string
24 let threadId: number 24 let threadId: number
25 let videoPlaylistUUID: string
25 26
26 before(async function () { 27 before(async function () {
27 this.timeout(60000) 28 this.timeout(60000)
@@ -33,9 +34,20 @@ describe('Test plugin filter hooks', function () {
33 34
34 await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath() }) 35 await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath() })
35 await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-filter-translations') }) 36 await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-filter-translations') })
37 {
38 ({ uuid: videoPlaylistUUID } = await servers[0].playlists.create({
39 attributes: {
40 displayName: 'my super playlist',
41 privacy: VideoPlaylistPrivacy.PUBLIC,
42 description: 'my super description',
43 videoChannelId: servers[0].store.channel.id
44 }
45 }))
46 }
36 47
37 for (let i = 0; i < 10; i++) { 48 for (let i = 0; i < 10; i++) {
38 await servers[0].videos.upload({ attributes: { name: 'default video ' + i } }) 49 const video = await servers[0].videos.upload({ attributes: { name: 'default video ' + i } })
50 await servers[0].playlists.addElement({ playlistId: videoPlaylistUUID, attributes: { videoId: video.id } })
39 } 51 }
40 52
41 const { data } = await servers[0].videos.list() 53 const { data } = await servers[0].videos.list()
@@ -69,6 +81,26 @@ describe('Test plugin filter hooks', function () {
69 expect(total).to.equal(11) 81 expect(total).to.equal(11)
70 }) 82 })
71 83
84 it('Should run filter:api.video-playlist.videos.list.params', async function () {
85 const { data } = await servers[0].playlists.listVideos({
86 count: 2,
87 playlistId: videoPlaylistUUID
88 })
89
90 // 1 plugin do +1 to the count parameter
91 expect(data).to.have.lengthOf(3)
92 })
93
94 it('Should run filter:api.video-playlist.videos.list.result', async function () {
95 const { total } = await servers[0].playlists.listVideos({
96 count: 0,
97 playlistId: videoPlaylistUUID
98 })
99
100 // Plugin do +1 to the total result
101 expect(total).to.equal(11)
102 })
103
72 it('Should run filter:api.accounts.videos.list.params', async function () { 104 it('Should run filter:api.accounts.videos.list.params', async function () {
73 const { data } = await servers[0].videos.listByAccount({ handle: 'root', start: 0, count: 2 }) 105 const { data } = await servers[0].videos.listByAccount({ handle: 'root', start: 0, count: 2 })
74 106
@@ -410,13 +442,7 @@ describe('Test plugin filter hooks', function () {
410 before(async function () { 442 before(async function () {
411 this.timeout(60000) 443 this.timeout(60000)
412 444
413 await servers[0].config.updateCustomSubConfig({ 445 await servers[0].config.disableTranscoding()
414 newConfig: {
415 transcoding: {
416 enabled: false
417 }
418 }
419 })
420 446
421 for (const name of [ 'bad embed', 'good embed' ]) { 447 for (const name of [ 'bad embed', 'good embed' ]) {
422 { 448 {
diff --git a/server/tests/plugins/plugin-transcoding.ts b/server/tests/plugins/plugin-transcoding.ts
index 5ab686472..49569f1fa 100644
--- a/server/tests/plugins/plugin-transcoding.ts
+++ b/server/tests/plugins/plugin-transcoding.ts
@@ -2,7 +2,8 @@
2 2
3import 'mocha' 3import 'mocha'
4import { expect } from 'chai' 4import { expect } from 'chai'
5import { getAudioStream, getVideoFileFPS, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils' 5import { getAudioStream, getVideoStreamFPS, getVideoStream } from '@server/helpers/ffmpeg'
6import { VideoPrivacy } from '@shared/models'
6import { 7import {
7 cleanupTests, 8 cleanupTests,
8 createSingleServer, 9 createSingleServer,
@@ -13,7 +14,6 @@ import {
13 testFfmpegStreamError, 14 testFfmpegStreamError,
14 waitJobs 15 waitJobs
15} from '@shared/server-commands' 16} from '@shared/server-commands'
16import { VideoPrivacy } from '@shared/models'
17 17
18async function createLiveWrapper (server: PeerTubeServer) { 18async function createLiveWrapper (server: PeerTubeServer) {
19 const liveAttributes = { 19 const liveAttributes = {
@@ -92,7 +92,7 @@ describe('Test transcoding plugins', function () {
92 92
93 async function checkLiveFPS (uuid: string, type: 'above' | 'below', fps: number) { 93 async function checkLiveFPS (uuid: string, type: 'above' | 'below', fps: number) {
94 const playlistUrl = `${server.url}/static/streaming-playlists/hls/${uuid}/0.m3u8` 94 const playlistUrl = `${server.url}/static/streaming-playlists/hls/${uuid}/0.m3u8`
95 const videoFPS = await getVideoFileFPS(playlistUrl) 95 const videoFPS = await getVideoStreamFPS(playlistUrl)
96 96
97 if (type === 'above') { 97 if (type === 'above') {
98 expect(videoFPS).to.be.above(fps) 98 expect(videoFPS).to.be.above(fps)
@@ -252,7 +252,7 @@ describe('Test transcoding plugins', function () {
252 const audioProbe = await getAudioStream(path) 252 const audioProbe = await getAudioStream(path)
253 expect(audioProbe.audioStream.codec_name).to.equal('opus') 253 expect(audioProbe.audioStream.codec_name).to.equal('opus')
254 254
255 const videoProbe = await getVideoStreamFromFile(path) 255 const videoProbe = await getVideoStream(path)
256 expect(videoProbe.codec_name).to.equal('vp9') 256 expect(videoProbe.codec_name).to.equal('vp9')
257 }) 257 })
258 258
@@ -269,7 +269,7 @@ describe('Test transcoding plugins', function () {
269 const audioProbe = await getAudioStream(playlistUrl) 269 const audioProbe = await getAudioStream(playlistUrl)
270 expect(audioProbe.audioStream.codec_name).to.equal('opus') 270 expect(audioProbe.audioStream.codec_name).to.equal('opus')
271 271
272 const videoProbe = await getVideoStreamFromFile(playlistUrl) 272 const videoProbe = await getVideoStream(playlistUrl)
273 expect(videoProbe.codec_name).to.equal('h264') 273 expect(videoProbe.codec_name).to.equal('h264')
274 }) 274 })
275 }) 275 })
diff --git a/server/tests/shared/generate.ts b/server/tests/shared/generate.ts
index f806df2f5..9a57084e4 100644
--- a/server/tests/shared/generate.ts
+++ b/server/tests/shared/generate.ts
@@ -3,12 +3,12 @@ import ffmpeg from 'fluent-ffmpeg'
3import { ensureDir, pathExists } from 'fs-extra' 3import { ensureDir, pathExists } from 'fs-extra'
4import { dirname } from 'path' 4import { dirname } from 'path'
5import { buildAbsoluteFixturePath, getMaxBitrate } from '@shared/core-utils' 5import { buildAbsoluteFixturePath, getMaxBitrate } from '@shared/core-utils'
6import { getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@shared/extra-utils' 6import { getVideoStreamBitrate, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '@shared/extra-utils'
7 7
8async function ensureHasTooBigBitrate (fixturePath: string) { 8async function ensureHasTooBigBitrate (fixturePath: string) {
9 const bitrate = await getVideoFileBitrate(fixturePath) 9 const bitrate = await getVideoStreamBitrate(fixturePath)
10 const dataResolution = await getVideoFileResolution(fixturePath) 10 const dataResolution = await getVideoStreamDimensionsInfo(fixturePath)
11 const fps = await getVideoFileFPS(fixturePath) 11 const fps = await getVideoStreamFPS(fixturePath)
12 12
13 const maxBitrate = getMaxBitrate({ ...dataResolution, fps }) 13 const maxBitrate = getMaxBitrate({ ...dataResolution, fps })
14 expect(bitrate).to.be.above(maxBitrate) 14 expect(bitrate).to.be.above(maxBitrate)
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/tests/shared/videos.ts b/server/tests/shared/videos.ts
index 6be094f2b..989865a49 100644
--- a/server/tests/shared/videos.ts
+++ b/server/tests/shared/videos.ts
@@ -240,6 +240,16 @@ async function uploadRandomVideoOnServers (
240 return res 240 return res
241} 241}
242 242
243function getAllFiles (video: VideoDetails) {
244 const files = video.files
245
246 if (video.streamingPlaylists[0]) {
247 return files.concat(video.streamingPlaylists[0].files)
248 }
249
250 return files
251}
252
243// --------------------------------------------------------------------------- 253// ---------------------------------------------------------------------------
244 254
245export { 255export {
@@ -247,5 +257,6 @@ export {
247 checkUploadVideoParam, 257 checkUploadVideoParam,
248 uploadRandomVideoOnServers, 258 uploadRandomVideoOnServers,
249 checkVideoFilesWereRemoved, 259 checkVideoFilesWereRemoved,
250 saveVideoInServers 260 saveVideoInServers,
261 getAllFiles
251} 262}
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index 1a99b598a..91a8cf3d8 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -40,7 +40,7 @@ import {
40 MVideoRedundancyVideo, 40 MVideoRedundancyVideo,
41 MVideoShareActor, 41 MVideoShareActor,
42 MVideoThumbnail 42 MVideoThumbnail
43} from '../../types/models' 43} from './models'
44import { Writable } from 'stream' 44import { Writable } from 'stream'
45 45
46declare module 'express' { 46declare module 'express' {
@@ -60,6 +60,7 @@ declare module 'express' {
60 export type UploadFileForCheck = { 60 export type UploadFileForCheck = {
61 originalname: string 61 originalname: string
62 mimetype: string 62 mimetype: string
63 size: number
63 } 64 }
64 65
65 export type UploadFilesForCheck = { 66 export type UploadFilesForCheck = {
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> &