diff --git a/client/src/app/+admin/config/pages/admin-config-common.scss b/client/src/app/+admin/config/pages/admin-config-common.scss
index 5d9469b69..7741b950d 100644
--- a/client/src/app/+admin/config/pages/admin-config-common.scss
+++ b/client/src/app/+admin/config/pages/admin-config-common.scss
@@ -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 {
diff --git a/client/src/app/+admin/config/pages/admin-config-general.component.html b/client/src/app/+admin/config/pages/admin-config-general.component.html
index 42d154e0f..3570f57f7 100644
--- a/client/src/app/+admin/config/pages/admin-config-general.component.html
+++ b/client/src/app/+admin/config/pages/admin-config-general.component.html
@@ -320,6 +320,34 @@
+
+
+
BROWSE VIDEOS
+
+
+
+
+
VIDEOS
diff --git a/client/src/app/+admin/config/pages/admin-config-general.component.ts b/client/src/app/+admin/config/pages/admin-config-general.component.ts
index ba2be5e07..1cf99599b 100644
--- a/client/src/app/+admin/config/pages/admin-config-general.component.ts
+++ b/client/src/app/+admin/config/pages/admin-config-general.component.ts
@@ -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
+ defaultScope: FormControl
+ }>
+
menu: FormGroup<{
login: FormGroup<{
redirectOnSingleExternalAuth: FormControl
@@ -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
diff --git a/client/src/app/+video-list/videos-list-all.component.ts b/client/src/app/+video-list/videos-list-all.component.ts
index 1f089af76..914a9a168 100644
--- a/client/src/app/+video-list/videos-list-all.component.ts
+++ b/client/src/app/+video-list/videos-list-all.component.ts
@@ -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())
}
diff --git a/client/src/app/shared/shared-forms/select/select-videos-scope.component.ts b/client/src/app/shared/shared-forms/select/select-videos-scope.component.ts
new file mode 100644
index 000000000..8809517ce
--- /dev/null
+++ b/client/src/app/shared/shared-forms/select/select-videos-scope.component.ts
@@ -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: `
+
+ `,
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => SelectVideosScopeComponent),
+ multi: true
+ }
+ ],
+ imports: [ FormsModule, CommonModule, SelectOptionsComponent ]
+})
+export class SelectVideosScopeComponent implements ControlValueAccessor, OnInit {
+ readonly inputId = input.required()
+
+ 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)
+ }
+}
diff --git a/client/src/app/shared/shared-forms/select/select-videos-sort.component.ts b/client/src/app/shared/shared-forms/select/select-videos-sort.component.ts
new file mode 100644
index 000000000..dfb1a7074
--- /dev/null
+++ b/client/src/app/shared/shared-forms/select/select-videos-sort.component.ts
@@ -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: `
+
+ `,
+ 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()
+
+ 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)
+ }
+}
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
index a7d74c7d6..c079fa1e1 100644
--- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
@@ -59,7 +59,7 @@
-
+
@@ -115,7 +115,7 @@
-
+
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
index d27c883b0..5d62b0854 100644
--- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
@@ -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;
}
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts b/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts
index 667f1bab7..5c75780ed 100644
--- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts
@@ -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) {
diff --git a/config/default.yaml b/config/default.yaml
index cd64cb397..f6be2eb4c 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -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
diff --git a/config/production.yaml.example b/config/production.yaml.example
index 7c1df414f..6901b8ea7 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -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
diff --git a/packages/models/src/server/custom-config.model.ts b/packages/models/src/server/custom-config.model.ts
index 05efb74f2..993d7bdd8 100644
--- a/packages/models/src/server/custom-config.model.ts
+++ b/packages/models/src/server/custom-config.model.ts
@@ -91,6 +91,11 @@ export interface CustomConfig {
}
}
+ browseVideos: {
+ defaultSort: string
+ defaultScope: string
+ }
+
menu: {
login: {
redirectOnSingleExternalAuth: boolean
diff --git a/packages/models/src/server/server-config.model.ts b/packages/models/src/server/server-config.model.ts
index aabcf706a..5d8aeac07 100644
--- a/packages/models/src/server/server-config.model.ts
+++ b/packages/models/src/server/server-config.model.ts
@@ -50,6 +50,11 @@ export interface ServerConfig {
}
}
+ browseVideos: {
+ defaultSort: string
+ defaultScope: string
+ }
+
menu: {
login: {
redirectOnSingleExternalAuth: boolean
diff --git a/packages/tests/src/api/check-params/config.ts b/packages/tests/src/api/check-params/config.ts
index aeec3df35..a1e3f5658 100644
--- a/packages/tests/src/api/check-params/config.ts
+++ b/packages/tests/src/api/check-params/config.ts
@@ -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 () {
diff --git a/packages/tests/src/api/server/config.ts b/packages/tests/src/api/server/config.ts
index ddccde22a..463e00eea 100644
--- a/packages/tests/src/api/server/config.ts
+++ b/packages/tests/src/api/server/config.ts
@@ -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
diff --git a/server/core/controllers/api/config.ts b/server/core/controllers/api/config.ts
index 2656b023a..df01b5ab5 100644
--- a/server/core/controllers/api/config.ts
+++ b/server/core/controllers/api/config.ts
@@ -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
diff --git a/server/core/helpers/custom-validators/browse-videos.ts b/server/core/helpers/custom-validators/browse-videos.ts
new file mode 100644
index 000000000..8f23a8835
--- /dev/null
+++ b/server/core/helpers/custom-validators/browse-videos.ts
@@ -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([
+ [ '-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
+}
diff --git a/server/core/helpers/i18n.ts b/server/core/helpers/i18n.ts
index e760c6957..9ed7aa53d 100644
--- a/server/core/helpers/i18n.ts
+++ b/server/core/helpers/i18n.ts
@@ -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
+
export function useI18n (req: express.Request, res: express.Response, next: express.NextFunction) {
req.t = (key: string, context: Record = {}) => {
// 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 = {}) {
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'
diff --git a/server/core/initializers/checker-after-init.ts b/server/core/initializers/checker-after-init.ts
index d3b430dd9..f3f8314f9 100644
--- a/server/core/initializers/checker-after-init.ts
+++ b/server/core/initializers/checker-after-init.ts
@@ -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)
+}
diff --git a/server/core/initializers/checker-before-init.ts b/server/core/initializers/checker-before-init.ts
index 9d90dac95..8247ba0fc 100644
--- a/server/core/initializers/checker-before-init.ts
+++ b/server/core/initializers/checker-before-init.ts
@@ -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',
diff --git a/server/core/initializers/config.ts b/server/core/initializers/config.ts
index 5f8f29397..409495d1d 100644
--- a/server/core/initializers/config.ts
+++ b/server/core/initializers/config.ts
@@ -95,6 +95,14 @@ const CONFIG = {
}
}
},
+ BROWSE_VIDEOS: {
+ get DEFAULT_SORT () {
+ return config.get('client.browse_videos.default_sort')
+ },
+ get DEFAULT_SCOPE () {
+ return config.get('client.browse_videos.default_scope')
+ }
+ },
MENU: {
LOGIN: {
get REDIRECT_ON_SINGLE_EXTERNAL_AUTH () {
diff --git a/server/core/lib/server-config-manager.ts b/server/core/lib/server-config-manager.ts
index be9767494..2d30359f9 100644
--- a/server/core/lib/server-config-manager.ts
+++ b/server/core/lib/server-config-manager.ts
@@ -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
diff --git a/server/core/middlewares/validators/config.ts b/server/core/middlewares/validators/config.ts
index e47e33129..9840be368 100644
--- a/server/core/middlewares/validators/config.ts
+++ b/server/core/middlewares/validators/config.ts
@@ -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
+}