diff options
Diffstat (limited to 'server')
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' |
19 | import { | 19 | import { |
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 | ||
219 | async function listAccountFollowers (req: express.Request, res: express.Response) { | 219 | async 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' | |||
3 | import { tokensRouter } from '@server/controllers/api/users/token' | 3 | import { tokensRouter } from '@server/controllers/api/users/token' |
4 | import { Hooks } from '@server/lib/plugins/hooks' | 4 | import { Hooks } from '@server/lib/plugins/hooks' |
5 | import { OAuthTokenModel } from '@server/models/oauth/oauth-token' | 5 | import { OAuthTokenModel } from '@server/models/oauth/oauth-token' |
6 | import { MUser, MUserAccountDefault } from '@server/types/models' | 6 | import { MUserAccountDefault } from '@server/types/models' |
7 | import { HttpStatusCode, UserAdminFlag, UserCreate, UserCreateResult, UserRegister, UserRight, UserRole, UserUpdate } from '@shared/models' | 7 | import { pick } from '@shared/core-utils' |
8 | import { HttpStatusCode, UserCreate, UserCreateResult, UserRegister, UserRight, UserUpdate } from '@shared/models' | ||
8 | import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger' | 9 | import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger' |
9 | import { logger } from '../../../helpers/logger' | 10 | import { logger } from '../../../helpers/logger' |
10 | import { generateRandomString, getFormattedObjects } from '../../../helpers/utils' | 11 | import { generateRandomString, getFormattedObjects } from '../../../helpers/utils' |
@@ -14,7 +15,7 @@ import { sequelizeTypescript } from '../../../initializers/database' | |||
14 | import { Emailer } from '../../../lib/emailer' | 15 | import { Emailer } from '../../../lib/emailer' |
15 | import { Notifier } from '../../../lib/notifier' | 16 | import { Notifier } from '../../../lib/notifier' |
16 | import { Redis } from '../../../lib/redis' | 17 | import { Redis } from '../../../lib/redis' |
17 | import { createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user' | 18 | import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user' |
18 | import { | 19 | import { |
19 | asyncMiddleware, | 20 | asyncMiddleware, |
20 | asyncRetryTransactionMiddleware, | 21 | asyncRetryTransactionMiddleware, |
@@ -175,18 +176,11 @@ export { | |||
175 | async function createUser (req: express.Request, res: express.Response) { | 176 | async 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) { | |||
225 | async function registerUser (req: express.Request, res: express.Response) { | 219 | async 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 @@ | |||
1 | import 'multer' | 1 | import 'multer' |
2 | import express from 'express' | 2 | import express from 'express' |
3 | import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '@server/helpers/audit-logger' | 3 | import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '@server/helpers/audit-logger' |
4 | import { getBiggestActorImage } from '@server/lib/actor-image' | ||
4 | import { Hooks } from '@server/lib/plugins/hooks' | 5 | import { Hooks } from '@server/lib/plugins/hooks' |
6 | import { pick } from '@shared/core-utils' | ||
5 | import { ActorImageType, HttpStatusCode, UserUpdateMe, UserVideoQuota, UserVideoRate as FormattedUserVideoRate } from '@shared/models' | 7 | import { ActorImageType, HttpStatusCode, UserUpdateMe, UserVideoQuota, UserVideoRate as FormattedUserVideoRate } from '@shared/models' |
6 | import { AttributesOnly } from '@shared/typescript-utils' | 8 | import { AttributesOnly } from '@shared/typescript-utils' |
7 | import { createReqFiles } from '../../../helpers/express-utils' | 9 | import { createReqFiles } from '../../../helpers/express-utils' |
@@ -10,7 +12,7 @@ import { CONFIG } from '../../../initializers/config' | |||
10 | import { MIMETYPES } from '../../../initializers/constants' | 12 | import { MIMETYPES } from '../../../initializers/constants' |
11 | import { sequelizeTypescript } from '../../../initializers/database' | 13 | import { sequelizeTypescript } from '../../../initializers/database' |
12 | import { sendUpdateActor } from '../../../lib/activitypub/send' | 14 | import { sendUpdateActor } from '../../../lib/activitypub/send' |
13 | import { deleteLocalActorImageFile, updateLocalActorImageFile } from '../../../lib/local-actor' | 15 | import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../../lib/local-actor' |
14 | import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user' | 16 | import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user' |
15 | import { | 17 | import { |
16 | asyncMiddleware, | 18 | asyncMiddleware, |
@@ -30,11 +32,10 @@ import { AccountVideoRateModel } from '../../../models/account/account-video-rat | |||
30 | import { UserModel } from '../../../models/user/user' | 32 | import { UserModel } from '../../../models/user/user' |
31 | import { VideoModel } from '../../../models/video/video' | 33 | import { VideoModel } from '../../../models/video/video' |
32 | import { VideoImportModel } from '../../../models/video/video-import' | 34 | import { VideoImportModel } from '../../../models/video/video-import' |
33 | import { pick } from '@shared/core-utils' | ||
34 | 35 | ||
35 | const auditLogger = auditLoggerFactory('users') | 36 | const auditLogger = auditLoggerFactory('users') |
36 | 37 | ||
37 | const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR }) | 38 | const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT) |
38 | 39 | ||
39 | const meRouter = express.Router() | 40 | const 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 | ||
261 | async function deleteMyAvatar (req: express.Request, res: express.Response) { | 270 | async 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' | |||
3 | import { UserNotificationModel } from '@server/models/user/user-notification' | 3 | import { UserNotificationModel } from '@server/models/user/user-notification' |
4 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | 4 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' |
5 | import { UserNotificationSetting } from '../../../../shared/models/users' | 5 | import { UserNotificationSetting } from '../../../../shared/models/users' |
6 | import { getFormattedObjects } from '../../../helpers/utils' | ||
7 | import { | 6 | import { |
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' |
21 | import { UserNotificationSettingModel } from '../../../models/user/user-notification-setting' | 20 | import { UserNotificationSettingModel } from '../../../models/user/user-notification-setting' |
22 | import { meRouter } from './me' | 21 | import { meRouter } from './me' |
22 | import { getFormattedObjects } from '@server/helpers/utils' | ||
23 | 23 | ||
24 | const myNotificationsRouter = express.Router() | 24 | const 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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { pickCommonVideoQuery } from '@server/helpers/query' | 2 | import { pickCommonVideoQuery } from '@server/helpers/query' |
3 | import { getBiggestActorImage } from '@server/lib/actor-image' | ||
3 | import { Hooks } from '@server/lib/plugins/hooks' | 4 | import { Hooks } from '@server/lib/plugins/hooks' |
4 | import { ActorFollowModel } from '@server/models/actor/actor-follow' | 5 | import { ActorFollowModel } from '@server/models/actor/actor-follow' |
5 | import { getServerActor } from '@server/models/application/application' | 6 | import { getServerActor } from '@server/models/application/application' |
@@ -11,12 +12,11 @@ import { resetSequelizeInstance } from '../../helpers/database-utils' | |||
11 | import { buildNSFWFilter, createReqFiles, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' | 12 | import { buildNSFWFilter, createReqFiles, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' |
12 | import { logger } from '../../helpers/logger' | 13 | import { logger } from '../../helpers/logger' |
13 | import { getFormattedObjects } from '../../helpers/utils' | 14 | import { getFormattedObjects } from '../../helpers/utils' |
14 | import { CONFIG } from '../../initializers/config' | ||
15 | import { MIMETYPES } from '../../initializers/constants' | 15 | import { MIMETYPES } from '../../initializers/constants' |
16 | import { sequelizeTypescript } from '../../initializers/database' | 16 | import { sequelizeTypescript } from '../../initializers/database' |
17 | import { sendUpdateActor } from '../../lib/activitypub/send' | 17 | import { sendUpdateActor } from '../../lib/activitypub/send' |
18 | import { JobQueue } from '../../lib/job-queue' | 18 | import { JobQueue } from '../../lib/job-queue' |
19 | import { deleteLocalActorImageFile, updateLocalActorImageFile } from '../../lib/local-actor' | 19 | import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../lib/local-actor' |
20 | import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel' | 20 | import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel' |
21 | import { | 21 | import { |
22 | asyncMiddleware, | 22 | asyncMiddleware, |
@@ -50,8 +50,8 @@ import { VideoChannelModel } from '../../models/video/video-channel' | |||
50 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | 50 | import { VideoPlaylistModel } from '../../models/video/video-playlist' |
51 | 51 | ||
52 | const auditLogger = auditLoggerFactory('channels') | 52 | const auditLogger = auditLoggerFactory('channels') |
53 | const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR }) | 53 | const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT) |
54 | const reqBannerFile = createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { bannerfile: CONFIG.STORAGE.TMP_DIR }) | 54 | const reqBannerFile = createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT) |
55 | 55 | ||
56 | const videoChannelRouter = express.Router() | 56 | const 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 | ||
196 | async function updateVideoChannelAvatar (req: express.Request, res: express.Response) { | 200 | async 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 | ||
208 | async function deleteVideoChannelAvatar (req: express.Request, res: express.Response) { | 215 | async 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' | |||
47 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | 47 | import { VideoPlaylistModel } from '../../models/video/video-playlist' |
48 | import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' | 48 | import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' |
49 | 49 | ||
50 | const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR }) | 50 | const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT) |
51 | 51 | ||
52 | const videoPlaylistRouter = express.Router() | 52 | const 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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { Hooks } from '@server/lib/plugins/hooks' | ||
2 | import { MVideoCaption } from '@server/types/models' | 3 | import { MVideoCaption } from '@server/types/models' |
3 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | 4 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' |
4 | import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils' | 5 | import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils' |
5 | import { createReqFiles } from '../../../helpers/express-utils' | 6 | import { createReqFiles } from '../../../helpers/express-utils' |
6 | import { logger } from '../../../helpers/logger' | 7 | import { logger } from '../../../helpers/logger' |
7 | import { getFormattedObjects } from '../../../helpers/utils' | 8 | import { getFormattedObjects } from '../../../helpers/utils' |
8 | import { CONFIG } from '../../../initializers/config' | ||
9 | import { MIMETYPES } from '../../../initializers/constants' | 9 | import { MIMETYPES } from '../../../initializers/constants' |
10 | import { sequelizeTypescript } from '../../../initializers/database' | 10 | import { sequelizeTypescript } from '../../../initializers/database' |
11 | import { federateVideoIfNeeded } from '../../../lib/activitypub/videos' | 11 | import { federateVideoIfNeeded } from '../../../lib/activitypub/videos' |
12 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' | 12 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' |
13 | import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators' | 13 | import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators' |
14 | import { VideoCaptionModel } from '../../../models/video/video-caption' | 14 | import { VideoCaptionModel } from '../../../models/video/video-caption' |
15 | import { Hooks } from '@server/lib/plugins/hooks' | ||
16 | 15 | ||
17 | const reqVideoCaptionAdd = createReqFiles( | 16 | const 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 | ||
25 | const videoCaptionsRouter = express.Router() | 18 | const 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 @@ | |||
1 | import express from 'express' | ||
2 | import { createAnyReqFiles } from '@server/helpers/express-utils' | ||
3 | import { MIMETYPES } from '@server/initializers/constants' | ||
4 | import { JobQueue } from '@server/lib/job-queue' | ||
5 | import { buildTaskFileFieldname, getTaskFile } from '@server/lib/video-editor' | ||
6 | import { | ||
7 | HttpStatusCode, | ||
8 | VideoEditionTaskPayload, | ||
9 | VideoEditorCreateEdition, | ||
10 | VideoEditorTask, | ||
11 | VideoEditorTaskCut, | ||
12 | VideoEditorTaskIntro, | ||
13 | VideoEditorTaskOutro, | ||
14 | VideoEditorTaskWatermark, | ||
15 | VideoState | ||
16 | } from '@shared/models' | ||
17 | import { asyncMiddleware, authenticate, videosEditorAddEditionValidator } from '../../../middlewares' | ||
18 | |||
19 | const editorRouter = express.Router() | ||
20 | |||
21 | const 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 | |||
46 | editorRouter.post('/:videoId/editor/edit', | ||
47 | authenticate, | ||
48 | tasksFiles, | ||
49 | asyncMiddleware(videosEditorAddEditionValidator), | ||
50 | asyncMiddleware(createEditionTasks) | ||
51 | ) | ||
52 | |||
53 | // --------------------------------------------------------------------------- | ||
54 | |||
55 | export { | ||
56 | editorRouter | ||
57 | } | ||
58 | |||
59 | // --------------------------------------------------------------------------- | ||
60 | |||
61 | async 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 | |||
79 | const 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 | |||
88 | function buildTaskPayload (task: VideoEditorTask, indice: number, files: Express.Multer.File[]): VideoEditionTaskPayload { | ||
89 | return taskPayloadBuilders[task.name](task, indice, files) | ||
90 | } | ||
91 | |||
92 | function 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 | |||
101 | function 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 | |||
111 | function 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 | ||
62 | const reqVideoFileImport = createReqFiles( | 62 | const 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 | ||
72 | videoImportsRouter.post('/imports', | 67 | videoImportsRouter.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' | |||
35 | import { blacklistRouter } from './blacklist' | 35 | import { blacklistRouter } from './blacklist' |
36 | import { videoCaptionsRouter } from './captions' | 36 | import { videoCaptionsRouter } from './captions' |
37 | import { videoCommentRouter } from './comment' | 37 | import { videoCommentRouter } from './comment' |
38 | import { editorRouter } from './editor' | ||
38 | import { filesRouter } from './files' | 39 | import { filesRouter } from './files' |
39 | import { videoImportsRouter } from './import' | 40 | import { videoImportsRouter } from './import' |
40 | import { liveRouter } from './live' | 41 | import { liveRouter } from './live' |
@@ -51,6 +52,7 @@ const videosRouter = express.Router() | |||
51 | videosRouter.use('/', blacklistRouter) | 52 | videosRouter.use('/', blacklistRouter) |
52 | videosRouter.use('/', rateVideoRouter) | 53 | videosRouter.use('/', rateVideoRouter) |
53 | videosRouter.use('/', videoCommentRouter) | 54 | videosRouter.use('/', videoCommentRouter) |
55 | videosRouter.use('/', editorRouter) | ||
54 | videosRouter.use('/', videoCaptionsRouter) | 56 | videosRouter.use('/', videoCaptionsRouter) |
55 | videosRouter.use('/', videoImportsRouter) | 57 | videosRouter.use('/', videoImportsRouter) |
56 | videosRouter.use('/', ownershipVideoRouter) | 58 | videosRouter.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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { createReqFiles } from '@server/helpers/express-utils' | 2 | import { createReqFiles } from '@server/helpers/express-utils' |
3 | import { CONFIG } from '@server/initializers/config' | ||
4 | import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' | 3 | import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' |
5 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' | 4 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' |
6 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' | 5 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' |
@@ -19,14 +18,7 @@ import { VideoModel } from '../../../models/video/video' | |||
19 | 18 | ||
20 | const liveRouter = express.Router() | 19 | const liveRouter = express.Router() |
21 | 20 | ||
22 | const reqVideoFileLive = createReqFiles( | 21 | const 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 | ||
31 | liveRouter.post('/live', | 23 | liveRouter.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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { computeLowerResolutionsToTranscode } from '@server/helpers/ffprobe-utils' | 2 | import { computeLowerResolutionsToTranscode } from '@server/helpers/ffmpeg' |
3 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | 3 | import { logger, loggerTagsFactory } from '@server/helpers/logger' |
4 | import { addTranscodingJob } from '@server/lib/video' | 4 | import { addTranscodingJob } from '@server/lib/video' |
5 | import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@shared/models' | 5 | import { 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 '../../../ | |||
11 | import { resetSequelizeInstance } from '../../../helpers/database-utils' | 11 | import { resetSequelizeInstance } from '../../../helpers/database-utils' |
12 | import { createReqFiles } from '../../../helpers/express-utils' | 12 | import { createReqFiles } from '../../../helpers/express-utils' |
13 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | 13 | import { logger, loggerTagsFactory } from '../../../helpers/logger' |
14 | import { CONFIG } from '../../../initializers/config' | ||
15 | import { MIMETYPES } from '../../../initializers/constants' | 14 | import { MIMETYPES } from '../../../initializers/constants' |
16 | import { sequelizeTypescript } from '../../../initializers/database' | 15 | import { sequelizeTypescript } from '../../../initializers/database' |
17 | import { federateVideoIfNeeded } from '../../../lib/activitypub/videos' | 16 | import { federateVideoIfNeeded } from '../../../lib/activitypub/videos' |
@@ -26,14 +25,7 @@ const lTags = loggerTagsFactory('api', 'video') | |||
26 | const auditLogger = auditLoggerFactory('videos') | 25 | const auditLogger = auditLoggerFactory('videos') |
27 | const updateRouter = express.Router() | 26 | const updateRouter = express.Router() |
28 | 27 | ||
29 | const reqVideoFileUpdate = createReqFiles( | 28 | const 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 | ||
38 | updateRouter.put('/:id', | 30 | updateRouter.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 | |||
24 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | 24 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' |
25 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | 25 | import { retryTransactionWrapper } from '../../../helpers/database-utils' |
26 | import { createReqFiles } from '../../../helpers/express-utils' | 26 | import { createReqFiles } from '../../../helpers/express-utils' |
27 | import { ffprobePromise, getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' | 27 | import { buildFileMetadata, ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '../../../helpers/ffmpeg' |
28 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | 28 | import { logger, loggerTagsFactory } from '../../../helpers/logger' |
29 | import { CONFIG } from '../../../initializers/config' | ||
30 | import { MIMETYPES } from '../../../initializers/constants' | 29 | import { MIMETYPES } from '../../../initializers/constants' |
31 | import { sequelizeTypescript } from '../../../initializers/database' | 30 | import { sequelizeTypescript } from '../../../initializers/database' |
32 | import { federateVideoIfNeeded } from '../../../lib/activitypub/videos' | 31 | import { federateVideoIfNeeded } from '../../../lib/activitypub/videos' |
@@ -52,21 +51,13 @@ const uploadRouter = express.Router() | |||
52 | 51 | ||
53 | const reqVideoFileAdd = createReqFiles( | 52 | const 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 | ||
63 | const reqVideoFileAddResumable = createReqFiles( | 57 | const 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 | ||
72 | uploadRouter.post('/upload', | 63 | uploadRouter.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 | ||
74 | for (const staticClientOverride of staticClientOverrides) { | 76 | for (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 | ||
2 | import { UploadFilesForCheck } from 'express' | ||
2 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' | 3 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' |
3 | import { isFileValid } from './misc' | 4 | import { 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('|') |
8 | const imageMimeTypesRegex = `image/(${imageMimeTypes})` | 9 | const imageMimeTypesRegex = `image/(${imageMimeTypes})` |
9 | function 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) | 11 | function 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 | ||
64 | function isFileFieldValid ( | 64 | function 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 | ||
85 | function 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 | |||
108 | function 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 | |||
99 | function 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 @@ | |||
1 | import { getFileSize } from '@shared/extra-utils' | 1 | import { UploadFilesForCheck } from 'express' |
2 | import { readFile } from 'fs-extra' | 2 | import { readFile } from 'fs-extra' |
3 | import { getFileSize } from '@shared/extra-utils' | ||
3 | import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants' | 4 | import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants' |
4 | import { exists, isFileValid } from './misc' | 5 | import { 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('|') |
14 | function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) { | 15 | function 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 | ||
18 | async function isVTTFileValid (filePath: string) { | 24 | async 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 @@ | |||
1 | import validator from 'validator' | ||
2 | import { CONSTRAINTS_FIELDS } from '@server/initializers/constants' | ||
3 | import { buildTaskFileFieldname } from '@server/lib/video-editor' | ||
4 | import { VideoEditorTask } from '@shared/models' | ||
5 | import { isArray } from './misc' | ||
6 | import { isVideoFileMimeTypeValid, isVideoImageValid } from './videos' | ||
7 | |||
8 | function 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 | |||
15 | function 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 | |||
30 | function 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 | |||
37 | function 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 | |||
46 | export { | ||
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 @@ | |||
1 | import 'multer' | 1 | import 'multer' |
2 | import { UploadFilesForCheck } from 'express' | ||
2 | import validator from 'validator' | 3 | import validator from 'validator' |
3 | import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants' | 4 | import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants' |
4 | import { exists, isFileValid } from './misc' | 5 | import { 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('|') |
28 | function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { | 29 | function 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' |
16 | import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc' | 16 | import { exists, isArray, isDateValid, isFileValid } from './misc' |
17 | 17 | ||
18 | const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS | 18 | const 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 | ||
69 | function isVideoTagsValid (tags: string[]) { | 69 | function 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 | ||
89 | function isVideoFileMimeTypeValid (files: UploadFilesForCheck) { | 89 | function 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 | ||
93 | const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME | 98 | const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME |
@@ -95,8 +100,14 @@ const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME | |||
95 | .join('|') | 100 | .join('|') |
96 | const videoImageTypesRegex = `image/(${videoImageTypes})` | 101 | const videoImageTypesRegex = `image/(${videoImageTypes})` |
97 | 102 | ||
98 | function isVideoImage (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) { | 103 | function 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 | ||
102 | function isVideoPrivacyValid (value: number) { | 113 | function 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 @@ | |||
1 | import express, { RequestHandler } from 'express' | 1 | import express, { RequestHandler } from 'express' |
2 | import multer, { diskStorage } from 'multer' | 2 | import multer, { diskStorage } from 'multer' |
3 | import { getLowercaseExtension } from '@shared/core-utils' | ||
3 | import { HttpStatusCode } from '../../shared/models/http/http-error-codes' | 4 | import { HttpStatusCode } from '../../shared/models/http/http-error-codes' |
4 | import { CONFIG } from '../initializers/config' | 5 | import { CONFIG } from '../initializers/config' |
5 | import { REMOTE_SCHEME } from '../initializers/constants' | 6 | import { REMOTE_SCHEME } from '../initializers/constants' |
6 | import { getLowercaseExtension } from '@shared/core-utils' | ||
7 | import { isArray } from './custom-validators/misc' | 7 | import { isArray } from './custom-validators/misc' |
8 | import { logger } from './logger' | 8 | import { logger } from './logger' |
9 | import { deleteFileAndCatch, generateRandomString } from './utils' | 9 | import { deleteFileAndCatch, generateRandomString } from './utils' |
@@ -68,36 +68,15 @@ function badRequest (_req: express.Request, res: express.Response) { | |||
68 | function createReqFiles ( | 68 | function 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 | ||
94 | function 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 | |||
115 | function isUserAbleToSearchRemoteURI (res: express.Response) { | 111 | function 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) { | |||
128 | export { | 124 | export { |
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 | |||
137 | async 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 @@ | |||
1 | import { Job } from 'bull' | ||
2 | import ffmpeg, { FfmpegCommand, FilterSpecification, getAvailableEncoders } from 'fluent-ffmpeg' | ||
3 | import { readFile, remove, writeFile } from 'fs-extra' | ||
4 | import { dirname, join } from 'path' | ||
5 | import { FFMPEG_NICE, VIDEO_LIVE } from '@server/initializers/constants' | ||
6 | import { pick } from '@shared/core-utils' | ||
7 | import { | ||
8 | AvailableEncoders, | ||
9 | EncoderOptions, | ||
10 | EncoderOptionsBuilder, | ||
11 | EncoderOptionsBuilderParams, | ||
12 | EncoderProfile, | ||
13 | VideoResolution | ||
14 | } from '../../shared/models/videos' | ||
15 | import { CONFIG } from '../initializers/config' | ||
16 | import { execPromise, promisify0 } from './core-utils' | ||
17 | import { computeFPS, ffprobePromise, getAudioStream, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from './ffprobe-utils' | ||
18 | import { processImage } from './image-utils' | ||
19 | import { logger, loggerTagsFactory } from './logger' | ||
20 | |||
21 | const 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 | |||
34 | type StreamType = 'audio' | 'video' | ||
35 | |||
36 | // --------------------------------------------------------------------------- | ||
37 | // Encoders support | ||
38 | // --------------------------------------------------------------------------- | ||
39 | |||
40 | // Detect supported encoders by ffmpeg | ||
41 | let supportedEncoders: Map<string, boolean> | ||
42 | async 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 | |||
70 | function resetSupportedEncoders () { | ||
71 | supportedEncoders = undefined | ||
72 | } | ||
73 | |||
74 | // --------------------------------------------------------------------------- | ||
75 | // Image manipulation | ||
76 | // --------------------------------------------------------------------------- | ||
77 | |||
78 | function 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 | |||
85 | function 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 | |||
98 | async 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 | |||
134 | type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' | ||
135 | |||
136 | interface 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 | |||
152 | interface HLSTranscodeOptions extends BaseTranscodeOptions { | ||
153 | type: 'hls' | ||
154 | copyCodecs: boolean | ||
155 | hlsPlaylist: { | ||
156 | videoFilename: string | ||
157 | } | ||
158 | } | ||
159 | |||
160 | interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions { | ||
161 | type: 'hls-from-ts' | ||
162 | |||
163 | isAAC: boolean | ||
164 | |||
165 | hlsPlaylist: { | ||
166 | videoFilename: string | ||
167 | } | ||
168 | } | ||
169 | |||
170 | interface QuickTranscodeOptions extends BaseTranscodeOptions { | ||
171 | type: 'quick-transcode' | ||
172 | } | ||
173 | |||
174 | interface VideoTranscodeOptions extends BaseTranscodeOptions { | ||
175 | type: 'video' | ||
176 | } | ||
177 | |||
178 | interface MergeAudioTranscodeOptions extends BaseTranscodeOptions { | ||
179 | type: 'merge-audio' | ||
180 | audioPath: string | ||
181 | } | ||
182 | |||
183 | interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions { | ||
184 | type: 'only-audio' | ||
185 | } | ||
186 | |||
187 | type TranscodeOptions = | ||
188 | HLSTranscodeOptions | ||
189 | | HLSFromTSTranscodeOptions | ||
190 | | VideoTranscodeOptions | ||
191 | | MergeAudioTranscodeOptions | ||
192 | | OnlyAudioTranscodeOptions | ||
193 | | QuickTranscodeOptions | ||
194 | |||
195 | const 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 | |||
206 | async 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 | |||
223 | async 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 | |||
337 | function 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 | |||
350 | function 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 | |||
362 | function 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 | |||
375 | function 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 | |||
396 | function 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 | |||
411 | async 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 | |||
428 | async 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 | |||
443 | function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) { | ||
444 | command = presetOnlyAudio(command) | ||
445 | |||
446 | return command | ||
447 | } | ||
448 | |||
449 | function 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 | |||
458 | function 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 | |||
468 | async 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 | |||
480 | function 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 | |||
496 | async 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 | |||
511 | function 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 | ||
522 | async 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 | |||
576 | async 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 | |||
647 | function presetCopy (command: FfmpegCommand): FfmpegCommand { | ||
648 | return command | ||
649 | .format('mp4') | ||
650 | .videoCodec('copy') | ||
651 | .audioCodec('copy') | ||
652 | } | ||
653 | |||
654 | function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand { | ||
655 | return command | ||
656 | .format('mp4') | ||
657 | .audioCodec('copy') | ||
658 | .noVideo() | ||
659 | } | ||
660 | |||
661 | function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand { | ||
662 | return command | ||
663 | .inputOptions(options.inputOptions ?? []) | ||
664 | .outputOptions(options.outputOptions ?? []) | ||
665 | } | ||
666 | |||
667 | function getScaleFilter (options: EncoderOptions): string { | ||
668 | if (options.scaleFilter) return options.scaleFilter.name | ||
669 | |||
670 | return 'scale' | ||
671 | } | ||
672 | |||
673 | // --------------------------------------------------------------------------- | ||
674 | // Utils | ||
675 | // --------------------------------------------------------------------------- | ||
676 | |||
677 | function 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 | |||
696 | function 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 | |||
720 | async 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 | ||
758 | function getScaleCleanerValue () { | ||
759 | return 'trunc(iw/2)*2:trunc(ih/2)*2' | ||
760 | } | ||
761 | |||
762 | // --------------------------------------------------------------------------- | ||
763 | |||
764 | export { | ||
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 @@ | |||
1 | import { Job } from 'bull' | ||
2 | import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' | ||
3 | import { execPromise } from '@server/helpers/core-utils' | ||
4 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
5 | import { CONFIG } from '@server/initializers/config' | ||
6 | import { FFMPEG_NICE } from '@server/initializers/constants' | ||
7 | import { EncoderOptions } from '@shared/models' | ||
8 | |||
9 | const lTags = loggerTagsFactory('ffmpeg') | ||
10 | |||
11 | type StreamType = 'audio' | 'video' | ||
12 | |||
13 | function 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 | |||
32 | function 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 | |||
56 | async 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 | |||
93 | function buildStreamSuffix (base: string, streamNum?: number) { | ||
94 | if (streamNum !== undefined) { | ||
95 | return `${base}:${streamNum}` | ||
96 | } | ||
97 | |||
98 | return base | ||
99 | } | ||
100 | |||
101 | function getScaleFilter (options: EncoderOptions): string { | ||
102 | if (options.scaleFilter) return options.scaleFilter.name | ||
103 | |||
104 | return 'scale' | ||
105 | } | ||
106 | |||
107 | export { | ||
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 @@ | |||
1 | import { FilterSpecification } from 'fluent-ffmpeg' | ||
2 | import { VIDEO_FILTERS } from '@server/initializers/constants' | ||
3 | import { AvailableEncoders } from '@shared/models' | ||
4 | import { logger, loggerTagsFactory } from '../logger' | ||
5 | import { getFFmpeg, runCommand } from './ffmpeg-commons' | ||
6 | import { presetCopy, presetVOD } from './ffmpeg-presets' | ||
7 | import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe-utils' | ||
8 | |||
9 | const lTags = loggerTagsFactory('ffmpeg') | ||
10 | |||
11 | async 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 | |||
37 | async 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 | |||
95 | async 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 | |||
238 | export { | ||
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 @@ | |||
1 | import { getAvailableEncoders } from 'fluent-ffmpeg' | ||
2 | import { pick } from '@shared/core-utils' | ||
3 | import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models' | ||
4 | import { promisify0 } from '../core-utils' | ||
5 | import { logger, loggerTagsFactory } from '../logger' | ||
6 | |||
7 | const lTags = loggerTagsFactory('ffmpeg') | ||
8 | |||
9 | // Detect supported encoders by ffmpeg | ||
10 | let supportedEncoders: Map<string, boolean> | ||
11 | async 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 | |||
39 | function 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 | ||
46 | async 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 | |||
111 | export { | ||
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 @@ | |||
1 | import ffmpeg from 'fluent-ffmpeg' | ||
2 | import { FFMPEG_NICE } from '@server/initializers/constants' | ||
3 | import { runCommand } from './ffmpeg-commons' | ||
4 | |||
5 | function 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 | |||
12 | function 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 | |||
25 | async 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 | |||
42 | export { | ||
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 @@ | |||
1 | import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg' | ||
2 | import { join } from 'path' | ||
3 | import { VIDEO_LIVE } from '@server/initializers/constants' | ||
4 | import { AvailableEncoders } from '@shared/models' | ||
5 | import { logger, loggerTagsFactory } from '../logger' | ||
6 | import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons' | ||
7 | import { getEncoderBuilderResult } from './ffmpeg-encoders' | ||
8 | import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './ffmpeg-presets' | ||
9 | import { computeFPS } from './ffprobe-utils' | ||
10 | |||
11 | const lTags = loggerTagsFactory('ffmpeg') | ||
12 | |||
13 | async 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 | |||
130 | function 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 | |||
145 | export { | ||
146 | getLiveTranscodingCommand, | ||
147 | getLiveMuxingCommand | ||
148 | } | ||
149 | |||
150 | // --------------------------------------------------------------------------- | ||
151 | |||
152 | function 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 @@ | |||
1 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
2 | import { pick } from 'lodash' | ||
3 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
4 | import { AvailableEncoders, EncoderOptions } from '@shared/models' | ||
5 | import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-commons' | ||
6 | import { getEncoderBuilderResult } from './ffmpeg-encoders' | ||
7 | import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from './ffprobe-utils' | ||
8 | |||
9 | const lTags = loggerTagsFactory('ffmpeg') | ||
10 | |||
11 | // --------------------------------------------------------------------------- | ||
12 | |||
13 | function 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 | |||
22 | function 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 | |||
46 | async 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 | |||
125 | function presetCopy (command: FfmpegCommand): FfmpegCommand { | ||
126 | return command | ||
127 | .format('mp4') | ||
128 | .videoCodec('copy') | ||
129 | .audioCodec('copy') | ||
130 | } | ||
131 | |||
132 | function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand { | ||
133 | return command | ||
134 | .format('mp4') | ||
135 | .audioCodec('copy') | ||
136 | .noVideo() | ||
137 | } | ||
138 | |||
139 | function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand { | ||
140 | return command | ||
141 | .inputOptions(options.inputOptions ?? []) | ||
142 | .outputOptions(options.outputOptions ?? []) | ||
143 | } | ||
144 | |||
145 | // --------------------------------------------------------------------------- | ||
146 | |||
147 | export { | ||
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 @@ | |||
1 | import { Job } from 'bull' | ||
2 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
3 | import { readFile, writeFile } from 'fs-extra' | ||
4 | import { dirname } from 'path' | ||
5 | import { pick } from '@shared/core-utils' | ||
6 | import { AvailableEncoders, VideoResolution } from '@shared/models' | ||
7 | import { logger, loggerTagsFactory } from '../logger' | ||
8 | import { getFFmpeg, runCommand } from './ffmpeg-commons' | ||
9 | import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets' | ||
10 | import { computeFPS, getVideoStreamFPS } from './ffprobe-utils' | ||
11 | import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants' | ||
12 | |||
13 | const lTags = loggerTagsFactory('ffmpeg') | ||
14 | |||
15 | // --------------------------------------------------------------------------- | ||
16 | |||
17 | type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' | ||
18 | |||
19 | interface 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 | |||
35 | interface HLSTranscodeOptions extends BaseTranscodeVODOptions { | ||
36 | type: 'hls' | ||
37 | copyCodecs: boolean | ||
38 | hlsPlaylist: { | ||
39 | videoFilename: string | ||
40 | } | ||
41 | } | ||
42 | |||
43 | interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions { | ||
44 | type: 'hls-from-ts' | ||
45 | |||
46 | isAAC: boolean | ||
47 | |||
48 | hlsPlaylist: { | ||
49 | videoFilename: string | ||
50 | } | ||
51 | } | ||
52 | |||
53 | interface QuickTranscodeOptions extends BaseTranscodeVODOptions { | ||
54 | type: 'quick-transcode' | ||
55 | } | ||
56 | |||
57 | interface VideoTranscodeOptions extends BaseTranscodeVODOptions { | ||
58 | type: 'video' | ||
59 | } | ||
60 | |||
61 | interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions { | ||
62 | type: 'merge-audio' | ||
63 | audioPath: string | ||
64 | } | ||
65 | |||
66 | interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions { | ||
67 | type: 'only-audio' | ||
68 | } | ||
69 | |||
70 | type TranscodeVODOptions = | ||
71 | HLSTranscodeOptions | ||
72 | | HLSFromTSTranscodeOptions | ||
73 | | VideoTranscodeOptions | ||
74 | | MergeAudioTranscodeOptions | ||
75 | | OnlyAudioTranscodeOptions | ||
76 | | QuickTranscodeOptions | ||
77 | |||
78 | // --------------------------------------------------------------------------- | ||
79 | |||
80 | const 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 | |||
91 | async 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 | |||
106 | export { | ||
107 | transcodeVOD, | ||
108 | |||
109 | buildVODCommand, | ||
110 | |||
111 | TranscodeVODOptions, | ||
112 | TranscodeVODOptionsType | ||
113 | } | ||
114 | |||
115 | // --------------------------------------------------------------------------- | ||
116 | |||
117 | async 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 | |||
143 | function 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 | |||
156 | async 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 | |||
180 | function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) { | ||
181 | command = presetOnlyAudio(command) | ||
182 | |||
183 | return command | ||
184 | } | ||
185 | |||
186 | // --------------------------------------------------------------------------- | ||
187 | // HLS transcoding | ||
188 | // --------------------------------------------------------------------------- | ||
189 | |||
190 | async 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 | |||
202 | function 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 | |||
218 | function 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 | |||
228 | async 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 | |||
247 | function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) { | ||
248 | return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` | ||
249 | } | ||
250 | |||
251 | // Avoid "height not divisible by 2" error | ||
252 | function 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 @@ | |||
1 | import { FfprobeData } from 'fluent-ffmpeg' | 1 | import { FfprobeData } from 'fluent-ffmpeg' |
2 | import { getMaxBitrate } from '@shared/core-utils' | 2 | import { getMaxBitrate } from '@shared/core-utils' |
3 | import { VideoResolution, VideoTranscodingFPS } from '../../shared/models/videos' | ||
4 | import { CONFIG } from '../initializers/config' | ||
5 | import { VIDEO_TRANSCODING_FPS } from '../initializers/constants' | ||
6 | import { logger } from './logger' | ||
7 | import { | 3 | import { |
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' |
15 | import { VideoResolution, VideoTranscodingFPS } from '@shared/models' | ||
16 | import { CONFIG } from '../../initializers/config' | ||
17 | import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants' | ||
18 | import { logger } from '../logger' | ||
20 | 19 | ||
21 | /** | 20 | /** |
22 | * | 21 | * |
@@ -24,9 +23,12 @@ import { | |||
24 | * | 23 | * |
25 | */ | 24 | */ |
26 | 25 | ||
27 | async function getVideoStreamCodec (path: string) { | 26 | // --------------------------------------------------------------------------- |
28 | const videoStream = await getVideoStreamFromFile(path) | 27 | // Codecs |
28 | // --------------------------------------------------------------------------- | ||
29 | 29 | ||
30 | async 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 | |||
86 | function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') { | 92 | function 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 | |||
115 | async function canDoQuickTranscode (path: string): Promise<boolean> { | 125 | async 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 | ||
134 | async 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 | |||
124 | async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> { | 154 | async 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 | |||
143 | function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) { | 177 | function 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 | ||
173 | export { | 207 | export { |
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 @@ | |||
1 | export * from './ffmpeg-commons' | ||
2 | export * from './ffmpeg-edition' | ||
3 | export * from './ffmpeg-encoders' | ||
4 | export * from './ffmpeg-images' | ||
5 | export * from './ffmpeg-live' | ||
6 | export * from './ffmpeg-presets' | ||
7 | export * from './ffmpeg-vod' | ||
8 | export * 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 @@ | |||
1 | import { copy, readFile, remove, rename } from 'fs-extra' | 1 | import { copy, readFile, remove, rename } from 'fs-extra' |
2 | import Jimp, { read } from 'jimp' | 2 | import Jimp, { read as jimpRead } from 'jimp' |
3 | import { join } from 'path' | ||
3 | import { getLowercaseExtension } from '@shared/core-utils' | 4 | import { getLowercaseExtension } from '@shared/core-utils' |
4 | import { buildUUID } from '@shared/extra-utils' | 5 | import { buildUUID } from '@shared/extra-utils' |
5 | import { convertWebPToJPG, processGIF } from './ffmpeg-utils' | 6 | import { convertWebPToJPG, generateThumbnailFromVideo, processGIF } from './ffmpeg/ffmpeg-images' |
6 | import { logger } from './logger' | 7 | import { logger, loggerTagsFactory } from './logger' |
8 | |||
9 | const lTags = loggerTagsFactory('image-utils') | ||
7 | 10 | ||
8 | function generateImageFilename (extension = '.jpg') { | 11 | function 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 | ||
39 | async 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 | |||
59 | async 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 | ||
38 | export { | 72 | export { |
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') | |||
8 | const MarkdownItClass = require('markdown-it') | 8 | const MarkdownItClass = require('markdown-it') |
9 | 9 | ||
10 | const markdownItWithHTML = new MarkdownItClass('default', { linkify: true, breaks: true, html: true }) | 10 | const markdownItWithHTML = new MarkdownItClass('default', { linkify: true, breaks: true, html: true }) |
11 | const markdownItWithoutHTML = new MarkdownItClass('default', { linkify: true, breaks: true, html: false }) | 11 | const markdownItWithoutHTML = new MarkdownItClass('default', { linkify: false, breaks: true, html: false }) |
12 | 12 | ||
13 | const toSafeHtml = (text: string) => { | 13 | const 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 | ||
93 | function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { | 93 | function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { |
94 | return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), videoPath => { | ||
95 | return createTorrentAndSetInfoHashFromPath(videoOrPlaylist, videoFile, videoPath) | ||
96 | }) | ||
97 | } | ||
98 | |||
99 | async 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 | ||
124 | async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { | 132 | async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { |
@@ -177,7 +185,10 @@ function generateMagnetUri ( | |||
177 | export { | 185 | export { |
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 @@ | |||
1 | import config from 'config' | 1 | import config from 'config' |
2 | import { uniq } from 'lodash' | 2 | import { uniq } from 'lodash' |
3 | import { URL } from 'url' | 3 | import { URL } from 'url' |
4 | import { getFFmpegVersion } from '@server/helpers/ffmpeg-utils' | 4 | import { getFFmpegVersion } from '@server/helpers/ffmpeg' |
5 | import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type' | 5 | import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type' |
6 | import { RecentlyAddedStrategy } from '../../shared/models/redundancy' | 6 | import { RecentlyAddedStrategy } from '../../shared/models/redundancy' |
7 | import { isProdInstance, isTestInstance, parseSemVersion } from '../helpers/core-utils' | 7 | import { 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 | ||
36 | function checkConfig () { | 35 | function 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) | ||
56 | async 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) | ||
63 | async 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) | ||
70 | async function applicationExist () { | ||
71 | const totalApplication = await ApplicationModel.countTotal() | ||
72 | |||
73 | return totalApplication !== 0 | ||
74 | } | ||
75 | |||
76 | async 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 | |||
87 | export { | ||
88 | checkConfig, | ||
89 | clientsExist, | ||
90 | checkFFmpegVersion, | ||
91 | usersExist, | ||
92 | applicationExist, | ||
93 | checkActivityPubUrls | ||
94 | } | ||
95 | |||
96 | // --------------------------------------------------------------------------- | ||
97 | |||
98 | function 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 | 110 | function 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 | 119 | function 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 | 150 | function 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 | ||
159 | function 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 | 178 | function 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 | 196 | function 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 | 207 | function 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 | 215 | function 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 | 237 | function 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) | ||
197 | async 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) | ||
204 | async 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) | 263 | function checkVideoEditorConfig () { |
211 | async 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 | |||
217 | async 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 | |||
228 | export { | ||
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' |
16 | import { ActivityPubActorType } from '../../shared/models/activitypub' | 16 | import { ActivityPubActorType } from '../../shared/models/activitypub' |
17 | import { FollowState } from '../../shared/models/actors' | 17 | import { ActorImageType, FollowState } from '../../shared/models/actors' |
18 | import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' | 18 | import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' |
19 | import { VideoPlaylistPrivacy } from '../../shared/models/videos/playlist/video-playlist-privacy.model' | 19 | import { VideoPlaylistPrivacy } from '../../shared/models/videos/playlist/video-playlist-privacy.model' |
20 | import { VideoPlaylistType } from '../../shared/models/videos/playlist/video-playlist-type.model' | 20 | import { 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 | ||
27 | const LAST_MIGRATION_VERSION = 680 | 27 | const 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 | } |
173 | const JOB_TTL: { [id in JobType]: number } = { | 175 | const 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 | ||
440 | const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = { | 449 | const 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 | } |
636 | const ACTOR_IMAGES_SIZE = { | 645 | const 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 | ||
647 | const EMBED_SIZE = { | 664 | const 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 | ||
707 | const MEMOIZE_LENGTH = { | 725 | const 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 | ||
869 | const 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 | |||
850 | export { | 879 | export { |
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 | ||
1023 | function updateWebserverConfig () { | 1053 | function 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' | |||
2 | import passwordGenerator from 'password-generator' | 2 | import passwordGenerator from 'password-generator' |
3 | import { UserRole } from '@shared/models' | 3 | import { UserRole } from '@shared/models' |
4 | import { logger } from '../helpers/logger' | 4 | import { logger } from '../helpers/logger' |
5 | import { createApplicationActor, createUserAccountAndChannelAndPlaylist } from '../lib/user' | 5 | import { buildUser, createApplicationActor, createUserAccountAndChannelAndPlaylist } from '../lib/user' |
6 | import { ApplicationModel } from '../models/application/application' | 6 | import { ApplicationModel } from '../models/application/application' |
7 | import { OAuthClientModel } from '../models/oauth/oauth-client' | 7 | import { OAuthClientModel } from '../models/oauth/oauth-client' |
8 | import { UserModel } from '../models/user/user' | ||
9 | import { applicationExist, clientsExist, usersExist } from './checker-after-init' | 8 | import { applicationExist, clientsExist, usersExist } from './checker-after-init' |
10 | import { CONFIG } from './config' | 9 | import { CONFIG } from './config' |
11 | import { FILES_CACHE, HLS_STREAMING_PLAYLIST_DIRECTORY, LAST_MIGRATION_VERSION, RESUMABLE_UPLOAD_DIRECTORY } from './constants' | 10 | import { 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 @@ | |||
1 | import * as Sequelize from 'sequelize' | 1 | import { readdir, rename } from 'fs-extra' |
2 | import { join } from 'path' | 2 | import { join } from 'path' |
3 | import * as Sequelize from 'sequelize' | ||
4 | import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg/ffprobe-utils' | ||
3 | import { CONFIG } from '../../initializers/config' | 5 | import { CONFIG } from '../../initializers/config' |
4 | import { getVideoFileResolution } from '../../helpers/ffprobe-utils' | ||
5 | import { readdir, rename } from 'fs-extra' | ||
6 | 6 | ||
7 | function up (utils: { | 7 | function 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 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async 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 | |||
55 | function down () { | ||
56 | throw new Error('Not implemented.') | ||
57 | } | ||
58 | |||
59 | export { | ||
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 | ||
15 | async function updateActorImageInstance (actor: MActorImages, type: ActorImageType, imageInfo: ImageInfo | null, t: Transaction) { | 15 | async 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 | ||
49 | async function deleteActorImageInstance (actor: MActorImages, type: ActorImageType, t: Transaction) { | 52 | async 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 | ||
68 | async 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 | ||
71 | export { | 81 | export { |
72 | ImageInfo, | 82 | ImageInfo, |
73 | 83 | ||
74 | updateActorImageInstance, | 84 | updateActorImages, |
75 | deleteActorImageInstance | 85 | deleteActorImages |
76 | } | 86 | } |
77 | 87 | ||
78 | // --------------------------------------------------------------------------- | 88 | // --------------------------------------------------------------------------- |
79 | 89 | ||
80 | function setActorImage (actorModel: MActorImages, type: ActorImageType, imageModel: MActorImage) { | 90 | function 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 | 97 | function 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' | |||
6 | import { VideoChannelModel } from '@server/models/video/video-channel' | 6 | import { VideoChannelModel } from '@server/models/video/video-channel' |
7 | import { MAccount, MAccountDefault, MActor, MActorFullActor, MActorId, MActorImages, MChannel, MServer } from '@server/types/models' | 7 | import { MAccount, MAccountDefault, MActor, MActorFullActor, MActorId, MActorImages, MChannel, MServer } from '@server/types/models' |
8 | import { ActivityPubActor, ActorImageType } from '@shared/models' | 8 | import { ActivityPubActor, ActorImageType } from '@shared/models' |
9 | import { updateActorImageInstance } from '../image' | 9 | import { updateActorImages } from '../image' |
10 | import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImageInfoFromObject } from './object-to-model-attributes' | 10 | import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes' |
11 | import { fetchActorFollowsCount } from './url-to-object' | 11 | import { fetchActorFollowsCount } from './url-to-object' |
12 | 12 | ||
13 | export class APActorCreator { | 13 | export 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' | |||
4 | import { FilteredModelAttributes } from '@server/types' | 4 | import { FilteredModelAttributes } from '@server/types' |
5 | import { getLowercaseExtension } from '@shared/core-utils' | 5 | import { getLowercaseExtension } from '@shared/core-utils' |
6 | import { buildUUID } from '@shared/extra-utils' | 6 | import { buildUUID } from '@shared/extra-utils' |
7 | import { ActivityPubActor, ActorImageType } from '@shared/models' | 7 | import { ActivityIconObject, ActivityPubActor, ActorImageType } from '@shared/models' |
8 | 8 | ||
9 | function getActorAttributesFromObject ( | 9 | function getActorAttributesFromObject ( |
10 | actorObject: ActivityPubActor, | 10 | actorObject: ActivityPubActor, |
@@ -30,33 +30,36 @@ function getActorAttributesFromObject ( | |||
30 | } | 30 | } |
31 | } | 31 | } |
32 | 32 | ||
33 | function getImageInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType) { | 33 | function 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 | ||
62 | function getActorDisplayNameFromObject (actorObject: ActivityPubActor) { | 65 | function getActorDisplayNameFromObject (actorObject: ActivityPubActor) { |
@@ -65,6 +68,15 @@ function getActorDisplayNameFromObject (actorObject: ActivityPubActor) { | |||
65 | 68 | ||
66 | export { | 69 | export { |
67 | getActorAttributesFromObject, | 70 | getActorAttributesFromObject, |
68 | getImageInfoFromObject, | 71 | getImagesInfoFromObject, |
69 | getActorDisplayNameFromObject | 72 | getActorDisplayNameFromObject |
70 | } | 73 | } |
74 | |||
75 | // --------------------------------------------------------------------------- | ||
76 | |||
77 | function 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' | |||
5 | import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models' | 5 | import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models' |
6 | import { ActivityPubActor, ActorImageType } from '@shared/models' | 6 | import { ActivityPubActor, ActorImageType } from '@shared/models' |
7 | import { getOrCreateAPOwner } from './get' | 7 | import { getOrCreateAPOwner } from './get' |
8 | import { updateActorImageInstance } from './image' | 8 | import { updateActorImages } from './image' |
9 | import { fetchActorFollowsCount } from './shared' | 9 | import { fetchActorFollowsCount } from './shared' |
10 | import { getImageInfoFromObject } from './shared/object-to-model-attributes' | 10 | import { getImagesInfoFromObject } from './shared/object-to-model-attributes' |
11 | 11 | ||
12 | export class APActorUpdater { | 12 | export 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 @@ | |||
1 | import maxBy from 'lodash/maxBy' | ||
2 | |||
3 | function 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 | |||
12 | export { | ||
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' | |||
5 | import { MOAuthClient } from '@server/types/models' | 5 | import { MOAuthClient } from '@server/types/models' |
6 | import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' | 6 | import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' |
7 | import { MUser } from '@server/types/models/user/user' | 7 | import { MUser } from '@server/types/models/user/user' |
8 | import { UserAdminFlag } from '@shared/models/users/user-flag.model' | 8 | import { pick } from '@shared/core-utils' |
9 | import { UserRole } from '@shared/models/users/user-role' | 9 | import { UserRole } from '@shared/models/users/user-role' |
10 | import { logger } from '../../helpers/logger' | 10 | import { logger } from '../../helpers/logger' |
11 | import { CONFIG } from '../../initializers/config' | 11 | import { CONFIG } from '../../initializers/config' |
12 | import { OAuthClientModel } from '../../models/oauth/oauth-client' | 12 | import { OAuthClientModel } from '../../models/oauth/oauth-client' |
13 | import { OAuthTokenModel } from '../../models/oauth/oauth-token' | 13 | import { OAuthTokenModel } from '../../models/oauth/oauth-token' |
14 | import { UserModel } from '../../models/user/user' | 14 | import { UserModel } from '../../models/user/user' |
15 | import { createUserAccountAndChannelAndPlaylist } from '../user' | 15 | import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user' |
16 | import { TokensCache } from './tokens-cache' | 16 | import { TokensCache } from './tokens-cache' |
17 | 17 | ||
18 | type TokenInfo = { | 18 | type 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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { readFile } from 'fs-extra' | 2 | import { readFile } from 'fs-extra' |
3 | import memoizee from 'memoizee' | ||
3 | import { join } from 'path' | 4 | import { join } from 'path' |
4 | import validator from 'validator' | 5 | import validator from 'validator' |
5 | import { toCompleteUUID } from '@server/helpers/custom-validators/misc' | 6 | import { toCompleteUUID } from '@server/helpers/custom-validators/misc' |
7 | import { ActorImageModel } from '@server/models/actor/actor-image' | ||
6 | import { root } from '@shared/core-utils' | 8 | import { root } from '@shared/core-utils' |
7 | import { escapeHTML } from '@shared/core-utils/renderer' | 9 | import { escapeHTML } from '@shared/core-utils/renderer' |
8 | import { sha256 } from '@shared/extra-utils' | 10 | import { sha256 } from '@shared/extra-utils' |
@@ -16,10 +18,11 @@ import { mdToOneLinePlainText } from '../helpers/markdown' | |||
16 | import { CONFIG } from '../initializers/config' | 18 | import { CONFIG } from '../initializers/config' |
17 | import { | 19 | import { |
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' | |||
29 | import { VideoChannelModel } from '../models/video/video-channel' | 32 | import { VideoChannelModel } from '../models/video/video-channel' |
30 | import { VideoPlaylistModel } from '../models/video/video-playlist' | 33 | import { VideoPlaylistModel } from '../models/video/video-playlist' |
31 | import { MAccountActor, MChannelActor } from '../types/models' | 34 | import { MAccountActor, MChannelActor } from '../types/models' |
35 | import { getBiggestActorImage } from './actor-image' | ||
32 | import { ServerConfigManager } from './server-config-manager' | 36 | import { ServerConfigManager } from './server-config-manager' |
33 | 37 | ||
38 | const 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 | |||
34 | type Tags = { | 43 | type 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' | |||
4 | import { MStreamingPlaylistFilesVideo, MVideo, MVideoUUID } from '@server/types/models' | 4 | import { MStreamingPlaylistFilesVideo, MVideo, MVideoUUID } from '@server/types/models' |
5 | import { sha256 } from '@shared/extra-utils' | 5 | import { sha256 } from '@shared/extra-utils' |
6 | import { VideoStorage } from '@shared/models' | 6 | import { VideoStorage } from '@shared/models' |
7 | import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamSize } from '../helpers/ffprobe-utils' | 7 | import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamDimensionsInfo } from '../helpers/ffmpeg' |
8 | import { logger } from '../helpers/logger' | 8 | import { logger } from '../helpers/logger' |
9 | import { doRequest, doRequestAndSaveToFile } from '../helpers/requests' | 9 | import { doRequest, doRequestAndSaveToFile } from '../helpers/requests' |
10 | import { generateRandomString } from '../helpers/utils' | 10 | import { 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 @@ | |||
1 | import { Job } from 'bull' | ||
2 | import { move, remove } from 'fs-extra' | ||
3 | import { join } from 'path' | ||
4 | import { addIntroOutro, addWatermark, cutVideo } from '@server/helpers/ffmpeg' | ||
5 | import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent' | ||
6 | import { CONFIG } from '@server/initializers/config' | ||
7 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' | ||
8 | import { generateWebTorrentVideoFilename } from '@server/lib/paths' | ||
9 | import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles' | ||
10 | import { isAbleToUploadVideo } from '@server/lib/user' | ||
11 | import { addMoveToObjectStorageJob, addOptimizeOrMergeAudioJob } from '@server/lib/video' | ||
12 | import { approximateIntroOutroAdditionalSize } from '@server/lib/video-editor' | ||
13 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
14 | import { buildNextVideoState } from '@server/lib/video-state' | ||
15 | import { UserModel } from '@server/models/user/user' | ||
16 | import { VideoModel } from '@server/models/video/video' | ||
17 | import { VideoFileModel } from '@server/models/video/video-file' | ||
18 | import { MVideo, MVideoFile, MVideoFullLight, MVideoId, MVideoWithAllFiles } from '@server/types/models' | ||
19 | import { getLowercaseExtension, pick } from '@shared/core-utils' | ||
20 | import { | ||
21 | buildFileMetadata, | ||
22 | buildUUID, | ||
23 | ffprobePromise, | ||
24 | getFileSize, | ||
25 | getVideoStreamDimensionsInfo, | ||
26 | getVideoStreamDuration, | ||
27 | getVideoStreamFPS | ||
28 | } from '@shared/extra-utils' | ||
29 | import { | ||
30 | VideoEditionPayload, | ||
31 | VideoEditionTaskPayload, | ||
32 | VideoEditorTask, | ||
33 | VideoEditorTaskCutPayload, | ||
34 | VideoEditorTaskIntroPayload, | ||
35 | VideoEditorTaskOutroPayload, | ||
36 | VideoEditorTaskWatermarkPayload, | ||
37 | VideoState | ||
38 | } from '@shared/models' | ||
39 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | ||
40 | |||
41 | const lTagsBase = loggerTagsFactory('video-edition') | ||
42 | |||
43 | async 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 | |||
114 | export { | ||
115 | processVideoEdition | ||
116 | } | ||
117 | |||
118 | // --------------------------------------------------------------------------- | ||
119 | |||
120 | type TaskProcessorOptions <T extends VideoEditionTaskPayload = VideoEditionTaskPayload> = { | ||
121 | inputPath: string | ||
122 | outputPath: string | ||
123 | video: MVideo | ||
124 | task: T | ||
125 | } | ||
126 | |||
127 | const 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 | |||
134 | async 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 | |||
145 | function 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 | |||
161 | function 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 | |||
172 | function 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 | |||
185 | async 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 | |||
204 | async 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 | |||
220 | async 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 @@ | |||
1 | import { Job } from 'bull' | 1 | import { Job } from 'bull' |
2 | import { copy, stat } from 'fs-extra' | 2 | import { copy, stat } from 'fs-extra' |
3 | import { getLowercaseExtension } from '@shared/core-utils' | ||
4 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' | 3 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' |
5 | import { CONFIG } from '@server/initializers/config' | 4 | import { CONFIG } from '@server/initializers/config' |
6 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' | 5 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' |
7 | import { generateWebTorrentVideoFilename } from '@server/lib/paths' | 6 | import { generateWebTorrentVideoFilename } from '@server/lib/paths' |
8 | import { addMoveToObjectStorageJob } from '@server/lib/video' | 7 | import { addMoveToObjectStorageJob } from '@server/lib/video' |
9 | import { VideoPathManager } from '@server/lib/video-path-manager' | 8 | import { VideoPathManager } from '@server/lib/video-path-manager' |
9 | import { VideoModel } from '@server/models/video/video' | ||
10 | import { VideoFileModel } from '@server/models/video/video-file' | ||
10 | import { MVideoFullLight } from '@server/types/models' | 11 | import { MVideoFullLight } from '@server/types/models' |
12 | import { getLowercaseExtension } from '@shared/core-utils' | ||
11 | import { VideoFileImportPayload, VideoStorage } from '@shared/models' | 13 | import { VideoFileImportPayload, VideoStorage } from '@shared/models' |
12 | import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' | 14 | import { getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg' |
13 | import { logger } from '../../../helpers/logger' | 15 | import { logger } from '../../../helpers/logger' |
14 | import { VideoModel } from '../../../models/video/video' | ||
15 | import { VideoFileModel } from '../../../models/video/video-file' | ||
16 | 16 | ||
17 | async function processVideoFileImport (job: Job) { | 17 | async 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 | ||
47 | async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) { | 47 | async 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' |
28 | import { ffprobePromise, getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' | 28 | import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg' |
29 | import { logger } from '../../../helpers/logger' | 29 | import { logger } from '../../../helpers/logger' |
30 | import { getSecureTorrentName } from '../../../helpers/utils' | 30 | import { getSecureTorrentName } from '../../../helpers/utils' |
31 | import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent' | 31 | import { 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 @@ | |||
1 | import { Job } from 'bull' | 1 | import { Job } from 'bull' |
2 | import { pathExists, readdir, remove } from 'fs-extra' | 2 | import { pathExists, readdir, remove } from 'fs-extra' |
3 | import { join } from 'path' | 3 | import { join } from 'path' |
4 | import { ffprobePromise, getAudioStream, getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils' | 4 | import { ffprobePromise, getAudioStream, getVideoStreamDuration, getVideoStreamDimensionsInfo } from '@server/helpers/ffmpeg' |
5 | import { VIDEO_LIVE } from '@server/initializers/constants' | 5 | import { VIDEO_LIVE } from '@server/initializers/constants' |
6 | import { buildConcatenatedName, cleanupLive, LiveSegmentShaStore } from '@server/lib/live' | 6 | import { buildConcatenatedName, cleanupLive, LiveSegmentShaStore } from '@server/lib/live' |
7 | import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveDirectory } from '@server/lib/paths' | 7 | import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveDirectory } from '@server/lib/paths' |
8 | import { generateVideoMiniature } from '@server/lib/thumbnail' | 8 | import { generateVideoMiniature } from '@server/lib/thumbnail' |
9 | import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/video-transcoding' | 9 | import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding' |
10 | import { VideoPathManager } from '@server/lib/video-path-manager' | 10 | import { VideoPathManager } from '@server/lib/video-path-manager' |
11 | import { moveToNextState } from '@server/lib/video-state' | 11 | import { moveToNextState } from '@server/lib/video-state' |
12 | import { VideoModel } from '@server/models/video/video' | 12 | import { 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 @@ | |||
1 | import { Job } from 'bull' | 1 | import { Job } from 'bull' |
2 | import { TranscodeOptionsType } from '@server/helpers/ffmpeg-utils' | 2 | import { TranscodeVODOptionsType } from '@server/helpers/ffmpeg' |
3 | import { addTranscodingJob, getTranscodingJobPriority } from '@server/lib/video' | 3 | import { addTranscodingJob, getTranscodingJobPriority } from '@server/lib/video' |
4 | import { VideoPathManager } from '@server/lib/video-path-manager' | 4 | import { VideoPathManager } from '@server/lib/video-path-manager' |
5 | import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state' | 5 | import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state' |
@@ -16,7 +16,7 @@ import { | |||
16 | VideoTranscodingPayload | 16 | VideoTranscodingPayload |
17 | } from '@shared/models' | 17 | } from '@shared/models' |
18 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | 18 | import { retryTransactionWrapper } from '../../../helpers/database-utils' |
19 | import { computeLowerResolutionsToTranscode } from '../../../helpers/ffprobe-utils' | 19 | import { computeLowerResolutionsToTranscode } from '../../../helpers/ffmpeg' |
20 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | 20 | import { logger, loggerTagsFactory } from '../../../helpers/logger' |
21 | import { CONFIG } from '../../../initializers/config' | 21 | import { CONFIG } from '../../../initializers/config' |
22 | import { VideoModel } from '../../../models/video/video' | 22 | import { 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 | ||
30 | type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<void> | 30 | type 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 | |||
174 | async function onVideoFirstWebTorrentTranscoding ( | 174 | async 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' | |||
31 | import { processActorKeys } from './handlers/actor-keys' | 32 | import { processActorKeys } from './handlers/actor-keys' |
32 | import { processEmail } from './handlers/email' | 33 | import { processEmail } from './handlers/email' |
33 | import { processMoveToObjectStorage } from './handlers/move-to-object-storage' | 34 | import { processMoveToObjectStorage } from './handlers/move-to-object-storage' |
35 | import { processVideoEdition } from './handlers/video-edition' | ||
34 | import { processVideoFileImport } from './handlers/video-file-import' | 36 | import { processVideoFileImport } from './handlers/video-file-import' |
35 | import { processVideoImport } from './handlers/video-import' | 37 | import { processVideoImport } from './handlers/video-import' |
36 | import { processVideoLiveEnding } from './handlers/video-live-ending' | 38 | import { 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 | ||
58 | export type CreateJobOptions = { | 61 | export 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 | ||
81 | const jobTypes: JobType[] = [ | 85 | const 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 | ||
99 | class JobQueue { | 104 | class 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' | |||
5 | import { | 5 | import { |
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' |
12 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | 12 | import { logger, loggerTagsFactory } from '@server/helpers/logger' |
13 | import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config' | 13 | import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config' |
14 | import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE } from '@server/initializers/constants' | 14 | import { 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' | |||
5 | import { appendFile, ensureDir, readFile, stat } from 'fs-extra' | 5 | import { appendFile, ensureDir, readFile, stat } from 'fs-extra' |
6 | import { basename, join } from 'path' | 6 | import { basename, join } from 'path' |
7 | import { EventEmitter } from 'stream' | 7 | import { EventEmitter } from 'stream' |
8 | import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg-utils' | 8 | import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg' |
9 | import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger' | 9 | import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger' |
10 | import { CONFIG } from '@server/initializers/config' | 10 | import { CONFIG } from '@server/initializers/config' |
11 | import { MEMOIZE_TTL, VIDEO_LIVE } from '@server/initializers/constants' | 11 | import { MEMOIZE_TTL, VIDEO_LIVE } from '@server/initializers/constants' |
12 | import { VideoFileModel } from '@server/models/video/video-file' | 12 | import { VideoFileModel } from '@server/models/video/video-file' |
13 | import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models' | 13 | import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models' |
14 | import { getLiveDirectory } from '../../paths' | 14 | import { getLiveDirectory } from '../../paths' |
15 | import { VideoTranscodingProfilesManager } from '../../transcoding/video-transcoding-profiles' | 15 | import { VideoTranscodingProfilesManager } from '../../transcoding/default-transcoding-profiles' |
16 | import { isAbleToUploadVideo } from '../../user' | 16 | import { isAbleToUploadVideo } from '../../user' |
17 | import { LiveQuotaStore } from '../live-quota-store' | 17 | import { LiveQuotaStore } from '../live-quota-store' |
18 | import { LiveSegmentShaStore } from '../live-segment-sha-store' | 18 | import { 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 @@ | |||
1 | import 'multer' | ||
2 | import { queue } from 'async' | 1 | import { queue } from 'async' |
2 | import { remove } from 'fs-extra' | ||
3 | import LRUCache from 'lru-cache' | 3 | import LRUCache from 'lru-cache' |
4 | import { join } from 'path' | 4 | import { join } from 'path' |
5 | import { ActorModel } from '@server/models/actor/actor' | 5 | import { ActorModel } from '@server/models/actor/actor' |
@@ -13,7 +13,7 @@ import { CONFIG } from '../initializers/config' | |||
13 | import { ACTOR_IMAGES_SIZE, LRU_CACHE, QUEUE_CONCURRENCY, WEBSERVER } from '../initializers/constants' | 13 | import { ACTOR_IMAGES_SIZE, LRU_CACHE, QUEUE_CONCURRENCY, WEBSERVER } from '../initializers/constants' |
14 | import { sequelizeTypescript } from '../initializers/database' | 14 | import { sequelizeTypescript } from '../initializers/database' |
15 | import { MAccountDefault, MActor, MChannelDefault } from '../types/models' | 15 | import { MAccountDefault, MActor, MChannelDefault } from '../types/models' |
16 | import { deleteActorImageInstance, updateActorImageInstance } from './activitypub/actors' | 16 | import { deleteActorImages, updateActorImages } from './activitypub/actors' |
17 | import { sendUpdateActor } from './activitypub/send' | 17 | import { sendUpdateActor } from './activitypub/send' |
18 | 18 | ||
19 | function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string) { | 19 | function 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 | ||
36 | async function updateLocalActorImageFile ( | 36 | async 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 | ||
73 | async function deleteLocalActorImageFile (accountOrChannel: MAccountDefault | MChannelDefault, type: ActorImageType) { | 77 | async 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 | ||
86 | type DownloadImageQueueTask = { fileUrl: string, filename: string, type: ActorImageType } | 90 | type DownloadImageQueueTask = { |
91 | fileUrl: string | ||
92 | filename: string | ||
93 | type: ActorImageType | ||
94 | size: typeof ACTOR_IMAGES_SIZE[ActorImageType][0] | ||
95 | } | ||
87 | 96 | ||
88 | const downloadImageQueue = queue<DownloadImageQueueTask, Error>((task, cb) => { | 97 | const 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 | ||
111 | export { | 116 | export { |
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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { join } from 'path' | 2 | import { join } from 'path' |
3 | import { ffprobePromise } from '@server/helpers/ffprobe-utils' | 3 | import { ffprobePromise } from '@server/helpers/ffmpeg/ffprobe-utils' |
4 | import { buildLogger } from '@server/helpers/logger' | 4 | import { buildLogger } from '@server/helpers/logger' |
5 | import { CONFIG } from '@server/initializers/config' | 5 | import { CONFIG } from '@server/initializers/config' |
6 | import { WEBSERVER } from '@server/initializers/constants' | 6 | import { 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' |
24 | import { VideoTranscodingProfilesManager } from '../transcoding/video-transcoding-profiles' | 24 | import { VideoTranscodingProfilesManager } from '../transcoding/default-transcoding-profiles' |
25 | import { buildPluginHelpers } from './plugin-helpers-builder' | 25 | import { buildPluginHelpers } from './plugin-helpers-builder' |
26 | 26 | ||
27 | export class RegisterHelpers { | 27 | export 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 | |||
8 | import { Hooks } from './plugins/hooks' | 8 | import { Hooks } from './plugins/hooks' |
9 | import { PluginManager } from './plugins/plugin-manager' | 9 | import { PluginManager } from './plugins/plugin-manager' |
10 | import { getThemeOrDefault } from './plugins/theme-utils' | 10 | import { getThemeOrDefault } from './plugins/theme-utils' |
11 | import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles' | 11 | import { 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 @@ | |||
1 | import { join } from 'path' | 1 | import { join } from 'path' |
2 | import { ThumbnailType } from '../../shared/models/videos/thumbnail.type' | 2 | import { ThumbnailType } from '@shared/models' |
3 | import { generateImageFromVideoFile } from '../helpers/ffmpeg-utils' | 3 | import { generateImageFilename, generateImageFromVideoFile, processImage } from '../helpers/image-utils' |
4 | import { generateImageFilename, processImage } from '../helpers/image-utils' | ||
5 | import { downloadImage } from '../helpers/requests' | 4 | import { downloadImage } from '../helpers/requests' |
6 | import { CONFIG } from '../initializers/config' | 5 | import { CONFIG } from '../initializers/config' |
7 | import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants' | 6 | import { 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 @@ | |||
2 | import { logger } from '@server/helpers/logger' | 2 | import { logger } from '@server/helpers/logger' |
3 | import { getAverageBitrate, getMinLimitBitrate } from '@shared/core-utils' | 3 | import { getAverageBitrate, getMinLimitBitrate } from '@shared/core-utils' |
4 | import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '../../../shared/models/videos' | 4 | import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '../../../shared/models/videos' |
5 | import { buildStreamSuffix, resetSupportedEncoders } from '../../helpers/ffmpeg-utils' | 5 | import { |
6 | import { 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 | |||
18 | const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => { | 28 | const 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 | ||
48 | const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum }) => { | 60 | const 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 | |||
79 | class VideoTranscodingProfilesManager { | 94 | class 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' | |||
6 | import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models' | 6 | import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models' |
7 | import { VideoResolution, VideoStorage } from '../../../shared/models/videos' | 7 | import { VideoResolution, VideoStorage } from '../../../shared/models/videos' |
8 | import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' | 8 | import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' |
9 | import { transcode, TranscodeOptions, TranscodeOptionsType } from '../../helpers/ffmpeg-utils' | 9 | import { |
10 | import { 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' | ||
11 | import { CONFIG } from '../../initializers/config' | 18 | import { CONFIG } from '../../initializers/config' |
12 | import { P2P_MEDIA_LOADER_PEER_VERSION } from '../../initializers/constants' | 19 | import { P2P_MEDIA_LOADER_PEER_VERSION } from '../../initializers/constants' |
13 | import { VideoFileModel } from '../../models/video/video-file' | 20 | import { VideoFileModel } from '../../models/video/video-file' |
@@ -21,7 +28,7 @@ import { | |||
21 | getHlsResolutionPlaylistFilename | 28 | getHlsResolutionPlaylistFilename |
22 | } from '../paths' | 29 | } from '../paths' |
23 | import { VideoPathManager } from '../video-path-manager' | 30 | import { VideoPathManager } from '../video-path-manager' |
24 | import { VideoTranscodingProfilesManager } from './video-transcoding-profiles' | 31 | import { 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 @@ | |||
1 | import { Transaction } from 'sequelize/types' | 1 | import { Transaction } from 'sequelize/types' |
2 | import { logger } from '@server/helpers/logger' | ||
3 | import { CONFIG } from '@server/initializers/config' | ||
2 | import { UserModel } from '@server/models/user/user' | 4 | import { UserModel } from '@server/models/user/user' |
3 | import { MActorDefault } from '@server/types/models/actor' | 5 | import { MActorDefault } from '@server/types/models/actor' |
4 | import { buildUUID } from '@shared/extra-utils' | 6 | import { buildUUID } from '@shared/extra-utils' |
5 | import { ActivityPubActorType } from '../../shared/models/activitypub' | 7 | import { ActivityPubActorType } from '../../shared/models/activitypub' |
6 | import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users' | 8 | import { UserAdminFlag, UserNotificationSetting, UserNotificationSettingValue, UserRole } from '../../shared/models/users' |
7 | import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants' | 9 | import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants' |
8 | import { sequelizeTypescript } from '../initializers/database' | 10 | import { sequelizeTypescript } from '../initializers/database' |
9 | import { AccountModel } from '../models/account/account' | 11 | import { AccountModel } from '../models/account/account' |
@@ -22,6 +24,53 @@ import { createWatchLaterPlaylist } from './video-playlist' | |||
22 | 24 | ||
23 | type ChannelNames = { name: string, displayName: string } | 25 | type ChannelNames = { name: string, displayName: string } |
24 | 26 | ||
27 | function 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 | |||
25 | async function createUserAccountAndChannelAndPlaylist (parameters: { | 74 | async 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 | ||
123 | async function getOriginalVideoFileTotalFromUser (user: MUserId) { | 172 | async 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 @@ | |||
1 | import { MVideoFullLight } from "@server/types/models" | ||
2 | import { getVideoStreamDuration } from "@shared/extra-utils" | ||
3 | import { VideoEditorTask } from "@shared/models" | ||
4 | |||
5 | function buildTaskFileFieldname (indice: number, fieldName = 'file') { | ||
6 | return `tasks[${indice}][options][${fieldName}]` | ||
7 | } | ||
8 | |||
9 | function getTaskFile (files: Express.Multer.File[], indice: number, fieldName = 'file') { | ||
10 | return files.find(f => f.fieldname === buildTaskFileFieldname(indice, fieldName)) | ||
11 | } | ||
12 | |||
13 | async 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 | |||
28 | export { | ||
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 | ||
84 | async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoFile, user: MUserId) { | 84 | async 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 | |||
166 | function 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 @@ | |||
1 | import { Request, Response } from 'express' | 1 | import { Request, Response } from 'express' |
2 | import { loadVideo, VideoLoadType } from '@server/lib/model-loaders' | 2 | import { loadVideo, VideoLoadType } from '@server/lib/model-loaders' |
3 | import { isAbleToUploadVideo } from '@server/lib/user' | ||
3 | import { authenticatePromiseIfNeeded } from '@server/middlewares/auth' | 4 | import { authenticatePromiseIfNeeded } from '@server/middlewares/auth' |
4 | import { VideoModel } from '@server/models/video/video' | 5 | import { VideoModel } from '@server/models/video/video' |
5 | import { VideoChannelModel } from '@server/models/video/video-channel' | 6 | import { VideoChannelModel } from '@server/models/video/video-channel' |
@@ -7,6 +8,7 @@ import { VideoFileModel } from '@server/models/video/video-file' | |||
7 | import { | 8 | import { |
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' |
19 | import { HttpStatusCode, UserRight } from '@shared/models' | 21 | import { HttpStatusCode, ServerErrorCode, UserRight } from '@shared/models' |
20 | 22 | ||
21 | async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { | 23 | async 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 | ||
149 | async 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 | ||
144 | export { | 164 | export { |
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' | |||
2 | export * from './video-captions' | 2 | export * from './video-captions' |
3 | export * from './video-channels' | 3 | export * from './video-channels' |
4 | export * from './video-comments' | 4 | export * from './video-comments' |
5 | export * from './video-editor' | ||
5 | export * from './video-files' | 6 | export * from './video-files' |
6 | export * from './video-imports' | 7 | export * from './video-imports' |
7 | export * from './video-live' | 8 | export * 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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { body, param } from 'express-validator' | 2 | import { body, param } from 'express-validator' |
3 | import { HttpStatusCode, UserRight } from '@shared/models' | 3 | import { UserRight } from '@shared/models' |
4 | import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions' | 4 | import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions' |
5 | import { cleanUpReqFiles } from '../../../helpers/express-utils' | 5 | import { cleanUpReqFiles } from '../../../helpers/express-utils' |
6 | import { logger } from '../../../helpers/logger' | 6 | import { 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 @@ | |||
1 | import express from 'express' | ||
2 | import { body, param } from 'express-validator' | ||
3 | import { isIdOrUUIDValid } from '@server/helpers/custom-validators/misc' | ||
4 | import { | ||
5 | isEditorCutTaskValid, | ||
6 | isEditorTaskAddIntroOutroValid, | ||
7 | isEditorTaskAddWatermarkValid, | ||
8 | isValidEditorTasksArray | ||
9 | } from '@server/helpers/custom-validators/video-editor' | ||
10 | import { cleanUpReqFiles } from '@server/helpers/express-utils' | ||
11 | import { CONFIG } from '@server/initializers/config' | ||
12 | import { approximateIntroOutroAdditionalSize, getTaskFile } from '@server/lib/video-editor' | ||
13 | import { isAudioFile } from '@shared/extra-utils' | ||
14 | import { HttpStatusCode, UserRight, VideoEditorCreateEdition, VideoEditorTask, VideoState } from '@shared/models' | ||
15 | import { logger } from '../../../helpers/logger' | ||
16 | import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared' | ||
17 | |||
18 | const 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 | |||
92 | export { | ||
93 | videosEditorAddEditionValidator | ||
94 | } | ||
95 | |||
96 | // --------------------------------------------------------------------------- | ||
97 | |||
98 | const 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 | |||
107 | function 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' | |||
3 | import { isIdValid } from '@server/helpers/custom-validators/misc' | 3 | import { isIdValid } from '@server/helpers/custom-validators/misc' |
4 | import { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership' | 4 | import { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership' |
5 | import { logger } from '@server/helpers/logger' | 5 | import { logger } from '@server/helpers/logger' |
6 | import { isAbleToUploadVideo } from '@server/lib/user' | ||
7 | import { AccountModel } from '@server/models/account/account' | 6 | import { AccountModel } from '@server/models/account/account' |
8 | import { MVideoWithAllFiles } from '@server/types/models' | 7 | import { MVideoWithAllFiles } from '@server/types/models' |
9 | import { | 8 | import { HttpStatusCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@shared/models' |
10 | HttpStatusCode, | ||
11 | ServerErrorCode, | ||
12 | UserRight, | ||
13 | VideoChangeOwnershipAccept, | ||
14 | VideoChangeOwnershipStatus, | ||
15 | VideoState | ||
16 | } from '@shared/models' | ||
17 | import { | 9 | import { |
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' |
30 | import { isVideoImage } from '../../../helpers/custom-validators/videos' | 30 | import { isVideoImageValid } from '../../../helpers/custom-validators/videos' |
31 | import { cleanUpReqFiles } from '../../../helpers/express-utils' | 31 | import { cleanUpReqFiles } from '../../../helpers/express-utils' |
32 | import { logger } from '../../../helpers/logger' | 32 | import { logger } from '../../../helpers/logger' |
33 | import { CONSTRAINTS_FIELDS } from '../../../initializers/constants' | 33 | import { CONSTRAINTS_FIELDS } from '../../../initializers/constants' |
@@ -390,7 +390,7 @@ export { | |||
390 | function getCommonPlaylistEditAttributes () { | 390 | function 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' | |||
3 | import { isTestInstance } from '@server/helpers/core-utils' | 3 | import { isTestInstance } from '@server/helpers/core-utils' |
4 | import { getResumableUploadPath } from '@server/helpers/upload' | 4 | import { getResumableUploadPath } from '@server/helpers/upload' |
5 | import { Redis } from '@server/lib/redis' | 5 | import { Redis } from '@server/lib/redis' |
6 | import { isAbleToUploadVideo } from '@server/lib/user' | ||
7 | import { getServerActor } from '@server/models/application/application' | 6 | import { getServerActor } from '@server/models/application/application' |
8 | import { ExpressPromiseHandler } from '@server/types/express-handler' | 7 | import { ExpressPromiseHandler } from '@server/types/express-handler' |
9 | import { MUserAccountId, MVideoFullLight } from '@server/types/models' | 8 | import { 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' |
24 | import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' | 23 | import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' |
25 | import { | 24 | import { |
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' |
42 | import { cleanUpReqFiles } from '../../../helpers/express-utils' | 41 | import { cleanUpReqFiles } from '../../../helpers/express-utils' |
43 | import { getDurationFromVideoFile } from '../../../helpers/ffprobe-utils' | 42 | import { getVideoStreamDuration } from '../../../helpers/ffmpeg' |
44 | import { logger } from '../../../helpers/logger' | 43 | import { logger } from '../../../helpers/logger' |
45 | import { deleteFileAndCatch } from '../../../helpers/utils' | 44 | import { deleteFileAndCatch } from '../../../helpers/utils' |
46 | import { getVideoWithAttributes } from '../../../helpers/video' | 45 | import { 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 | ||
62 | const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ | 62 | const 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 = [ | |||
375 | function getCommonVideoEditAttributes () { | 374 | function 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 | ||
656 | async function addDurationToVideo (videoFile: { path: string, duration?: number }) { | 648 | async 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 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | 1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' |
2 | import { isAbuseMessageValid } from '@server/helpers/custom-validators/abuses' | 2 | import { isAbuseMessageValid } from '@server/helpers/custom-validators/abuses' |
3 | import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models' | 3 | import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models' |
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { AbuseMessage } from '@shared/models' | 4 | import { AbuseMessage } from '@shared/models' |
5 | import { AttributesOnly } from '@shared/typescript-utils' | ||
6 | import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' | 6 | import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' |
7 | import { getSort, throwIfNotValid } from '../utils' | 7 | import { getSort, throwIfNotValid } from '../utils' |
8 | import { AbuseModel } from './abuse' | 8 | import { AbuseModel } from './abuse' |
9 | import { 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 @@ | |||
1 | import { Op, QueryTypes } from 'sequelize' | 1 | import { FindOptions, Op, QueryTypes } from 'sequelize' |
2 | import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | 2 | import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' |
3 | import { handlesToNameAndHost } from '@server/helpers/actors' | 3 | import { handlesToNameAndHost } from '@server/helpers/actors' |
4 | import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models' | 4 | import { MAccountBlocklist, MAccountBlocklistFormattable } from '@server/types/models' |
5 | import { AttributesOnly } from '@shared/typescript-utils' | 5 | import { AttributesOnly } from '@shared/typescript-utils' |
6 | import { AccountBlock } from '../../../shared/models' | 6 | import { AccountBlock } from '../../../shared/models' |
7 | import { ActorModel } from '../actor/actor' | 7 | import { ActorModel } from '../actor/actor' |
@@ -9,27 +9,6 @@ import { ServerModel } from '../server/server' | |||
9 | import { createSafeIn, getSort, searchAttribute } from '../utils' | 9 | import { createSafeIn, getSort, searchAttribute } from '../utils' |
10 | import { AccountModel } from './account' | 10 | import { AccountModel } from './account' |
11 | 11 | ||
12 | enum 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 @@ | |||
1 | import { difference, values } from 'lodash' | 1 | import { difference, values } from 'lodash' |
2 | import { IncludeOptions, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize' | 2 | import { Includeable, IncludeOptions, literal, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize' |
3 | import { | 3 | import { |
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' |
33 | import { AttributesOnly } from '@shared/typescript-utils' | ||
34 | import { ActivityPubActorType } from '@shared/models' | 33 | import { ActivityPubActorType } from '@shared/models' |
34 | import { AttributesOnly } from '@shared/typescript-utils' | ||
35 | import { FollowState } from '../../../shared/models/actors' | 35 | import { FollowState } from '../../../shared/models/actors' |
36 | import { ActorFollow } from '../../../shared/models/actors/follow.model' | 36 | import { ActorFollow } from '../../../shared/models/actors/follow.model' |
37 | import { logger } from '../../helpers/logger' | 37 | import { logger } from '../../helpers/logger' |
38 | import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants' | 38 | import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME, SORTABLE_COLUMNS } from '../../initializers/constants' |
39 | import { AccountModel } from '../account/account' | 39 | import { AccountModel } from '../account/account' |
40 | import { ServerModel } from '../server/server' | 40 | import { ServerModel } from '../server/server' |
41 | import { doesExist } from '../shared/query' | 41 | import { 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 @@ | |||
1 | import { remove } from 'fs-extra' | 1 | import { remove } from 'fs-extra' |
2 | import { join } from 'path' | 2 | import { join } from 'path' |
3 | import { AfterDestroy, AllowNull, Column, CreatedAt, Default, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | 3 | import { |
4 | import { 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' | ||
16 | import { MActorImage, MActorImageFormattable } from '@server/types/models' | ||
17 | import { getLowercaseExtension } from '@shared/core-utils' | ||
18 | import { ActivityIconObject, ActorImageType } from '@shared/models' | ||
5 | import { AttributesOnly } from '@shared/typescript-utils' | 19 | import { AttributesOnly } from '@shared/typescript-utils' |
6 | import { ActorImageType } from '@shared/models' | ||
7 | import { ActorImage } from '../../../shared/models/actors/actor-image.model' | 20 | import { ActorImage } from '../../../shared/models/actors/actor-image.model' |
8 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | 21 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' |
9 | import { logger } from '../../helpers/logger' | 22 | import { logger } from '../../helpers/logger' |
10 | import { CONFIG } from '../../initializers/config' | 23 | import { CONFIG } from '../../initializers/config' |
11 | import { LAZY_STATIC_PATHS } from '../../initializers/constants' | 24 | import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants' |
12 | import { throwIfNotValid } from '../utils' | 25 | import { throwIfNotValid } from '../utils' |
26 | import { 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' |
19 | import { getBiggestActorImage } from '@server/lib/actor-image' | ||
19 | import { ModelCache } from '@server/models/model-cache' | 20 | import { ModelCache } from '@server/models/model-cache' |
20 | import { getLowercaseExtension } from '@shared/core-utils' | 21 | import { getLowercaseExtension } from '@shared/core-utils' |
22 | import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models' | ||
21 | import { AttributesOnly } from '@shared/typescript-utils' | 23 | import { AttributesOnly } from '@shared/typescript-utils' |
22 | import { ActivityIconObject, ActivityPubActorType } from '../../../shared/models/activitypub' | ||
23 | import { ActorImage } from '../../../shared/models/actors/actor-image.model' | ||
24 | import { activityPubContextify } from '../../helpers/activitypub' | 24 | import { activityPubContextify } from '../../helpers/activitypub' |
25 | import { | 25 | import { |
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 @@ | |||
1 | import { Op, QueryTypes } from 'sequelize' | 1 | import { Op, QueryTypes } from 'sequelize' |
2 | import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | 2 | import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' |
3 | import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models' | 3 | import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models' |
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { ServerBlock } from '@shared/models' | 4 | import { ServerBlock } from '@shared/models' |
5 | import { AttributesOnly } from '@shared/typescript-utils' | ||
6 | import { AccountModel } from '../account/account' | 6 | import { AccountModel } from '../account/account' |
7 | import { createSafeIn, getSort, searchAttribute } from '../utils' | 7 | import { createSafeIn, getSort, searchAttribute } from '../utils' |
8 | import { ServerModel } from './server' | 8 | import { 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 | ||
9 | export class AbstractRunQuery { | 9 | export 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 @@ | |||
1 | export * from './abstract-run-query' | ||
2 | export * from './model-builder' | ||
1 | export * from './query' | 3 | export * from './query' |
2 | export * from './update' | 4 | export * 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 @@ | |||
1 | import { isPlainObject } from 'lodash' | ||
2 | import { Model as SequelizeModel, Sequelize } from 'sequelize' | ||
3 | import { logger } from '@server/helpers/logger' | ||
4 | |||
5 | export 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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | import { AbstractRunQuery, ModelBuilder } from '@server/models/shared' | ||
3 | import { getSort } from '@server/models/utils' | ||
4 | import { UserNotificationModelForApi } from '@server/types/models' | ||
5 | import { ActorImageType } from '@shared/models' | ||
6 | |||
7 | export interface ListNotificationsOptions { | ||
8 | userId: number | ||
9 | unread?: boolean | ||
10 | sort: string | ||
11 | offset: number | ||
12 | limit: number | ||
13 | } | ||
14 | |||
15 | export class UserNotificationListQueryBuilder extends AbstractRunQuery { | ||
16 | private innerQuery: string | ||
17 | |||
18 | constructor ( | ||
19 | protected readonly sequelize: Sequelize, | ||
20 | private readonly options: ListNotificationsOptions | ||
21 | ) { | ||
22 | super(sequelize) | ||
23 | } | ||
24 | |||
25 | async listNotifications () { | ||
26 | this.buildQuery() | ||
27 | |||
28 | const results = await this.runQuery({ nest: true }) | ||
29 | const modelBuilder = new ModelBuilder<UserNotificationModelForApi>(this.sequelize) | ||
30 | |||
31 | return modelBuilder.createModels(results, 'UserNotification') | ||
32 | } | ||
33 | |||
34 | private buildInnerQuery () { | ||
35 | this.innerQuery = `SELECT * FROM "userNotification" AS "UserNotificationModel" ` + | ||
36 | `${this.getWhere()} ` + | ||
37 | `${this.getOrder()} ` + | ||
38 | `LIMIT :limit OFFSET :offset ` | ||
39 | |||
40 | this.replacements.limit = this.options.limit | ||
41 | this.replacements.offset = this.options.offset | ||
42 | } | ||
43 | |||
44 | private buildQuery () { | ||
45 | this.buildInnerQuery() | ||
46 | |||
47 | this.query = ` | ||
48 | ${this.getSelect()} | ||
49 | FROM (${this.innerQuery}) "UserNotificationModel" | ||
50 | ${this.getJoins()} | ||
51 | ${this.getOrder()}` | ||
52 | } | ||
53 | |||
54 | private getWhere () { | ||
55 | let base = '"UserNotificationModel"."userId" = :userId ' | ||
56 | this.replacements.userId = this.options.userId | ||
57 | |||
58 | if (this.options.unread === true) { | ||
59 | base += 'AND "UserNotificationModel"."read" IS FALSE ' | ||
60 | } else if (this.options.unread === false) { | ||
61 | base += 'AND "UserNotificationModel"."read" IS TRUE ' | ||
62 | } | ||
63 | |||
64 | return `WHERE ${base}` | ||
65 | } | ||
66 | |||
67 | private getOrder () { | ||
68 | const orders = getSort(this.options.sort) | ||
69 | |||
70 | return 'ORDER BY ' + orders.map(o => `"UserNotificationModel"."${o[0]}" ${o[1]}`).join(', ') | ||
71 | } | ||
72 | |||
73 | private getSelect () { | ||
74 | return `SELECT | ||
75 | "UserNotificationModel"."id", | ||
76 | "UserNotificationModel"."type", | ||
77 | "UserNotificationModel"."read", | ||
78 | "UserNotificationModel"."createdAt", | ||
79 | "UserNotificationModel"."updatedAt", | ||
80 | "Video"."id" AS "Video.id", | ||
81 | "Video"."uuid" AS "Video.uuid", | ||
82 | "Video"."name" AS "Video.name", | ||
83 | "Video->VideoChannel"."id" AS "Video.VideoChannel.id", | ||
84 | "Video->VideoChannel"."name" AS "Video.VideoChannel.name", | ||
85 | "Video->VideoChannel->Actor"."id" AS "Video.VideoChannel.Actor.id", | ||
86 | "Video->VideoChannel->Actor"."preferredUsername" AS "Video.VideoChannel.Actor.preferredUsername", | ||
87 | "Video->VideoChannel->Actor->Avatars"."id" AS "Video.VideoChannel.Actor.Avatars.id", | ||
88 | "Video->VideoChannel->Actor->Avatars"."width" AS "Video.VideoChannel.Actor.Avatars.width", | ||
89 | "Video->VideoChannel->Actor->Avatars"."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 @@ | |||
1 | import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize' | 1 | import { ModelIndexesOptions, Op, WhereOptions } from 'sequelize' |
2 | import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | 2 | import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' |
3 | import { getBiggestActorImage } from '@server/lib/actor-image' | ||
3 | import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user' | 4 | import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user' |
4 | import { uuidToShort } from '@shared/extra-utils' | 5 | import { uuidToShort } from '@shared/extra-utils' |
5 | import { UserNotification, UserNotificationType } from '@shared/models' | 6 | import { UserNotification, UserNotificationType } from '@shared/models' |
@@ -7,207 +8,18 @@ import { AttributesOnly } from '@shared/typescript-utils' | |||
7 | import { isBooleanValid } from '../../helpers/custom-validators/misc' | 8 | import { isBooleanValid } from '../../helpers/custom-validators/misc' |
8 | import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications' | 9 | import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications' |
9 | import { AbuseModel } from '../abuse/abuse' | 10 | import { AbuseModel } from '../abuse/abuse' |
10 | import { VideoAbuseModel } from '../abuse/video-abuse' | ||
11 | import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' | ||
12 | import { AccountModel } from '../account/account' | 11 | import { AccountModel } from '../account/account' |
13 | import { ActorModel } from '../actor/actor' | ||
14 | import { ActorFollowModel } from '../actor/actor-follow' | 12 | import { ActorFollowModel } from '../actor/actor-follow' |
15 | import { ActorImageModel } from '../actor/actor-image' | ||
16 | import { ApplicationModel } from '../application/application' | 13 | import { ApplicationModel } from '../application/application' |
17 | import { PluginModel } from '../server/plugin' | 14 | import { PluginModel } from '../server/plugin' |
18 | import { ServerModel } from '../server/server' | 15 | import { throwIfNotValid } from '../utils' |
19 | import { getSort, throwIfNotValid } from '../utils' | ||
20 | import { VideoModel } from '../video/video' | 16 | import { VideoModel } from '../video/video' |
21 | import { VideoBlacklistModel } from '../video/video-blacklist' | 17 | import { VideoBlacklistModel } from '../video/video-blacklist' |
22 | import { VideoChannelModel } from '../video/video-channel' | ||
23 | import { VideoCommentModel } from '../video/video-comment' | 18 | import { VideoCommentModel } from '../video/video-comment' |
24 | import { VideoImportModel } from '../video/video-import' | 19 | import { VideoImportModel } from '../video/video-import' |
20 | import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder' | ||
25 | import { UserModel } from './user' | 21 | import { UserModel } from './user' |
26 | 22 | ||
27 | enum ScopeNames { | ||
28 | WITH_ALL = 'WITH_ALL' | ||
29 | } | ||
30 | |||
31 | function 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 | |||
52 | function buildVideoInclude (required: boolean) { | ||
53 | return { | ||
54 | attributes: [ 'id', 'uuid', 'name' ], | ||
55 | model: VideoModel.unscoped(), | ||
56 | required | ||
57 | } | ||
58 | } | ||
59 | |||
60 | function 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 | |||
69 | function 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 | ||
187 | function buildWhereIdOrUUID (id: number | string) { | 187 | function 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 @@ | |||
1 | export * from './video-model-get-query-builder' | ||
2 | export * from './videos-id-list-query-builder' | ||
3 | export * from './videos-model-list-query-builder' | ||
diff --git a/server/models/video/sql/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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | import validator from 'validator' | ||
1 | import { createSafeIn } from '@server/models/utils' | 3 | import { createSafeIn } from '@server/models/utils' |
2 | import { MUserAccountId } from '@server/types/models' | 4 | import { MUserAccountId } from '@server/types/models' |
3 | import validator from 'validator' | 5 | import { ActorImageType } from '@shared/models' |
4 | import { AbstractRunQuery } from './abstract-run-query' | 6 | import { AbstractRunQuery } from '../../../../shared/abstract-run-query' |
5 | import { VideoTableAttributes } from './video-table-attributes' | 7 | import { 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' | |||
9 | import { TrackerModel } from '@server/models/server/tracker' | 9 | import { TrackerModel } from '@server/models/server/tracker' |
10 | import { UserVideoHistoryModel } from '@server/models/user/user-video-history' | 10 | import { UserVideoHistoryModel } from '@server/models/user/user-video-history' |
11 | import { VideoInclude } from '@shared/models' | 11 | import { VideoInclude } from '@shared/models' |
12 | import { ScheduleVideoUpdateModel } from '../../schedule-video-update' | 12 | import { ScheduleVideoUpdateModel } from '../../../schedule-video-update' |
13 | import { TagModel } from '../../tag' | 13 | import { TagModel } from '../../../tag' |
14 | import { ThumbnailModel } from '../../thumbnail' | 14 | import { ThumbnailModel } from '../../../thumbnail' |
15 | import { VideoModel } from '../../video' | 15 | import { VideoModel } from '../../../video' |
16 | import { VideoBlacklistModel } from '../../video-blacklist' | 16 | import { VideoBlacklistModel } from '../../../video-blacklist' |
17 | import { VideoChannelModel } from '../../video-channel' | 17 | import { VideoChannelModel } from '../../../video-channel' |
18 | import { VideoFileModel } from '../../video-file' | 18 | import { VideoFileModel } from '../../../video-file' |
19 | import { VideoLiveModel } from '../../video-live' | 19 | import { VideoLiveModel } from '../../../video-live' |
20 | import { VideoStreamingPlaylistModel } from '../../video-streaming-playlist' | 20 | import { VideoStreamingPlaylistModel } from '../../../video-streaming-playlist' |
21 | import { VideoTableAttributes } from './video-table-attributes' | 21 | import { VideoTableAttributes } from './video-table-attributes' |
22 | 22 | ||
23 | type SQLRow = { [id: string]: string | number } | 23 | type 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 | */ |
7 | export class VideoTableAttributes { | 7 | export 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' | |||
5 | import { buildDirectionAndField, createSafeIn } from '@server/models/utils' | 5 | import { buildDirectionAndField, createSafeIn } from '@server/models/utils' |
6 | import { MUserAccountId, MUserId } from '@server/types/models' | 6 | import { MUserAccountId, MUserId } from '@server/types/models' |
7 | import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models' | 7 | import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models' |
8 | import { AbstractRunQuery } from './shared/abstract-run-query' | 8 | import { 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 { | |||
31 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' | 31 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' |
32 | import { sendDeleteActor } from '../../lib/activitypub/send' | 32 | import { sendDeleteActor } from '../../lib/activitypub/send' |
33 | import { | 33 | import { |
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 | ||
67 | type AvailableWithStatsOptions = { | 69 | type 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 @@ | |||
1 | import { uniq } from 'lodash' | 1 | import { uniq } from 'lodash' |
2 | import { FindAndCountOptions, FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' | 2 | import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' |
3 | import { | 3 | import { |
4 | AllowNull, | 4 | AllowNull, |
5 | BelongsTo, | 5 | BelongsTo, |
@@ -16,8 +16,8 @@ import { | |||
16 | } from 'sequelize-typescript' | 16 | } from 'sequelize-typescript' |
17 | import { getServerActor } from '@server/models/application/application' | 17 | import { getServerActor } from '@server/models/application/application' |
18 | import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' | 18 | import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' |
19 | import { AttributesOnly } from '@shared/typescript-utils' | ||
20 | import { VideoPrivacy } from '@shared/models' | 19 | import { VideoPrivacy } from '@shared/models' |
20 | import { AttributesOnly } from '@shared/typescript-utils' | ||
21 | import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' | 21 | import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' |
22 | import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' | 22 | import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' |
23 | import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/comment/video-comment.model' | 23 | import { 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' |
26 | import { AttributesOnly } from '@shared/typescript-utils' | ||
26 | import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object' | 27 | import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object' |
27 | import { VideoPrivacy } from '../../../shared/models/videos' | 28 | import { VideoPrivacy } from '../../../shared/models/videos' |
28 | import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/models/videos/playlist/video-playlist-element.model' | 29 | import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/models/videos/playlist/video-playlist-element.model' |
@@ -32,7 +33,6 @@ import { AccountModel } from '../account/account' | |||
32 | import { getSort, throwIfNotValid } from '../utils' | 33 | import { getSort, throwIfNotValid } from '../utils' |
33 | import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video' | 34 | import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video' |
34 | import { VideoPlaylistModel } from './video-playlist' | 35 | import { VideoPlaylistModel } from './video-playlist' |
35 | import { 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 @@ | |||
1 | import { join } from 'path' | 1 | import { join } from 'path' |
2 | import { FindOptions, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' | 2 | import { FindOptions, Includeable, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' |
3 | import { | 3 | import { |
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 | ||
91 | function getVideoLengthSelect () { | 92 | function 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' |
64 | import { getVideoFileResolution } from '../../helpers/ffprobe-utils' | 64 | import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg' |
65 | import { logger } from '../../helpers/logger' | 65 | import { logger } from '../../helpers/logger' |
66 | import { CONFIG } from '../../initializers/config' | 66 | import { CONFIG } from '../../initializers/config' |
67 | import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants' | 67 | import { 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' |
116 | import { ScheduleVideoUpdateModel } from './schedule-video-update' | 116 | import { ScheduleVideoUpdateModel } from './schedule-video-update' |
117 | import { VideoModelGetQueryBuilder } from './sql/video-model-get-query-builder' | 117 | import { |
118 | import { BuildVideosListQueryOptions, DisplayOnlyForFollowerOptions, VideosIdListQueryBuilder } from './sql/videos-id-list-query-builder' | 118 | BuildVideosListQueryOptions, |
119 | import { VideosModelListQueryBuilder } from './sql/videos-model-list-query-builder' | 119 | DisplayOnlyForFollowerOptions, |
120 | VideoModelGetQueryBuilder, | ||
121 | VideosIdListQueryBuilder, | ||
122 | VideosModelListQueryBuilder | ||
123 | } from './sql/video' | ||
120 | import { TagModel } from './tag' | 124 | import { TagModel } from './tag' |
121 | import { ThumbnailModel } from './thumbnail' | 125 | import { ThumbnailModel } from './thumbnail' |
122 | import { VideoBlacklistModel } from './video-blacklist' | 126 | import { 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' | |||
25 | import './video-captions' | 25 | import './video-captions' |
26 | import './video-channels' | 26 | import './video-channels' |
27 | import './video-comments' | 27 | import './video-comments' |
28 | import './video-editor' | ||
28 | import './video-imports' | 29 | import './video-imports' |
29 | import './video-playlists' | 30 | import './video-playlists' |
30 | import './videos' | 31 | import './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 | |||
3 | import 'mocha' | ||
4 | import { HttpStatusCode, VideoEditorTask } from '@shared/models' | ||
5 | import { | ||
6 | cleanupTests, | ||
7 | createSingleServer, | ||
8 | PeerTubeServer, | ||
9 | setAccessTokensToServers, | ||
10 | VideoEditorCommand, | ||
11 | waitJobs | ||
12 | } from '@shared/server-commands' | ||
13 | |||
14 | describe('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' | |||
7 | import './redundancy' | 7 | import './redundancy' |
8 | import './search' | 8 | import './search' |
9 | import './server' | 9 | import './server' |
10 | import './transcoding' | ||
10 | import './users' | 11 | import './users' |
11 | import './videos' | 12 | import './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 @@ | |||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { basename, join } from 'path' | 5 | import { basename, join } from 'path' |
6 | import { ffprobePromise, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils' | 6 | import { ffprobePromise, getVideoStream } from '@server/helpers/ffmpeg' |
7 | import { checkLiveCleanupAfterSave, checkLiveSegmentHash, checkResolutionsInMasterPlaylist, testImage } from '@server/tests/shared' | 7 | import { checkLiveCleanupAfterSave, checkLiveSegmentHash, checkResolutionsInMasterPlaylist, testImage } from '@server/tests/shared' |
8 | import { wait } from '@shared/core-utils' | 8 | import { wait } from '@shared/core-utils' |
9 | import { | 9 | import { |
@@ -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 | ||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { AbuseMessage, AbusePredefinedReasonsString, AbuseState, AdminAbuse, UserAbuse } from '@shared/models' | ||
5 | import { | 6 | import { |
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' |
14 | import { AbuseMessage, AbusePredefinedReasonsString, AbuseState, AdminAbuse, UserAbuse } from '@shared/models' | ||
15 | 17 | ||
16 | const expect = chai.expect | 18 | const 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 | ||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { UserNotificationType } from '@shared/models' | ||
5 | import { | 6 | import { |
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' |
15 | import { UserNotificationType } from '@shared/models' | ||
16 | 17 | ||
17 | const expect = chai.expect | 18 | const 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 | ||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { VideoChannel } from '@shared/models' | ||
5 | import { | 6 | import { |
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' |
13 | import { VideoChannel } from '@shared/models' | ||
14 | 16 | ||
15 | const expect = chai.expect | 17 | const 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 | ||
15 | const expect = chai.expect | 15 | const expect = chai.expect |
16 | 16 | ||
17 | describe('Test videos search', function () { | 17 | describe('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 | ||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { VideoPlaylistPrivacy } from '@shared/models' | ||
5 | import { | 6 | import { |
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' |
14 | import { VideoPlaylistPrivacy } from '@shared/models' | ||
15 | 17 | ||
16 | const expect = chai.expect | 18 | const 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 | ||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { wait } from '@shared/core-utils' | ||
6 | import { VideoPrivacy } from '@shared/models' | ||
5 | import { | 7 | import { |
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' |
15 | import { VideoPrivacy } from '@shared/models' | ||
16 | import { wait } from '@shared/core-utils' | ||
17 | 19 | ||
18 | const expect = chai.expect | 20 | const 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 | ||
15 | const expect = chai.expect | 17 | const 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 | ||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { getAudioStream, getVideoStreamSize } from '@server/helpers/ffprobe-utils' | 5 | import { getAudioStream, getVideoStreamDimensionsInfo } from '@server/helpers/ffmpeg' |
6 | import { | 6 | import { |
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 @@ | |||
1 | export * from './audio-only' | ||
2 | export * from './create-transcoding' | ||
3 | export * from './hls' | ||
4 | export * from './transcoder' | ||
5 | export * 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 @@ | |||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { omit } from 'lodash' | 5 | import { omit } from 'lodash' |
6 | import { canDoQuickTranscode } from '@server/helpers/ffprobe-utils' | 6 | import { canDoQuickTranscode } from '@server/helpers/ffmpeg' |
7 | import { generateHighBitrateVideo, generateVideoWithFramerate } from '@server/tests/shared' | 7 | import { generateHighBitrateVideo, generateVideoWithFramerate, getAllFiles } from '@server/tests/shared' |
8 | import { buildAbsoluteFixturePath, getMaxBitrate, getMinLimitBitrate } from '@shared/core-utils' | 8 | import { buildAbsoluteFixturePath, getMaxBitrate, getMinLimitBitrate } from '@shared/core-utils' |
9 | import { getAudioStream, getMetadataFromFile, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@shared/extra-utils' | 9 | import { |
10 | getAudioStream, | ||
11 | buildFileMetadata, | ||
12 | getVideoStreamBitrate, | ||
13 | getVideoStreamFPS, | ||
14 | getVideoStreamDimensionsInfo, | ||
15 | hasAudioStream | ||
16 | } from '@shared/extra-utils' | ||
10 | import { HttpStatusCode, VideoState } from '@shared/models' | 17 | import { HttpStatusCode, VideoState } from '@shared/models' |
11 | import { | 18 | import { |
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 @@ | |||
1 | import { expect } from 'chai' | ||
2 | import { expectStartWith, getAllFiles } from '@server/tests/shared' | ||
3 | import { areObjectStorageTestsDisabled } from '@shared/core-utils' | ||
4 | import { VideoEditorTask } from '@shared/models' | ||
5 | import { | ||
6 | cleanupTests, | ||
7 | createMultipleServers, | ||
8 | doubleFollow, | ||
9 | ObjectStorageCommand, | ||
10 | PeerTubeServer, | ||
11 | setAccessTokensToServers, | ||
12 | setDefaultVideoChannel, | ||
13 | VideoEditorCommand, | ||
14 | waitJobs | ||
15 | } from '@shared/server-commands' | ||
16 | |||
17 | describe('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 @@ | |||
1 | import './audio-only' | ||
2 | import './multiple-servers' | 1 | import './multiple-servers' |
3 | import './resumable-upload' | 2 | import './resumable-upload' |
4 | import './single-server' | 3 | import './single-server' |
@@ -6,17 +5,14 @@ import './video-captions' | |||
6 | import './video-change-ownership' | 5 | import './video-change-ownership' |
7 | import './video-channels' | 6 | import './video-channels' |
8 | import './video-comments' | 7 | import './video-comments' |
9 | import './video-create-transcoding' | ||
10 | import './video-description' | 8 | import './video-description' |
11 | import './video-files' | 9 | import './video-files' |
12 | import './video-hls' | ||
13 | import './video-imports' | 10 | import './video-imports' |
14 | import './video-nsfw' | 11 | import './video-nsfw' |
15 | import './video-playlists' | 12 | import './video-playlists' |
16 | import './video-playlist-thumbnails' | 13 | import './video-playlist-thumbnails' |
17 | import './video-privacy' | 14 | import './video-privacy' |
18 | import './video-schedule-update' | 15 | import './video-schedule-update' |
19 | import './video-transcoder' | ||
20 | import './videos-common-filters' | 16 | import './videos-common-filters' |
21 | import './videos-history' | 17 | import './videos-history' |
22 | import './videos-overview' | 18 | import './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' | |||
5 | import { checkVideoFilesWereRemoved, completeVideoCheck, testImage } from '@server/tests/shared' | 5 | import { checkVideoFilesWereRemoved, completeVideoCheck, testImage } from '@server/tests/shared' |
6 | import { wait } from '@shared/core-utils' | 6 | import { wait } from '@shared/core-utils' |
7 | import { Video, VideoPrivacy } from '@shared/models' | 7 | import { Video, VideoPrivacy } from '@shared/models' |
8 | import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' | 8 | import { |
9 | cleanupTests, | ||
10 | createSingleServer, | ||
11 | PeerTubeServer, | ||
12 | setAccessTokensToServers, | ||
13 | setDefaultAccountAvatar, | ||
14 | setDefaultChannelAvatar | ||
15 | } from '@shared/server-commands' | ||
9 | 16 | ||
10 | const expect = chai.expect | 17 | const 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' | |||
6 | import { ACTOR_IMAGES_SIZE } from '@server/initializers/constants' | 6 | import { ACTOR_IMAGES_SIZE } from '@server/initializers/constants' |
7 | import { testFileExistsOrNot, testImage } from '@server/tests/shared' | 7 | import { testFileExistsOrNot, testImage } from '@server/tests/shared' |
8 | import { wait } from '@shared/core-utils' | 8 | import { wait } from '@shared/core-utils' |
9 | import { User, VideoChannel } from '@shared/models' | 9 | import { ActorImageType, User, VideoChannel } from '@shared/models' |
10 | import { | 10 | import { |
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 @@ | |||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { dateIsValid, testImage } from '@server/tests/shared' | 5 | import { dateIsValid, testImage } from '@server/tests/shared' |
6 | import { cleanupTests, CommentsCommand, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' | 6 | import { |
7 | cleanupTests, | ||
8 | CommentsCommand, | ||
9 | createSingleServer, | ||
10 | PeerTubeServer, | ||
11 | setAccessTokensToServers, | ||
12 | setDefaultAccountAvatar, | ||
13 | setDefaultChannelAvatar | ||
14 | } from '@shared/server-commands' | ||
7 | 15 | ||
8 | const expect = chai.expect | 16 | const 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 @@ | |||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import { expect } from 'chai' | 4 | import { expect } from 'chai' |
5 | import { pick } from '@shared/core-utils' | 5 | import { pick } from '@shared/core-utils' |
6 | import { HttpStatusCode, UserRole, Video, VideoDetails, VideoInclude, VideoPrivacy } from '@shared/models' | ||
6 | import { | 7 | import { |
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' |
16 | import { HttpStatusCode, UserRole, Video, VideoDetails, VideoInclude, VideoPrivacy } from '@shared/models' | ||
17 | 18 | ||
18 | describe('Test videos filter', function () { | 19 | describe('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' |
15 | import { getAllFiles } from '../shared' | ||
15 | 16 | ||
16 | describe('Test update host scripts', function () { | 17 | describe('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 @@ | |||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { XMLParser, XMLValidator } from 'fast-xml-parser' | 5 | import { XMLParser, XMLValidator } from 'fast-xml-parser' |
6 | import { HttpStatusCode, VideoPrivacy } from '@shared/models' | ||
6 | import { | 7 | import { |
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' |
16 | import { HttpStatusCode, VideoPrivacy } from '@shared/models' | ||
17 | 18 | ||
18 | chai.use(require('chai-xml')) | 19 | chai.use(require('chai-xml')) |
19 | chai.use(require('chai-json-schema')) | 20 | chai.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 | ||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import { expect } from 'chai' | 4 | import { expect } from 'chai' |
5 | import { getAudioStream, getVideoFileFPS, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils' | 5 | import { getAudioStream, getVideoStreamFPS, getVideoStream } from '@server/helpers/ffmpeg' |
6 | import { VideoPrivacy } from '@shared/models' | ||
6 | import { | 7 | import { |
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' |
16 | import { VideoPrivacy } from '@shared/models' | ||
17 | 17 | ||
18 | async function createLiveWrapper (server: PeerTubeServer) { | 18 | async 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' | |||
3 | import { ensureDir, pathExists } from 'fs-extra' | 3 | import { ensureDir, pathExists } from 'fs-extra' |
4 | import { dirname } from 'path' | 4 | import { dirname } from 'path' |
5 | import { buildAbsoluteFixturePath, getMaxBitrate } from '@shared/core-utils' | 5 | import { buildAbsoluteFixturePath, getMaxBitrate } from '@shared/core-utils' |
6 | import { getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@shared/extra-utils' | 6 | import { getVideoStreamBitrate, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '@shared/extra-utils' |
7 | 7 | ||
8 | async function ensureHasTooBigBitrate (fixturePath: string) { | 8 | async 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' |
13 | import { createMultipleServers, doubleFollow, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' | 13 | import { |
14 | createMultipleServers, | ||
15 | doubleFollow, | ||
16 | PeerTubeServer, | ||
17 | setAccessTokensToServers, | ||
18 | setDefaultAccountAvatar, | ||
19 | setDefaultChannelAvatar | ||
20 | } from '@shared/server-commands' | ||
14 | import { MockSmtpServer } from './mock-servers' | 21 | import { MockSmtpServer } from './mock-servers' |
15 | 22 | ||
16 | type CheckerBaseParams = { | 23 | type 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 | ||
243 | function 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 | ||
245 | export { | 255 | export { |
@@ -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' |
44 | import { Writable } from 'stream' | 44 | import { Writable } from 'stream' |
45 | 45 | ||
46 | declare module 'express' { | 46 | declare 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 | ||
10 | export type MActorImageFormattable = | 10 | export 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 | ||
13 | export type MActor = Omit<ActorModel, 'Account' | 'VideoChannel' | 'ActorFollowing' | 'Avatar' | 'ActorFollowers' | 'Server' | 'Banner'> | 13 | export 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 | |||
35 | export type MActorDefaultLight = | 35 | export type MActorDefaultLight = |
36 | MActorLight & | 36 | MActorLight & |
37 | Use<'Server', MServerHost> & | 37 | Use<'Server', MServerHost> & |
38 | Use<'Avatar', MActorImage> | 38 | Use<'Avatars', MActorImage[]> |
39 | 39 | ||
40 | export type MActorAccountId = | 40 | export type MActorAccountId = |
41 | MActor & | 41 | MActor & |
@@ -78,13 +78,13 @@ export type MActorServer = | |||
78 | 78 | ||
79 | export type MActorImages = | 79 | export type MActorImages = |
80 | MActor & | 80 | MActor & |
81 | Use<'Avatar', MActorImage> & | 81 | Use<'Avatars', MActorImage[]> & |
82 | UseOpt<'Banner', MActorImage> | 82 | UseOpt<'Banners', MActorImage[]> |
83 | 83 | ||
84 | export type MActorDefault = | 84 | export type MActorDefault = |
85 | MActor & | 85 | MActor & |
86 | Use<'Server', MServer> & | 86 | Use<'Server', MServer> & |
87 | Use<'Avatar', MActorImage> | 87 | Use<'Avatars', MActorImage[]> |
88 | 88 | ||
89 | export type MActorDefaultChannelId = | 89 | export type MActorDefaultChannelId = |
90 | MActorDefault & | 90 | MActorDefault & |
@@ -93,8 +93,8 @@ export type MActorDefaultChannelId = | |||
93 | export type MActorDefaultBanner = | 93 | export 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 = | |||
105 | export type MActorFull = | 105 | export 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 = | |||
114 | export type MActorFullActor = | 114 | export 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 | ||
126 | export type MActorSummary = | 126 | export 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 | ||
132 | export type MActorSummaryBlocks = | 132 | export 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 | ||
150 | export type MActorFormattable = | 150 | export 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 | ||
156 | type MActorAPBase = | 157 | type MActorAPBase = |
157 | MActor & | 158 | MActor & |
158 | Use<'Avatar', MActorImage> | 159 | Use<'Avatars', MActorImage[]> |
159 | 160 | ||
160 | export type MActorAPAccount = | 161 | export type MActorAPAccount = |
161 | MActorAPBase | 162 | MActorAPBase |
162 | 163 | ||
163 | export type MActorAPChannel = | 164 | export 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 | ||
23 | export module UserNotificationIncludes { | 23 | export 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 | ||
100 | export type MUserNotification = | 101 | export 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 = | |||
106 | export type UserNotificationModelForApi = | 107 | export 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> & |