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:
parent
9b7edd1c59
commit
e74bf8ae2a
23 changed files with 436 additions and 54 deletions
|
@ -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 {
|
||||
|
|
|
@ -320,6 +320,34 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4">
|
||||
<div class="title-col">
|
||||
<h2 i18n>BROWSE VIDEOS</h2>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
<div class="form-group" formGroupName="client">
|
||||
<ng-container formGroupName="browseVideos">
|
||||
<div class="form-group">
|
||||
<label i18n for="browseVideosDefaultSort">Default sort</label>
|
||||
|
||||
<my-select-videos-sort inputId="browseVideosDefaultSort" formControlName="defaultSort"></my-select-videos-sort>
|
||||
|
||||
<div *ngIf="formErrors.client.browseVideos.defaultSort" class="form-error" role="alert">{{ formErrors.client.browseVideos.defaultSort }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="browseVideosDefaultScope">Default scope</label>
|
||||
|
||||
<my-select-videos-scope inputId="browseVideosDefaultScope" formControlName="defaultScope"></my-select-videos-scope>
|
||||
|
||||
<div *ngIf="formErrors.client.browseVideos.defaultScope" class="form-error" role="alert">{{ formErrors.client.browseVideos.defaultScope }}</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4">
|
||||
<div class="title-col">
|
||||
<h2 i18n>VIDEOS</h2>
|
||||
|
|
|
@ -33,6 +33,8 @@ import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown
|
|||
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
|
||||
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
|
||||
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
|
||||
import { SelectVideosSortComponent } from '../../../shared/shared-forms/select/select-videos-sort.component'
|
||||
import { SelectVideosScopeComponent } from '../../../shared/shared-forms/select/select-videos-scope.component'
|
||||
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
|
||||
import { UserRealQuotaInfoComponent } from '../../shared/user-real-quota-info.component'
|
||||
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
|
||||
|
@ -43,6 +45,11 @@ type Form = {
|
|||
}>
|
||||
|
||||
client: FormGroup<{
|
||||
browseVideos: FormGroup<{
|
||||
defaultSort: FormControl<string>
|
||||
defaultScope: FormControl<string>
|
||||
}>
|
||||
|
||||
menu: FormGroup<{
|
||||
login: FormGroup<{
|
||||
redirectOnSingleExternalAuth: FormControl<boolean>
|
||||
|
@ -220,7 +227,9 @@ type Form = {
|
|||
UserRealQuotaInfoComponent,
|
||||
SelectOptionsComponent,
|
||||
AlertComponent,
|
||||
AdminSaveBarComponent
|
||||
AdminSaveBarComponent,
|
||||
SelectVideosSortComponent,
|
||||
SelectVideosScopeComponent
|
||||
]
|
||||
})
|
||||
export class AdminConfigGeneralComponent implements OnInit, OnDestroy, CanComponentDeactivate {
|
||||
|
@ -295,6 +304,10 @@ export class AdminConfigGeneralComponent implements OnInit, OnDestroy, CanCompon
|
|||
defaultClientRoute: null
|
||||
},
|
||||
client: {
|
||||
browseVideos: {
|
||||
defaultSort: null,
|
||||
defaultScope: null
|
||||
},
|
||||
menu: {
|
||||
login: {
|
||||
redirectOnSingleExternalAuth: null
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -59,7 +59,7 @@
|
|||
<div class="d-flex flex-wrap align-items-center mb-2">
|
||||
<label for="sort-videos" i18n class="select-label">Sort by:</label>
|
||||
|
||||
<my-select-options inputId="sort-videos" class="d-inline-block me-2" formControlName="sort" [items]="sortItems"></my-select-options>
|
||||
<my-select-videos-sort inputId="sort-videos" class="d-inline-block me-2" formControlName="sort"></my-select-videos-sort>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -115,7 +115,7 @@
|
|||
<div class="form-group">
|
||||
<label for="scope" i18n>Displayed videos</label>
|
||||
|
||||
<my-select-options inputId="scope" class="scope-select" formControlName="scope" [items]="availableScopes"></my-select-options>
|
||||
<my-select-videos-scope inputId="scope" class="scope-select" formControlName="scope"></my-select-videos-scope>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -91,6 +91,11 @@ export interface CustomConfig {
|
|||
}
|
||||
}
|
||||
|
||||
browseVideos: {
|
||||
defaultSort: string
|
||||
defaultScope: string
|
||||
}
|
||||
|
||||
menu: {
|
||||
login: {
|
||||
redirectOnSingleExternalAuth: boolean
|
||||
|
|
|
@ -50,6 +50,11 @@ export interface ServerConfig {
|
|||
}
|
||||
}
|
||||
|
||||
browseVideos: {
|
||||
defaultSort: string
|
||||
defaultScope: string
|
||||
}
|
||||
|
||||
menu: {
|
||||
login: {
|
||||
redirectOnSingleExternalAuth: boolean
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
35
server/core/helpers/custom-validators/browse-videos.ts
Normal file
35
server/core/helpers/custom-validators/browse-videos.ts
Normal 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
|
||||
}
|
|
@ -34,15 +34,16 @@ export async function initI18n () {
|
|||
.then(() => logger.info('i18n initialized with locales: ' + Object.keys(resources).join(', ')))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TranslateFn = (key: string, context: Record<string, string | number>) => string
|
||||
|
||||
export function useI18n (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
req.t = (key: string, context: Record<string, string | number> = {}) => {
|
||||
// Use req special header language
|
||||
// Or user language
|
||||
// Or default language
|
||||
const language = req.headers[LANGUAGE_HEADER] as string ||
|
||||
res.locals.oauth?.token?.User?.language ||
|
||||
req.acceptsLanguages(AVAILABLE_LOCALES) ||
|
||||
CONFIG.INSTANCE.DEFAULT_LANGUAGE
|
||||
const language = guessLanguageFromReq(req, res)
|
||||
|
||||
return t(key, language, context)
|
||||
}
|
||||
|
@ -50,6 +51,13 @@ export function useI18n (req: express.Request, res: express.Response, next: expr
|
|||
next()
|
||||
}
|
||||
|
||||
export function guessLanguageFromReq (req: express.Request, res: express.Response) {
|
||||
return req.headers[LANGUAGE_HEADER] as string ||
|
||||
res.locals.oauth?.token?.User?.language ||
|
||||
req.acceptsLanguages(AVAILABLE_LOCALES) ||
|
||||
CONFIG.INSTANCE.DEFAULT_LANGUAGE
|
||||
}
|
||||
|
||||
export function t (key: string, language: string, context: Record<string, string | number> = {}) {
|
||||
if (!language) throw new Error('Language is required for translation')
|
||||
|
||||
|
@ -74,3 +82,7 @@ export function setClientLanguageCookie (res: express.Response, language: string
|
|||
maxAge: 1000 * 3600 * 24 * 90 // 3 months
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const englishLanguage = 'en-US'
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -95,6 +95,14 @@ const CONFIG = {
|
|||
}
|
||||
}
|
||||
},
|
||||
BROWSE_VIDEOS: {
|
||||
get DEFAULT_SORT () {
|
||||
return config.get<string>('client.browse_videos.default_sort')
|
||||
},
|
||||
get DEFAULT_SCOPE () {
|
||||
return config.get<string>('client.browse_videos.default_scope')
|
||||
}
|
||||
},
|
||||
MENU: {
|
||||
LOGIN: {
|
||||
get REDIRECT_ON_SINGLE_EXTERNAL_AUTH () {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue