1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 17:59:37 +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); @include peertube-select-container($form-base-input-width);
} }
my-select-videos-sort,
my-select-videos-scope,
my-select-checkbox, my-select-checkbox,
my-select-options, my-select-options,
my-select-custom-value { my-select-custom-value {

View file

@ -320,6 +320,34 @@
</div> </div>
</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="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>VIDEOS</h2> <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 { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component' import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.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 { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { UserRealQuotaInfoComponent } from '../../shared/user-real-quota-info.component' import { UserRealQuotaInfoComponent } from '../../shared/user-real-quota-info.component'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component' import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
@ -43,6 +45,11 @@ type Form = {
}> }>
client: FormGroup<{ client: FormGroup<{
browseVideos: FormGroup<{
defaultSort: FormControl<string>
defaultScope: FormControl<string>
}>
menu: FormGroup<{ menu: FormGroup<{
login: FormGroup<{ login: FormGroup<{
redirectOnSingleExternalAuth: FormControl<boolean> redirectOnSingleExternalAuth: FormControl<boolean>
@ -220,7 +227,9 @@ type Form = {
UserRealQuotaInfoComponent, UserRealQuotaInfoComponent,
SelectOptionsComponent, SelectOptionsComponent,
AlertComponent, AlertComponent,
AdminSaveBarComponent AdminSaveBarComponent,
SelectVideosSortComponent,
SelectVideosScopeComponent
] ]
}) })
export class AdminConfigGeneralComponent implements OnInit, OnDestroy, CanComponentDeactivate { export class AdminConfigGeneralComponent implements OnInit, OnDestroy, CanComponentDeactivate {
@ -295,6 +304,10 @@ export class AdminConfigGeneralComponent implements OnInit, OnDestroy, CanCompon
defaultClientRoute: null defaultClientRoute: null
}, },
client: { client: {
browseVideos: {
defaultSort: null,
defaultScope: null
},
menu: { menu: {
login: { login: {
redirectOnSingleExternalAuth: null redirectOnSingleExternalAuth: null

View file

@ -1,11 +1,11 @@
import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { ActivatedRoute } from '@angular/router' 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 { HooksService } from '@app/core/plugins/hooks.service'
import { VideoService } from '@app/shared/shared-main/video/video.service' import { VideoService } from '@app/shared/shared-main/video/video.service'
import { VideoFilterScope, VideoFilters } from '@app/shared/shared-video-miniature/video-filters.model' import { VideoFilterScope, VideoFilters } from '@app/shared/shared-video-miniature/video-filters.model'
import { VideosListComponent } from '@app/shared/shared-video-miniature/videos-list.component' 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' import { Subscription } from 'rxjs'
@Component({ @Component({
@ -19,6 +19,7 @@ export class VideosListAllComponent implements OnInit, OnDestroy, DisableForReus
private videoService = inject(VideoService) private videoService = inject(VideoService)
private hooks = inject(HooksService) private hooks = inject(HooksService)
private meta = inject(MetaService) private meta = inject(MetaService)
private serverService = inject(ServerService)
getVideosObservableFunction = this.getVideosObservable.bind(this) getVideosObservableFunction = this.getVideosObservable.bind(this)
getSyndicationItemsFunction = this.getSyndicationItems.bind(this) getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
@ -37,11 +38,14 @@ export class VideosListAllComponent implements OnInit, OnDestroy, DisableForReus
disabled = false disabled = false
private routeSub: Subscription private routeSub: Subscription
private serverConfig: HTMLServerConfig
ngOnInit () { ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
const queryParams = this.route.snapshot.queryParams const queryParams = this.route.snapshot.queryParams
this.defaultSort = queryParams.sort || '-publishedAt' this.defaultSort = queryParams.sort || this.serverConfig.client.browseVideos.defaultSort
this.defaultScope = queryParams.scope || 'federated' this.defaultScope = queryParams.scope || this.serverConfig.client.browseVideos.defaultScope
this.routeSub = this.route.params.subscribe(() => this.update()) 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"> <div class="d-flex flex-wrap align-items-center mb-2">
<label for="sort-videos" i18n class="select-label">Sort by:</label> <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>
</div> </div>
@ -115,7 +115,7 @@
<div class="form-group"> <div class="form-group">
<label for="scope" i18n>Displayed videos</label> <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>
</div> </div>

View file

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

View file

@ -8,11 +8,11 @@ import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap'
import { UserRight, VideoConstant } from '@peertube/peertube-models' import { UserRight, VideoConstant } from '@peertube/peertube-models'
import { AttributesOnly } from '@peertube/peertube-typescript-utils' import { AttributesOnly } from '@peertube/peertube-typescript-utils'
import debug from 'debug' import debug from 'debug'
import { SelectOptionsItem } from 'src/types'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component' import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
import { SelectCategoriesComponent } from '../shared-forms/select/select-categories.component' import { SelectCategoriesComponent } from '../shared-forms/select/select-categories.component'
import { SelectLanguagesComponent } from '../shared-forms/select/select-languages.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 { GlobalIconComponent, GlobalIconName } from '../shared-icons/global-icon.component'
import { InstanceFollowService } from '../shared-instance/instance-follow.service' import { InstanceFollowService } from '../shared-instance/instance-follow.service'
import { ButtonComponent } from '../shared-main/buttons/button.component' import { ButtonComponent } from '../shared-main/buttons/button.component'
@ -43,8 +43,9 @@ type QuickFilter = {
SelectLanguagesComponent, SelectLanguagesComponent,
SelectCategoriesComponent, SelectCategoriesComponent,
PeertubeCheckboxComponent, PeertubeCheckboxComponent,
SelectOptionsComponent, ButtonComponent,
ButtonComponent SelectVideosSortComponent,
SelectVideosScopeComponent
], ],
providers: [ InstanceFollowService ] providers: [ InstanceFollowService ]
}) })
@ -65,9 +66,6 @@ export class VideoFiltersHeaderComponent implements OnInit {
form: FormGroup form: FormGroup
sortItems: SelectOptionsItem[] = []
availableScopes: SelectOptionsItem[] = []
quickFilters: QuickFilter[] = [] quickFilters: QuickFilter[] = []
instanceName: string instanceName: string
@ -109,12 +107,6 @@ export class VideoFiltersHeaderComponent implements OnInit {
this.followService.getFollowing({ pagination: { count: 1, start: 0 }, state: 'accepted' }) this.followService.getFollowing({ pagination: { count: 1, start: 0 }, state: 'accepted' })
.subscribe(({ total }) => this.totalFollowing = total) .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() 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) { getFilterValue (filter: VideoFilterActive) {
if ((filter.key === 'categoryOneOf' || filter.key === 'languageOneOf') && Array.isArray(filter.rawValue)) { if ((filter.key === 'categoryOneOf' || filter.key === 'languageOneOf') && Array.isArray(filter.rawValue)) {
if (filter.rawValue.length > 2) { if (filter.rawValue.length > 2) {

View file

@ -1088,6 +1088,22 @@ client:
# If null, it will be calculated based on network speed # If null, it will be calculated based on network speed
max_chunk_size: null 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: menu:
login: login:
# If you enable only one external auth plugin # 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 # If null, it will be calculated based on network speed
max_chunk_size: null 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: menu:
login: login:
# If you enable only one external auth plugin # If you enable only one external auth plugin

View file

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

View file

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

View file

@ -221,6 +221,69 @@ describe('Test config API validators', function () {
expectedStatus: HttpStatusCode.OK_200 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 () { 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.header.hideInstanceName).to.be.false
expect(data.client.videos.miniature.preferAuthorDisplayName).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.client.menu.login.redirectOnSingleExternalAuth).to.be.false
expect(data.cache.previews.size).to.equal(1) expect(data.cache.previews.size).to.equal(1)
@ -233,6 +235,10 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
preferAuthorDisplayName: true preferAuthorDisplayName: true
} }
}, },
browseVideos: {
defaultSort: '-trending',
defaultScope: 'local'
},
menu: { menu: {
login: { login: {
redirectOnSingleExternalAuth: true redirectOnSingleExternalAuth: true

View file

@ -366,6 +366,10 @@ function customConfig (): CustomConfig {
preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME 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: { menu: {
login: { login: {
redirectOnSingleExternalAuth: CONFIG.CLIENT.MENU.LOGIN.REDIRECT_ON_SINGLE_EXTERNAL_AUTH 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(', '))) .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) { export function useI18n (req: express.Request, res: express.Response, next: express.NextFunction) {
req.t = (key: string, context: Record<string, string | number> = {}) => { req.t = (key: string, context: Record<string, string | number> = {}) => {
// Use req special header language // Use req special header language
// Or user language // Or user language
// Or default language // Or default language
const language = req.headers[LANGUAGE_HEADER] as string || const language = guessLanguageFromReq(req, res)
res.locals.oauth?.token?.User?.language ||
req.acceptsLanguages(AVAILABLE_LOCALES) ||
CONFIG.INSTANCE.DEFAULT_LANGUAGE
return t(key, language, context) return t(key, language, context)
} }
@ -50,6 +51,13 @@ export function useI18n (req: express.Request, res: express.Response, next: expr
next() 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> = {}) { export function t (key: string, language: string, context: Record<string, string | number> = {}) {
if (!language) throw new Error('Language is required for translation') 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 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 { URL } from 'url'
import { parseBytes, parseSemVersion } from '../helpers/core-utils.js' import { parseBytes, parseSemVersion } from '../helpers/core-utils.js'
import { isArray } from '../helpers/custom-validators/misc.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 { logger } from '../helpers/logger.js'
import { ApplicationModel, getServerActor } from '../models/application/application.js' import { ApplicationModel, getServerActor } from '../models/application/application.js'
import { OAuthClientModel } from '../models/oauth/oauth-client.js' import { OAuthClientModel } from '../models/oauth/oauth-client.js'
import { UserModel } from '../models/user/user.js' import { UserModel } from '../models/user/user.js'
import { CONFIG, getLocalConfigFilePath, isEmailEnabled, reloadConfig } from './config.js' import { CONFIG, getLocalConfigFilePath, isEmailEnabled, reloadConfig } from './config.js'
import { WEBSERVER } from './constants.js' import { WEBSERVER } from './constants.js'
import { englishLanguage } from '@server/helpers/i18n.js'
async function checkActivityPubUrls () { async function checkActivityPubUrls () {
const actor = await getServerActor() const actor = await getServerActor()
@ -54,6 +56,7 @@ function checkConfig () {
checkObjectStorageConfig() checkObjectStorageConfig()
checkVideoStudioConfig() checkVideoStudioConfig()
checkThumbnailsConfig() checkThumbnailsConfig()
checkBrowseVideosConfig()
} }
// We get db by param to not import it in this file (import orders) // 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') 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', 'auto_blacklist.videos.of_users.enabled',
'trending.videos.interval_days', 'trending.videos.interval_days',
'client.videos.miniature.prefer_author_display_name', '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.menu.login.redirect_on_single_external_auth',
'client.header.hide_instance_name', 'client.header.hide_instance_name',
'defaults.publish.download_enabled', '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: { MENU: {
LOGIN: { LOGIN: {
get REDIRECT_ON_SINGLE_EXTERNAL_AUTH () { get REDIRECT_ON_SINGLE_EXTERNAL_AUTH () {

View file

@ -69,6 +69,10 @@ class ServerConfigManager {
maxChunkSize: CONFIG.CLIENT.VIDEOS.RESUMABLE_UPLOAD.MAX_CHUNK_SIZE 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: { menu: {
login: { login: {
redirectOnSingleExternalAuth: CONFIG.CLIENT.MENU.LOGIN.REDIRECT_ON_SINGLE_EXTERNAL_AUTH 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 { isIntOrNull } from '@server/helpers/custom-validators/misc.js'
import { isNumberArray, isStringArray } from '@server/helpers/custom-validators/search.js' import { isNumberArray, isStringArray } from '@server/helpers/custom-validators/search.js'
import { isVideoCommentsPolicyValid, isVideoLicenceValid, isVideoPrivacyValid } from '@server/helpers/custom-validators/videos.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 { CONFIG, isEmailEnabled } from '@server/initializers/config.js'
import express from 'express' import express from 'express'
import { body, param } from 'express-validator' 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 { isThemeNameValid } from '../../helpers/custom-validators/plugins.js'
import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users.js' import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users.js'
import { isThemeRegistered } from '../../lib/plugins/theme-utils.js' import { isThemeRegistered } from '../../lib/plugins/theme-utils.js'
@ -159,6 +161,7 @@ export const customConfigUpdateValidator = [
if (!checkInvalidLiveConfig(req.body, req, res)) return if (!checkInvalidLiveConfig(req.body, req, res)) return
if (!checkInvalidVideoStudioConfig(req.body, req, res)) return if (!checkInvalidVideoStudioConfig(req.body, req, res)) return
if (!checkInvalidSearchConfig(req.body, req, res)) return if (!checkInvalidSearchConfig(req.body, req, res)) return
if (!checkInvalidBrowseVideosConfig(req.body, req, res)) return
return next() return next()
} }
@ -254,3 +257,23 @@ function checkInvalidSearchConfig (customConfig: CustomConfig, req: express.Requ
return true 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
}