1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 09:49:20 +02:00

feat(config): add admin options to customize default "Browse videos" behaviour (#7193)

* feat(customBrowseVideosDefaultSort): update server config

* feat(customBrowseVideosDefaultSort): update client admin-config-general component

* feat(customBrowseVideosDefaultSort): add new consistency check to server checker-after-init

* feat(customBrowseVideosDefaultSort): update config .yaml with more details about available options

* feat(customBrowseVideosDefaultSort): refactor consistency check in server checker-after-init

* feat(customBrowseVideosDefaultSort): client, add new select-videos-sort shared component

* feat(customBrowseVideosDefaultSort): client, refactor admin-config-general component to use new select-videos-sort shared component

* feat(customBrowseVideosDefaultSort): client, fix my-select-videos-sort width in admin-config-general

* feat(customBrowseVideosDefaultSort): client, update video-filters-header scss

* feat(customBrowseVideosDefaultSort): client, update videos-list-all component

* feat(config): refactor checkBrowseVideosConfig logic into separate custom validator

* feat(config): refactor isBrowseVideosDefaultSortValid to use template literals

* feat(config): refactor isBrowseVideosDefaultSortValid

* feat(config): add check for invalid browse videos config to customConfigUpdateValidator

* feat(config): group browse-videos tests in describe block

* feat(config): refactor to use client.browse_videos section, instead of browse.videos section

* feat(config): add browse_videos default_scope config key (config and server changes)

* feat(config): add browse_videos default_scope config key (client changes)

* Reorder browse videos before videos

* Fix i18n message

---------

Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
Gianantonio Pini 2025-09-10 10:41:45 +02:00 committed by GitHub
parent 9b7edd1c59
commit e74bf8ae2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 436 additions and 54 deletions

View file

@ -36,6 +36,8 @@ input[type="checkbox"] {
@include peertube-select-container($form-base-input-width);
}
my-select-videos-sort,
my-select-videos-scope,
my-select-checkbox,
my-select-options,
my-select-custom-value {

View file

@ -320,6 +320,34 @@
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>BROWSE VIDEOS</h2>
</div>
<div class="content-col">
<div class="form-group" formGroupName="client">
<ng-container formGroupName="browseVideos">
<div class="form-group">
<label i18n for="browseVideosDefaultSort">Default sort</label>
<my-select-videos-sort inputId="browseVideosDefaultSort" formControlName="defaultSort"></my-select-videos-sort>
<div *ngIf="formErrors.client.browseVideos.defaultSort" class="form-error" role="alert">{{ formErrors.client.browseVideos.defaultSort }}</div>
</div>
<div class="form-group">
<label i18n for="browseVideosDefaultScope">Default scope</label>
<my-select-videos-scope inputId="browseVideosDefaultScope" formControlName="defaultScope"></my-select-videos-scope>
<div *ngIf="formErrors.client.browseVideos.defaultScope" class="form-error" role="alert">{{ formErrors.client.browseVideos.defaultScope }}</div>
</div>
</ng-container>
</div>
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>VIDEOS</h2>

View file

@ -33,6 +33,8 @@ import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { SelectVideosSortComponent } from '../../../shared/shared-forms/select/select-videos-sort.component'
import { SelectVideosScopeComponent } from '../../../shared/shared-forms/select/select-videos-scope.component'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { UserRealQuotaInfoComponent } from '../../shared/user-real-quota-info.component'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
@ -43,6 +45,11 @@ type Form = {
}>
client: FormGroup<{
browseVideos: FormGroup<{
defaultSort: FormControl<string>
defaultScope: FormControl<string>
}>
menu: FormGroup<{
login: FormGroup<{
redirectOnSingleExternalAuth: FormControl<boolean>
@ -220,7 +227,9 @@ type Form = {
UserRealQuotaInfoComponent,
SelectOptionsComponent,
AlertComponent,
AdminSaveBarComponent
AdminSaveBarComponent,
SelectVideosSortComponent,
SelectVideosScopeComponent
]
})
export class AdminConfigGeneralComponent implements OnInit, OnDestroy, CanComponentDeactivate {
@ -295,6 +304,10 @@ export class AdminConfigGeneralComponent implements OnInit, OnDestroy, CanCompon
defaultClientRoute: null
},
client: {
browseVideos: {
defaultSort: null,
defaultScope: null
},
menu: {
login: {
redirectOnSingleExternalAuth: null

View file

@ -1,11 +1,11 @@
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ComponentPaginationLight, DisableForReuseHook, MetaService } from '@app/core'
import { ComponentPaginationLight, DisableForReuseHook, MetaService, ServerService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { VideoFilterScope, VideoFilters } from '@app/shared/shared-video-miniature/video-filters.model'
import { VideosListComponent } from '@app/shared/shared-video-miniature/videos-list.component'
import { VideoSortField } from '@peertube/peertube-models'
import { HTMLServerConfig, VideoSortField } from '@peertube/peertube-models'
import { Subscription } from 'rxjs'
@Component({
@ -19,6 +19,7 @@ export class VideosListAllComponent implements OnInit, OnDestroy, DisableForReus
private videoService = inject(VideoService)
private hooks = inject(HooksService)
private meta = inject(MetaService)
private serverService = inject(ServerService)
getVideosObservableFunction = this.getVideosObservable.bind(this)
getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
@ -37,11 +38,14 @@ export class VideosListAllComponent implements OnInit, OnDestroy, DisableForReus
disabled = false
private routeSub: Subscription
private serverConfig: HTMLServerConfig
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
const queryParams = this.route.snapshot.queryParams
this.defaultSort = queryParams.sort || '-publishedAt'
this.defaultScope = queryParams.scope || 'federated'
this.defaultSort = queryParams.sort || this.serverConfig.client.browseVideos.defaultSort
this.defaultScope = queryParams.scope || this.serverConfig.client.browseVideos.defaultScope
this.routeSub = this.route.params.subscribe(() => this.update())
}

View file

@ -0,0 +1,64 @@
import { CommonModule } from '@angular/common'
import { Component, forwardRef, OnInit, input } from '@angular/core'
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
import { SelectOptionsComponent } from './select-options.component'
@Component({
selector: 'my-select-videos-scope',
template: `
<my-select-options
[inputId]="inputId()"
[items]="scopeItems"
[(ngModel)]="selectedId"
(ngModelChange)="onModelChange()"
></my-select-options>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SelectVideosScopeComponent),
multi: true
}
],
imports: [ FormsModule, CommonModule, SelectOptionsComponent ]
})
export class SelectVideosScopeComponent implements ControlValueAccessor, OnInit {
readonly inputId = input.required<string>()
scopeItems: SelectOptionsItem[]
selectedId: string
ngOnInit () {
this.buildScopeItems()
}
private buildScopeItems () {
this.scopeItems = [
{ id: 'local', label: $localize`Only videos from this platform` },
{ id: 'federated', label: $localize`Videos from all platforms` }
]
}
propagateChange = (_: any) => {
// empty
}
writeValue (id: string) {
this.selectedId = id
}
registerOnChange (fn: (_: any) => void) {
this.propagateChange = fn
}
registerOnTouched () {
// Unused
}
onModelChange () {
this.propagateChange(this.selectedId)
}
}

View file

@ -0,0 +1,91 @@
import { CommonModule } from '@angular/common'
import { Component, forwardRef, OnInit, inject, input } from '@angular/core'
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'
import { ServerService } from '@app/core'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
import { SelectOptionsComponent } from './select-options.component'
import { HTMLServerConfig } from '@peertube/peertube-models'
@Component({
selector: 'my-select-videos-sort',
template: `
<my-select-options
[inputId]="inputId()"
[items]="sortItems"
[(ngModel)]="selectedId"
(ngModelChange)="onModelChange()"
></my-select-options>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SelectVideosSortComponent),
multi: true
}
],
imports: [ FormsModule, CommonModule, SelectOptionsComponent ]
})
export class SelectVideosSortComponent implements ControlValueAccessor, OnInit {
private server = inject(ServerService)
readonly inputId = input.required<string>()
sortItems: SelectOptionsItem[]
selectedId: string
private serverConfig: HTMLServerConfig
ngOnInit () {
this.serverConfig = this.server.getHTMLConfig()
this.buildSortItems()
}
private buildSortItems () {
this.sortItems = [
{ id: '-publishedAt', label: $localize`Recently Added` },
{ id: '-originallyPublishedAt', label: $localize`Original Publication Date` },
{ id: 'name', label: $localize`Name` }
]
if (this.isTrendingSortEnabled('most-viewed')) {
this.sortItems.push({ id: '-trending', label: $localize`Recent Views` })
}
if (this.isTrendingSortEnabled('hot')) {
this.sortItems.push({ id: '-hot', label: $localize`Hot` })
}
if (this.isTrendingSortEnabled('most-liked')) {
this.sortItems.push({ id: '-likes', label: $localize`Likes` })
}
this.sortItems.push({ id: '-views', label: $localize`Global Views` })
}
private isTrendingSortEnabled (sort: 'most-viewed' | 'hot' | 'most-liked') {
return this.serverConfig.trending.videos.algorithms.enabled.includes(sort)
}
propagateChange = (_: any) => {
// empty
}
writeValue (id: string) {
this.selectedId = id
}
registerOnChange (fn: (_: any) => void) {
this.propagateChange = fn
}
registerOnTouched () {
// Unused
}
onModelChange () {
this.propagateChange(this.selectedId)
}
}

View file

@ -59,7 +59,7 @@
<div class="d-flex flex-wrap align-items-center mb-2">
<label for="sort-videos" i18n class="select-label">Sort by:</label>
<my-select-options inputId="sort-videos" class="d-inline-block me-2" formControlName="sort" [items]="sortItems"></my-select-options>
<my-select-videos-sort inputId="sort-videos" class="d-inline-block me-2" formControlName="sort"></my-select-videos-sort>
</div>
</div>
@ -115,7 +115,7 @@
<div class="form-group">
<label for="scope" i18n>Displayed videos</label>
<my-select-options inputId="scope" class="scope-select" formControlName="scope" [items]="availableScopes"></my-select-options>
<my-select-videos-scope inputId="scope" class="scope-select" formControlName="scope"></my-select-videos-scope>
</div>
</div>

View file

@ -121,9 +121,10 @@ $filters-background: pvar(--bg-secondary-400);
height: min-content;
}
my-select-videos-sort,
my-select-videos-scope,
my-select-languages,
my-select-categories,
my-select-options {
my-select-categories {
max-width: 300px;
display: inline-block;
}

View file

@ -8,11 +8,11 @@ import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap'
import { UserRight, VideoConstant } from '@peertube/peertube-models'
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
import debug from 'debug'
import { SelectOptionsItem } from 'src/types'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
import { SelectCategoriesComponent } from '../shared-forms/select/select-categories.component'
import { SelectLanguagesComponent } from '../shared-forms/select/select-languages.component'
import { SelectOptionsComponent } from '../shared-forms/select/select-options.component'
import { SelectVideosSortComponent } from '../shared-forms/select/select-videos-sort.component'
import { SelectVideosScopeComponent } from '../shared-forms/select/select-videos-scope.component'
import { GlobalIconComponent, GlobalIconName } from '../shared-icons/global-icon.component'
import { InstanceFollowService } from '../shared-instance/instance-follow.service'
import { ButtonComponent } from '../shared-main/buttons/button.component'
@ -43,8 +43,9 @@ type QuickFilter = {
SelectLanguagesComponent,
SelectCategoriesComponent,
PeertubeCheckboxComponent,
SelectOptionsComponent,
ButtonComponent
ButtonComponent,
SelectVideosSortComponent,
SelectVideosScopeComponent
],
providers: [ InstanceFollowService ]
})
@ -65,9 +66,6 @@ export class VideoFiltersHeaderComponent implements OnInit {
form: FormGroup
sortItems: SelectOptionsItem[] = []
availableScopes: SelectOptionsItem[] = []
quickFilters: QuickFilter[] = []
instanceName: string
@ -109,12 +107,6 @@ export class VideoFiltersHeaderComponent implements OnInit {
this.followService.getFollowing({ pagination: { count: 1, start: 0 }, state: 'accepted' })
.subscribe(({ total }) => this.totalFollowing = total)
this.availableScopes = [
{ id: 'local', label: $localize`Only videos from this platform` },
{ id: 'federated', label: $localize`Videos from all platforms` }
]
this.buildSortItems()
this.buildQuickFilters()
}
@ -149,34 +141,6 @@ export class VideoFiltersHeaderComponent implements OnInit {
// ---------------------------------------------------------------------------
private buildSortItems () {
this.sortItems = [
{ id: '-publishedAt', label: $localize`Recently Added` },
{ id: '-originallyPublishedAt', label: $localize`Original Publication Date` },
{ id: 'name', label: $localize`Name` }
]
if (this.isTrendingSortEnabled('most-viewed')) {
this.sortItems.push({ id: '-trending', label: $localize`Recent Views` })
}
if (this.isTrendingSortEnabled('hot')) {
this.sortItems.push({ id: '-hot', label: $localize`Hot` })
}
if (this.isTrendingSortEnabled('most-liked')) {
this.sortItems.push({ id: '-likes', label: $localize`Likes` })
}
this.sortItems.push({ id: '-views', label: $localize`Global Views` })
}
private isTrendingSortEnabled (sort: 'most-viewed' | 'hot' | 'most-liked') {
const serverConfig = this.serverService.getHTMLConfig()
return serverConfig.trending.videos.algorithms.enabled.includes(sort)
}
getFilterValue (filter: VideoFilterActive) {
if ((filter.key === 'categoryOneOf' || filter.key === 'languageOneOf') && Array.isArray(filter.rawValue)) {
if (filter.rawValue.length > 2) {

View file

@ -1088,6 +1088,22 @@ client:
# If null, it will be calculated based on network speed
max_chunk_size: null
browse_videos:
# Default sort option
# Available options:
# '-publishedAt'
# '-originallyPublishedAt'
# 'name'
# '-trending' (requires the 'most-viewed' trending videos algorithm to be enabled)
# '-hot' (requires the 'hot' trending videos algorithm to be enabled)
# '-likes' (requires the 'most-liked' trending videos algorithm to be enabled)
# '-views'
default_sort: '-publishedAt'
# Default scope option
# Available options: 'local' or 'federated'
default_scope: 'federated'
menu:
login:
# If you enable only one external auth plugin

View file

@ -1095,6 +1095,22 @@ client:
# If null, it will be calculated based on network speed
max_chunk_size: null
browse_videos:
# Default sort option
# Available options:
# '-publishedAt'
# '-originallyPublishedAt'
# 'name'
# '-trending' (requires the 'most-viewed' trending videos algorithm to be enabled)
# '-hot' (requires the 'hot' trending videos algorithm to be enabled)
# '-likes' (requires the 'most-liked' trending videos algorithm to be enabled)
# '-views'
default_sort: '-publishedAt'
# Default scope option
# Available options: 'local' or 'federated'
default_scope: 'federated'
menu:
login:
# If you enable only one external auth plugin

View file

@ -91,6 +91,11 @@ export interface CustomConfig {
}
}
browseVideos: {
defaultSort: string
defaultScope: string
}
menu: {
login: {
redirectOnSingleExternalAuth: boolean

View file

@ -50,6 +50,11 @@ export interface ServerConfig {
}
}
browseVideos: {
defaultSort: string
defaultScope: string
}
menu: {
login: {
redirectOnSingleExternalAuth: boolean

View file

@ -221,6 +221,69 @@ describe('Test config API validators', function () {
expectedStatus: HttpStatusCode.OK_200
})
})
describe('Browse videos section', function () {
it('Should fail with an invalid default sort', async function () {
const newUpdateParams: CustomConfig = merge({}, {}, updateParams, {
client: {
browseVideos: {
defaultSort: 'hello'
}
}
})
await makePutBodyRequest({
url: server.url,
path,
fields: newUpdateParams,
token: server.accessToken,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with a trending default sort & disabled trending algorithm', async function () {
const newUpdateParams: CustomConfig = merge({}, {}, updateParams, {
trending: {
videos: {
algorithms: {
enabled: [ 'hot', 'most-liked' ]
}
}
},
client: {
browseVideos: {
defaultSort: '-trending'
}
}
})
await makePutBodyRequest({
url: server.url,
path,
fields: newUpdateParams,
token: server.accessToken,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with an invalid default scope', async function () {
const newUpdateParams: CustomConfig = merge({}, {}, updateParams, {
client: {
browseVideos: {
defaultScope: 'hello'
}
}
})
await makePutBodyRequest({
url: server.url,
path,
fields: newUpdateParams,
token: server.accessToken,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
})
})
describe('When deleting the configuration', function () {

View file

@ -50,6 +50,8 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
expect(data.client.header.hideInstanceName).to.be.false
expect(data.client.videos.miniature.preferAuthorDisplayName).to.be.false
expect(data.client.browseVideos.defaultSort).to.equal('-publishedAt')
expect(data.client.browseVideos.defaultScope).to.equal('federated')
expect(data.client.menu.login.redirectOnSingleExternalAuth).to.be.false
expect(data.cache.previews.size).to.equal(1)
@ -233,6 +235,10 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
preferAuthorDisplayName: true
}
},
browseVideos: {
defaultSort: '-trending',
defaultScope: 'local'
},
menu: {
login: {
redirectOnSingleExternalAuth: true

View file

@ -366,6 +366,10 @@ function customConfig (): CustomConfig {
preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME
}
},
browseVideos: {
defaultSort: CONFIG.CLIENT.BROWSE_VIDEOS.DEFAULT_SORT,
defaultScope: CONFIG.CLIENT.BROWSE_VIDEOS.DEFAULT_SCOPE
},
menu: {
login: {
redirectOnSingleExternalAuth: CONFIG.CLIENT.MENU.LOGIN.REDIRECT_ON_SINGLE_EXTERNAL_AUTH

View file

@ -0,0 +1,35 @@
import { t } from '../i18n.js'
export function getBrowseVideosDefaultSortError (value: string, enabledTrendingAlgorithms: string[], language: string) {
const availableOptions = [ '-publishedAt', '-originallyPublishedAt', 'name', '-trending', '-hot', '-likes', '-views' ]
if (availableOptions.includes(value) === false) {
return t('Browse videos default sort should be \'' + availableOptions.join('\' or \'') + '\', instead of \'' + value + '\'', language)
}
const trendingSortAlgorithmMap = new Map<string, string>([
[ '-trending', 'most-viewed' ],
[ '-hot', 'hot' ],
[ '-likes', 'most-liked' ]
])
const currentTrendingSortAlgorithm = trendingSortAlgorithmMap.get(value)
if (currentTrendingSortAlgorithm && enabledTrendingAlgorithms.includes(currentTrendingSortAlgorithm) === false) {
return t(
`Trending videos algorithm '${currentTrendingSortAlgorithm}' should be enabled if browse videos default sort is '${value}'`,
language
)
}
return null
}
export function getBrowseVideosDefaultScopeError (value: string, language: string) {
const availableOptions = [ 'local', 'federated' ]
if (availableOptions.includes(value) === false) {
return t('Browse videos default scope should be \'' + availableOptions.join('\' or \'') + '\', instead of \'' + value + '\'', language)
}
return null
}

View file

@ -34,15 +34,16 @@ export async function initI18n () {
.then(() => logger.info('i18n initialized with locales: ' + Object.keys(resources).join(', ')))
}
// ---------------------------------------------------------------------------
export type TranslateFn = (key: string, context: Record<string, string | number>) => string
export function useI18n (req: express.Request, res: express.Response, next: express.NextFunction) {
req.t = (key: string, context: Record<string, string | number> = {}) => {
// Use req special header language
// Or user language
// Or default language
const language = req.headers[LANGUAGE_HEADER] as string ||
res.locals.oauth?.token?.User?.language ||
req.acceptsLanguages(AVAILABLE_LOCALES) ||
CONFIG.INSTANCE.DEFAULT_LANGUAGE
const language = guessLanguageFromReq(req, res)
return t(key, language, context)
}
@ -50,6 +51,13 @@ export function useI18n (req: express.Request, res: express.Response, next: expr
next()
}
export function guessLanguageFromReq (req: express.Request, res: express.Response) {
return req.headers[LANGUAGE_HEADER] as string ||
res.locals.oauth?.token?.User?.language ||
req.acceptsLanguages(AVAILABLE_LOCALES) ||
CONFIG.INSTANCE.DEFAULT_LANGUAGE
}
export function t (key: string, language: string, context: Record<string, string | number> = {}) {
if (!language) throw new Error('Language is required for translation')
@ -74,3 +82,7 @@ export function setClientLanguageCookie (res: express.Response, language: string
maxAge: 1000 * 3600 * 24 * 90 // 3 months
})
}
// ---------------------------------------------------------------------------
export const englishLanguage = 'en-US'

View file

@ -8,12 +8,14 @@ import { basename } from 'path'
import { URL } from 'url'
import { parseBytes, parseSemVersion } from '../helpers/core-utils.js'
import { isArray } from '../helpers/custom-validators/misc.js'
import { getBrowseVideosDefaultSortError, getBrowseVideosDefaultScopeError } from '../helpers/custom-validators/browse-videos.js'
import { logger } from '../helpers/logger.js'
import { ApplicationModel, getServerActor } from '../models/application/application.js'
import { OAuthClientModel } from '../models/oauth/oauth-client.js'
import { UserModel } from '../models/user/user.js'
import { CONFIG, getLocalConfigFilePath, isEmailEnabled, reloadConfig } from './config.js'
import { WEBSERVER } from './constants.js'
import { englishLanguage } from '@server/helpers/i18n.js'
async function checkActivityPubUrls () {
const actor = await getServerActor()
@ -54,6 +56,7 @@ function checkConfig () {
checkObjectStorageConfig()
checkVideoStudioConfig()
checkThumbnailsConfig()
checkBrowseVideosConfig()
}
// We get db by param to not import it in this file (import orders)
@ -385,3 +388,16 @@ function checkThumbnailsConfig () {
throw new Error('thumbnails.sizes must be an array of 2 sizes')
}
}
function checkBrowseVideosConfig () {
const sortError = getBrowseVideosDefaultSortError(
CONFIG.CLIENT.BROWSE_VIDEOS.DEFAULT_SORT,
CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED,
englishLanguage
)
if (sortError) throw new Error(sortError)
const scopeError = getBrowseVideosDefaultScopeError(CONFIG.CLIENT.BROWSE_VIDEOS.DEFAULT_SCOPE, englishLanguage)
if (scopeError) throw new Error(scopeError)
}

View file

@ -125,6 +125,8 @@ export function checkMissedConfig () {
'auto_blacklist.videos.of_users.enabled',
'trending.videos.interval_days',
'client.videos.miniature.prefer_author_display_name',
'client.browse_videos.default_sort',
'client.browse_videos.default_scope',
'client.menu.login.redirect_on_single_external_auth',
'client.header.hide_instance_name',
'defaults.publish.download_enabled',

View file

@ -95,6 +95,14 @@ const CONFIG = {
}
}
},
BROWSE_VIDEOS: {
get DEFAULT_SORT () {
return config.get<string>('client.browse_videos.default_sort')
},
get DEFAULT_SCOPE () {
return config.get<string>('client.browse_videos.default_scope')
}
},
MENU: {
LOGIN: {
get REDIRECT_ON_SINGLE_EXTERNAL_AUTH () {

View file

@ -69,6 +69,10 @@ class ServerConfigManager {
maxChunkSize: CONFIG.CLIENT.VIDEOS.RESUMABLE_UPLOAD.MAX_CHUNK_SIZE
}
},
browseVideos: {
defaultSort: CONFIG.CLIENT.BROWSE_VIDEOS.DEFAULT_SORT,
defaultScope: CONFIG.CLIENT.BROWSE_VIDEOS.DEFAULT_SCOPE
},
menu: {
login: {
redirectOnSingleExternalAuth: CONFIG.CLIENT.MENU.LOGIN.REDIRECT_ON_SINGLE_EXTERNAL_AUTH

View file

@ -3,9 +3,11 @@ import { isConfigLogoTypeValid } from '@server/helpers/custom-validators/config.
import { isIntOrNull } from '@server/helpers/custom-validators/misc.js'
import { isNumberArray, isStringArray } from '@server/helpers/custom-validators/search.js'
import { isVideoCommentsPolicyValid, isVideoLicenceValid, isVideoPrivacyValid } from '@server/helpers/custom-validators/videos.js'
import { guessLanguageFromReq } from '@server/helpers/i18n.js'
import { CONFIG, isEmailEnabled } from '@server/initializers/config.js'
import express from 'express'
import { body, param } from 'express-validator'
import { getBrowseVideosDefaultScopeError, getBrowseVideosDefaultSortError } from '../../helpers/custom-validators/browse-videos.js'
import { isThemeNameValid } from '../../helpers/custom-validators/plugins.js'
import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users.js'
import { isThemeRegistered } from '../../lib/plugins/theme-utils.js'
@ -159,6 +161,7 @@ export const customConfigUpdateValidator = [
if (!checkInvalidLiveConfig(req.body, req, res)) return
if (!checkInvalidVideoStudioConfig(req.body, req, res)) return
if (!checkInvalidSearchConfig(req.body, req, res)) return
if (!checkInvalidBrowseVideosConfig(req.body, req, res)) return
return next()
}
@ -254,3 +257,23 @@ function checkInvalidSearchConfig (customConfig: CustomConfig, req: express.Requ
return true
}
function checkInvalidBrowseVideosConfig (customConfig: CustomConfig, req: express.Request, res: express.Response) {
const sortError = getBrowseVideosDefaultSortError(
customConfig.client.browseVideos.defaultSort,
customConfig.trending.videos.algorithms.enabled,
guessLanguageFromReq(req, res)
)
if (sortError) {
res.fail({ message: sortError })
return false
}
const scopeError = getBrowseVideosDefaultScopeError(customConfig.client.browseVideos.defaultScope, guessLanguageFromReq(req, res))
if (scopeError) {
res.fail({ message: scopeError })
return false
}
return true
}