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 +}