Add language filters in user preferences
This commit is contained in:
parent
bbe078ba55
commit
3caf77d3b1
|
@ -7,6 +7,9 @@
|
|||
<div i18n class="account-title">Profile</div>
|
||||
<my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile>
|
||||
|
||||
<div i18n class="account-title">Video settings</div>
|
||||
<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
|
||||
|
||||
<div i18n class="account-title" id="notifications">Notifications</div>
|
||||
<my-account-notification-preferences [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-notification-preferences>
|
||||
|
||||
|
@ -16,8 +19,5 @@
|
|||
<div i18n class="account-title">Email</div>
|
||||
<my-account-change-email></my-account-change-email>
|
||||
|
||||
<div i18n class="account-title">Video settings</div>
|
||||
<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
|
||||
|
||||
<div i18n class="account-title">Danger zone</div>
|
||||
<my-account-danger-zone [user]="user"></my-account-danger-zone>
|
||||
|
|
|
@ -15,6 +15,21 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="videoLanguages">Only display videos in the following languages</label>
|
||||
<my-help i18n-customHtml
|
||||
customHtml="In Recently added, Trending, Local and Search pages"
|
||||
></my-help>
|
||||
|
||||
<div>
|
||||
<p-multiSelect
|
||||
[options]="languageItems" formControlName="videoLanguages" showToggleAll="true"
|
||||
[defaultLabel]="getDefaultVideoLanguageLabel()" [selectedItemsLabel]="getSelectedVideoLanguageLabel()"
|
||||
emptyFilterMessage="No results found" i18n-emptyFilterMessage
|
||||
></p-multiSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="webTorrentEnabled" formControlName="webTorrentEnabled"
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { Component, Input, OnInit } from '@angular/core'
|
||||
import { Notifier } from '@app/core'
|
||||
import { Notifier, ServerService } from '@app/core'
|
||||
import { UserUpdateMe } from '../../../../../../shared'
|
||||
import { AuthService } from '../../../core'
|
||||
import { FormReactive, User, UserService } from '../../../shared'
|
||||
import { I18n } from '@ngx-translate/i18n-polyfill'
|
||||
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
|
||||
import { Subject } from 'rxjs'
|
||||
import { SelectItem } from 'primeng/api'
|
||||
import { switchMap } from 'rxjs/operators'
|
||||
|
||||
@Component({
|
||||
selector: 'my-account-video-settings',
|
||||
|
@ -16,11 +18,14 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
|
|||
@Input() user: User = null
|
||||
@Input() userInformationLoaded: Subject<any>
|
||||
|
||||
languageItems: SelectItem[] = []
|
||||
|
||||
constructor (
|
||||
protected formValidatorService: FormValidatorService,
|
||||
private authService: AuthService,
|
||||
private notifier: Notifier,
|
||||
private userService: UserService,
|
||||
private serverService: ServerService,
|
||||
private i18n: I18n
|
||||
) {
|
||||
super()
|
||||
|
@ -30,31 +35,60 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
|
|||
this.buildForm({
|
||||
nsfwPolicy: null,
|
||||
webTorrentEnabled: null,
|
||||
autoPlayVideo: null
|
||||
autoPlayVideo: null,
|
||||
videoLanguages: null
|
||||
})
|
||||
|
||||
this.userInformationLoaded.subscribe(() => {
|
||||
this.form.patchValue({
|
||||
nsfwPolicy: this.user.nsfwPolicy,
|
||||
webTorrentEnabled: this.user.webTorrentEnabled,
|
||||
autoPlayVideo: this.user.autoPlayVideo === true
|
||||
})
|
||||
})
|
||||
this.serverService.videoLanguagesLoaded
|
||||
.pipe(switchMap(() => this.userInformationLoaded))
|
||||
.subscribe(() => {
|
||||
const languages = this.serverService.getVideoLanguages()
|
||||
|
||||
this.languageItems = [ { label: this.i18n('Unknown language'), value: '_unknown' } ]
|
||||
this.languageItems = this.languageItems
|
||||
.concat(languages.map(l => ({ label: l.label, value: l.id })))
|
||||
|
||||
const videoLanguages = this.user.videoLanguages
|
||||
? this.user.videoLanguages
|
||||
: this.languageItems.map(l => l.value)
|
||||
|
||||
this.form.patchValue({
|
||||
nsfwPolicy: this.user.nsfwPolicy,
|
||||
webTorrentEnabled: this.user.webTorrentEnabled,
|
||||
autoPlayVideo: this.user.autoPlayVideo === true,
|
||||
videoLanguages
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
updateDetails () {
|
||||
const nsfwPolicy = this.form.value['nsfwPolicy']
|
||||
const webTorrentEnabled = this.form.value['webTorrentEnabled']
|
||||
const autoPlayVideo = this.form.value['autoPlayVideo']
|
||||
|
||||
let videoLanguages: string[] = this.form.value['videoLanguages']
|
||||
if (Array.isArray(videoLanguages)) {
|
||||
if (videoLanguages.length === this.languageItems.length) {
|
||||
videoLanguages = null // null means "All"
|
||||
} else if (videoLanguages.length > 20) {
|
||||
this.notifier.error('Too many languages are enabled. Please enable them all or stay below 20 enabled languages.')
|
||||
return
|
||||
} else if (videoLanguages.length === 0) {
|
||||
this.notifier.error('You need to enabled at least 1 video language.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const details: UserUpdateMe = {
|
||||
nsfwPolicy,
|
||||
webTorrentEnabled,
|
||||
autoPlayVideo
|
||||
autoPlayVideo,
|
||||
videoLanguages
|
||||
}
|
||||
|
||||
this.userService.updateMyProfile(details).subscribe(
|
||||
() => {
|
||||
this.notifier.success(this.i18n('Information updated.'))
|
||||
this.notifier.success(this.i18n('Video settings updated.'))
|
||||
|
||||
this.authService.refreshUserInformation()
|
||||
},
|
||||
|
@ -62,4 +96,12 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
|
|||
err => this.notifier.error(err.message)
|
||||
)
|
||||
}
|
||||
|
||||
getDefaultVideoLanguageLabel () {
|
||||
return this.i18n('No language')
|
||||
}
|
||||
|
||||
getSelectedVideoLanguageLabel () {
|
||||
return this.i18n('{{\'{0} languages selected')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,18 +25,13 @@ import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-b
|
|||
import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
|
||||
import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
|
||||
import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences'
|
||||
import {
|
||||
MyAccountVideoPlaylistCreateComponent
|
||||
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
|
||||
import {
|
||||
MyAccountVideoPlaylistUpdateComponent
|
||||
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
|
||||
import { MyAccountVideoPlaylistCreateComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
|
||||
import { MyAccountVideoPlaylistUpdateComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
|
||||
import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
|
||||
import {
|
||||
MyAccountVideoPlaylistElementsComponent
|
||||
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
|
||||
import { MyAccountVideoPlaylistElementsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||
import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email'
|
||||
import { MultiSelectModule } from 'primeng/primeng'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
@ -46,7 +41,8 @@ import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-setti
|
|||
SharedModule,
|
||||
TableModule,
|
||||
InputSwitchModule,
|
||||
DragDropModule
|
||||
DragDropModule,
|
||||
MultiSelectModule
|
||||
],
|
||||
|
||||
declarations: [
|
||||
|
|
|
@ -18,6 +18,7 @@ export class User implements UserServerModel {
|
|||
webTorrentEnabled: boolean
|
||||
autoPlayVideo: boolean
|
||||
videosHistoryEnabled: boolean
|
||||
videoLanguages: string[]
|
||||
|
||||
videoQuota: number
|
||||
videoQuotaDaily: number
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { debounceTime } from 'rxjs/operators'
|
||||
import { debounceTime, first, tap } from 'rxjs/operators'
|
||||
import { OnDestroy, OnInit } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { fromEvent, Observable, Subscription } from 'rxjs'
|
||||
import { fromEvent, Observable, of, Subscription } from 'rxjs'
|
||||
import { AuthService } from '../../core/auth'
|
||||
import { ComponentPagination } from '../rest/component-pagination.model'
|
||||
import { VideoSortField } from './sort-field.type'
|
||||
|
@ -32,18 +32,20 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
|
|||
sort: VideoSortField = '-publishedAt'
|
||||
|
||||
categoryOneOf?: number
|
||||
languageOneOf?: string[]
|
||||
defaultSort: VideoSortField = '-publishedAt'
|
||||
|
||||
syndicationItems: Syndication[] = []
|
||||
|
||||
loadOnInit = true
|
||||
videos: Video[] = []
|
||||
useUserVideoLanguagePreferences = false
|
||||
ownerDisplayType: OwnerDisplayType = 'account'
|
||||
displayModerationBlock = false
|
||||
titleTooltip: string
|
||||
displayVideoActions = true
|
||||
groupByDate = false
|
||||
|
||||
videos: Video[] = []
|
||||
disabled = false
|
||||
|
||||
displayOptions: MiniatureDisplayOptions = {
|
||||
|
@ -98,7 +100,12 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
|
|||
.subscribe(() => this.calcPageSizes())
|
||||
|
||||
this.calcPageSizes()
|
||||
if (this.loadOnInit === true) this.loadMoreVideos()
|
||||
|
||||
const loadUserObservable = this.loadUserVideoLanguagesIfNeeded()
|
||||
|
||||
if (this.loadOnInit === true) {
|
||||
loadUserObservable.subscribe(() => this.loadMoreVideos())
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
|
@ -245,4 +252,16 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
|
|||
|
||||
this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' })
|
||||
}
|
||||
|
||||
private loadUserVideoLanguagesIfNeeded () {
|
||||
if (!this.authService.isLoggedIn() || !this.useUserVideoLanguagePreferences) {
|
||||
return of(true)
|
||||
}
|
||||
|
||||
return this.authService.userInformationLoaded
|
||||
.pipe(
|
||||
first(),
|
||||
tap(() => this.languageOneOf = this.user.videoLanguages)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,12 +35,13 @@ import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
|
|||
import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
|
||||
|
||||
export interface VideosProvider {
|
||||
getVideos (
|
||||
getVideos (parameters: {
|
||||
videoPagination: ComponentPagination,
|
||||
sort: VideoSortField,
|
||||
filter?: VideoFilter,
|
||||
categoryOneOf?: number
|
||||
): Observable<{ videos: Video[], totalVideos: number }>
|
||||
categoryOneOf?: number,
|
||||
languageOneOf?: string[]
|
||||
}): Observable<{ videos: Video[], totalVideos: number }>
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
@ -206,12 +207,15 @@ export class VideoService implements VideosProvider {
|
|||
)
|
||||
}
|
||||
|
||||
getVideos (
|
||||
getVideos (parameters: {
|
||||
videoPagination: ComponentPagination,
|
||||
sort: VideoSortField,
|
||||
filter?: VideoFilter,
|
||||
categoryOneOf?: number
|
||||
): Observable<{ videos: Video[], totalVideos: number }> {
|
||||
categoryOneOf?: number,
|
||||
languageOneOf?: string[]
|
||||
}): Observable<{ videos: Video[], totalVideos: number }> {
|
||||
const { videoPagination, sort, filter, categoryOneOf, languageOneOf } = parameters
|
||||
|
||||
const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
|
||||
|
||||
let params = new HttpParams()
|
||||
|
@ -225,6 +229,12 @@ export class VideoService implements VideosProvider {
|
|||
params = params.set('categoryOneOf', categoryOneOf + '')
|
||||
}
|
||||
|
||||
if (languageOneOf) {
|
||||
for (const l of languageOneOf) {
|
||||
params = params.append('languageOneOf[]', l)
|
||||
}
|
||||
}
|
||||
|
||||
return this.authHttp
|
||||
.get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params })
|
||||
.pipe(
|
||||
|
|
|
@ -32,7 +32,7 @@ export class RecentVideosRecommendationService implements RecommendationService
|
|||
|
||||
private fetchPage (page: number, recommendation: RecommendationInfo): Observable<Video[]> {
|
||||
const pagination = { currentPage: page, itemsPerPage: this.pageSize + 1 }
|
||||
const defaultSubscription = this.videos.getVideos(pagination, '-createdAt')
|
||||
const defaultSubscription = this.videos.getVideos({ videoPagination: pagination, sort: '-createdAt' })
|
||||
.pipe(map(v => v.videos))
|
||||
|
||||
if (!recommendation.tags || recommendation.tags.length === 0) return defaultSubscription
|
||||
|
|
|
@ -21,6 +21,8 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
|
|||
sort = '-publishedAt' as VideoSortField
|
||||
filter: VideoFilter = 'local'
|
||||
|
||||
useUserVideoLanguagePreferences = true
|
||||
|
||||
constructor (
|
||||
protected i18n: I18n,
|
||||
protected router: Router,
|
||||
|
@ -54,7 +56,13 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
|
|||
getVideosObservable (page: number) {
|
||||
const newPagination = immutableAssign(this.pagination, { currentPage: page })
|
||||
|
||||
return this.videoService.getVideos(newPagination, this.sort, this.filter, this.categoryOneOf)
|
||||
return this.videoService.getVideos({
|
||||
videoPagination: newPagination,
|
||||
sort: this.sort,
|
||||
filter: this.filter,
|
||||
categoryOneOf: this.categoryOneOf,
|
||||
languageOneOf: this.languageOneOf
|
||||
})
|
||||
}
|
||||
|
||||
generateSyndicationList () {
|
||||
|
|
|
@ -19,6 +19,8 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On
|
|||
sort: VideoSortField = '-publishedAt'
|
||||
groupByDate = true
|
||||
|
||||
useUserVideoLanguagePreferences = true
|
||||
|
||||
constructor (
|
||||
protected i18n: I18n,
|
||||
protected route: ActivatedRoute,
|
||||
|
@ -47,7 +49,13 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On
|
|||
getVideosObservable (page: number) {
|
||||
const newPagination = immutableAssign(this.pagination, { currentPage: page })
|
||||
|
||||
return this.videoService.getVideos(newPagination, this.sort, undefined, this.categoryOneOf)
|
||||
return this.videoService.getVideos({
|
||||
videoPagination: newPagination,
|
||||
sort: this.sort,
|
||||
filter: undefined,
|
||||
categoryOneOf: this.categoryOneOf,
|
||||
languageOneOf: this.languageOneOf
|
||||
})
|
||||
}
|
||||
|
||||
generateSyndicationList () {
|
||||
|
|
|
@ -18,6 +18,8 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
|
|||
titlePage: string
|
||||
defaultSort: VideoSortField = '-trending'
|
||||
|
||||
useUserVideoLanguagePreferences = true
|
||||
|
||||
constructor (
|
||||
protected i18n: I18n,
|
||||
protected router: Router,
|
||||
|
@ -59,7 +61,13 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
|
|||
|
||||
getVideosObservable (page: number) {
|
||||
const newPagination = immutableAssign(this.pagination, { currentPage: page })
|
||||
return this.videoService.getVideos(newPagination, this.sort, undefined, this.categoryOneOf)
|
||||
return this.videoService.getVideos({
|
||||
videoPagination: newPagination,
|
||||
sort: this.sort,
|
||||
filter: undefined,
|
||||
categoryOneOf: this.categoryOneOf,
|
||||
languageOneOf: this.languageOneOf
|
||||
})
|
||||
}
|
||||
|
||||
generateSyndicationList () {
|
||||
|
|
|
@ -224,6 +224,20 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
@mixin select-arrow-down {
|
||||
top: 50%;
|
||||
right: calc(0% + 15px);
|
||||
content: " ";
|
||||
height: 0;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 5px solid rgba(0, 0, 0, 0);
|
||||
border-top-color: #000;
|
||||
margin-top: -2px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@mixin peertube-select-container ($width) {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
@ -248,17 +262,7 @@
|
|||
}
|
||||
|
||||
&:after {
|
||||
top: 50%;
|
||||
right: calc(0% + 15px);
|
||||
content: " ";
|
||||
height: 0;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 5px solid rgba(0, 0, 0, 0);
|
||||
border-top-color: #000;
|
||||
margin-top: -2px;
|
||||
z-index: 100;
|
||||
@include select-arrow-down;
|
||||
}
|
||||
|
||||
select {
|
||||
|
|
|
@ -232,6 +232,43 @@ p-table {
|
|||
}
|
||||
}
|
||||
|
||||
// multiselect customizations
|
||||
p-multiselect {
|
||||
.ui-multiselect-label {
|
||||
font-size: 15px !important;
|
||||
padding: 4px 30px 4px 12px !important;
|
||||
|
||||
$width: 338px;
|
||||
width: $width !important;
|
||||
|
||||
@media screen and (max-width: $width) {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.pi.pi-chevron-down{
|
||||
margin-left: 0 !important;
|
||||
|
||||
&::after {
|
||||
@include select-arrow-down;
|
||||
|
||||
right: 0;
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-chkbox-icon {
|
||||
//position: absolute !important;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
//left: 0;
|
||||
|
||||
//&::after {
|
||||
// left: -2px !important;
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
||||
// PrimeNG calendar tweaks
|
||||
p-calendar .ui-datepicker {
|
||||
a {
|
||||
|
|
|
@ -182,6 +182,7 @@ async function updateMe (req: express.Request, res: express.Response) {
|
|||
if (body.webTorrentEnabled !== undefined) user.webTorrentEnabled = body.webTorrentEnabled
|
||||
if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo
|
||||
if (body.videosHistoryEnabled !== undefined) user.videosHistoryEnabled = body.videosHistoryEnabled
|
||||
if (body.videoLanguages !== undefined) user.videoLanguages = body.videoLanguages
|
||||
|
||||
if (body.email !== undefined) {
|
||||
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'express-validator'
|
|||
import * as validator from 'validator'
|
||||
import { UserRole } from '../../../shared'
|
||||
import { CONSTRAINTS_FIELDS, NSFW_POLICY_TYPES } from '../../initializers/constants'
|
||||
import { exists, isBooleanValid, isFileValid } from './misc'
|
||||
import { exists, isArray, isBooleanValid, isFileValid } from './misc'
|
||||
import { values } from 'lodash'
|
||||
|
||||
const USERS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USERS
|
||||
|
@ -54,6 +54,10 @@ function isUserAutoPlayVideoValid (value: any) {
|
|||
return isBooleanValid(value)
|
||||
}
|
||||
|
||||
function isUserVideoLanguages (value: any) {
|
||||
return value === null || (isArray(value) && value.length < CONSTRAINTS_FIELDS.USERS.VIDEO_LANGUAGES.max)
|
||||
}
|
||||
|
||||
function isUserAdminFlagsValid (value: any) {
|
||||
return exists(value) && validator.isInt('' + value)
|
||||
}
|
||||
|
@ -84,6 +88,7 @@ export {
|
|||
isUserVideosHistoryEnabledValid,
|
||||
isUserBlockedValid,
|
||||
isUserPasswordValid,
|
||||
isUserVideoLanguages,
|
||||
isUserBlockedReasonValid,
|
||||
isUserRoleValid,
|
||||
isUserVideoQuotaValid,
|
||||
|
|
|
@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LAST_MIGRATION_VERSION = 390
|
||||
const LAST_MIGRATION_VERSION = 395
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
@ -177,6 +177,7 @@ let CONSTRAINTS_FIELDS = {
|
|||
PASSWORD: { min: 6, max: 255 }, // Length
|
||||
VIDEO_QUOTA: { min: -1 },
|
||||
VIDEO_QUOTA_DAILY: { min: -1 },
|
||||
VIDEO_LANGUAGES: { max: 500 }, // Array length
|
||||
BLOCKED_REASON: { min: 3, max: 250 } // Length
|
||||
},
|
||||
VIDEO_ABUSES: {
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import * as Sequelize from 'sequelize'
|
||||
|
||||
async function up (utils: {
|
||||
transaction: Sequelize.Transaction,
|
||||
queryInterface: Sequelize.QueryInterface,
|
||||
sequelize: Sequelize.Sequelize,
|
||||
db: any
|
||||
}): Promise<void> {
|
||||
const data = {
|
||||
type: Sequelize.ARRAY(Sequelize.STRING),
|
||||
allowNull: true,
|
||||
defaultValue: null
|
||||
}
|
||||
|
||||
await utils.queryInterface.addColumn('user', 'videoLanguages', data)
|
||||
}
|
||||
|
||||
function down (options) {
|
||||
throw new Error('Not implemented.')
|
||||
}
|
||||
|
||||
export {
|
||||
up,
|
||||
down
|
||||
}
|
|
@ -13,7 +13,7 @@ import {
|
|||
isUserNSFWPolicyValid,
|
||||
isUserPasswordValid,
|
||||
isUserRoleValid,
|
||||
isUserUsernameValid,
|
||||
isUserUsernameValid, isUserVideoLanguages,
|
||||
isUserVideoQuotaDailyValid,
|
||||
isUserVideoQuotaValid,
|
||||
isUserVideosHistoryEnabledValid
|
||||
|
@ -198,6 +198,9 @@ const usersUpdateMeValidator = [
|
|||
body('autoPlayVideo')
|
||||
.optional()
|
||||
.custom(isUserAutoPlayVideoValid).withMessage('Should have a valid automatically plays video attribute'),
|
||||
body('videoLanguages')
|
||||
.optional()
|
||||
.custom(isUserVideoLanguages).withMessage('Should have a valid video languages attribute'),
|
||||
body('videosHistoryEnabled')
|
||||
.optional()
|
||||
.custom(isUserVideosHistoryEnabledValid).withMessage('Should have a valid videos history enabled attribute'),
|
||||
|
|
|
@ -31,6 +31,7 @@ import {
|
|||
isUserPasswordValid,
|
||||
isUserRoleValid,
|
||||
isUserUsernameValid,
|
||||
isUserVideoLanguages,
|
||||
isUserVideoQuotaDailyValid,
|
||||
isUserVideoQuotaValid,
|
||||
isUserVideosHistoryEnabledValid,
|
||||
|
@ -147,6 +148,12 @@ export class UserModel extends Model<UserModel> {
|
|||
@Column
|
||||
autoPlayVideo: boolean
|
||||
|
||||
@AllowNull(true)
|
||||
@Default(null)
|
||||
@Is('UserVideoLanguages', value => throwIfNotValid(value, isUserVideoLanguages, 'video languages'))
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
videoLanguages: string[]
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(UserAdminFlag.NONE)
|
||||
@Is('UserAdminFlags', value => throwIfNotValid(value, isUserAdminFlagsValid, 'user admin flags'))
|
||||
|
@ -551,6 +558,7 @@ export class UserModel extends Model<UserModel> {
|
|||
webTorrentEnabled: this.webTorrentEnabled,
|
||||
videosHistoryEnabled: this.videosHistoryEnabled,
|
||||
autoPlayVideo: this.autoPlayVideo,
|
||||
videoLanguages: this.videoLanguages,
|
||||
role: this.role,
|
||||
roleLabel: USER_ROLE_LABELS[ this.role ],
|
||||
videoQuota: this.videoQuota,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Sequelize } from 'sequelize-typescript'
|
||||
import { Model, Sequelize } from 'sequelize-typescript'
|
||||
import * as validator from 'validator'
|
||||
import { OrderItem } from 'sequelize'
|
||||
import { Col } from 'sequelize/types/lib/utils'
|
||||
import { OrderItem } from 'sequelize/types'
|
||||
|
||||
type SortType = { sortModel: any, sortValue: string }
|
||||
|
||||
|
@ -127,6 +127,11 @@ function parseAggregateResult (result: any) {
|
|||
return total
|
||||
}
|
||||
|
||||
const createSafeIn = (model: typeof Model, stringArr: string[]) => {
|
||||
return stringArr.map(t => model.sequelize.escape(t))
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
|
@ -141,7 +146,8 @@ export {
|
|||
buildTrigramSearchIndex,
|
||||
buildWhereIdOrUUID,
|
||||
isOutdated,
|
||||
parseAggregateResult
|
||||
parseAggregateResult,
|
||||
createSafeIn
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -83,6 +83,7 @@ import {
|
|||
buildBlockedAccountSQL,
|
||||
buildTrigramSearchIndex,
|
||||
buildWhereIdOrUUID,
|
||||
createSafeIn,
|
||||
createSimilarityAttribute,
|
||||
getVideoSort,
|
||||
isOutdated,
|
||||
|
@ -227,6 +228,8 @@ type AvailableForListIDsOptions = {
|
|||
trendingDays?: number
|
||||
user?: UserModel,
|
||||
historyOfUser?: UserModel
|
||||
|
||||
baseWhere?: WhereOptions[]
|
||||
}
|
||||
|
||||
@Scopes(() => ({
|
||||
|
@ -270,34 +273,34 @@ type AvailableForListIDsOptions = {
|
|||
return query
|
||||
},
|
||||
[ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
|
||||
const attributes = options.withoutId === true ? [] : [ 'id' ]
|
||||
const whereAnd = options.baseWhere ? options.baseWhere : []
|
||||
|
||||
const query: FindOptions = {
|
||||
raw: true,
|
||||
attributes,
|
||||
where: {
|
||||
id: {
|
||||
[ Op.and ]: [
|
||||
{
|
||||
[ Op.notIn ]: Sequelize.literal(
|
||||
'(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
|
||||
)
|
||||
}
|
||||
]
|
||||
},
|
||||
channelId: {
|
||||
[ Op.notIn ]: Sequelize.literal(
|
||||
'(' +
|
||||
'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
|
||||
buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
|
||||
')' +
|
||||
')'
|
||||
)
|
||||
}
|
||||
},
|
||||
attributes: options.withoutId === true ? [] : [ 'id' ],
|
||||
include: []
|
||||
}
|
||||
|
||||
whereAnd.push({
|
||||
id: {
|
||||
[ Op.notIn ]: Sequelize.literal(
|
||||
'(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
whereAnd.push({
|
||||
channelId: {
|
||||
[ Op.notIn ]: Sequelize.literal(
|
||||
'(' +
|
||||
'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
|
||||
buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
|
||||
')' +
|
||||
')'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Only list public/published videos
|
||||
if (!options.filter || options.filter !== 'all-local') {
|
||||
const privacyWhere = {
|
||||
|
@ -317,7 +320,7 @@ type AvailableForListIDsOptions = {
|
|||
]
|
||||
}
|
||||
|
||||
Object.assign(query.where, privacyWhere)
|
||||
whereAnd.push(privacyWhere)
|
||||
}
|
||||
|
||||
if (options.videoPlaylistId) {
|
||||
|
@ -387,86 +390,114 @@ type AvailableForListIDsOptions = {
|
|||
|
||||
// Force actorId to be a number to avoid SQL injections
|
||||
const actorIdNumber = parseInt(options.followerActorId.toString(), 10)
|
||||
query.where[ 'id' ][ Op.and ].push({
|
||||
[ Op.in ]: Sequelize.literal(
|
||||
'(' +
|
||||
'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
|
||||
'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
|
||||
'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
|
||||
' UNION ALL ' +
|
||||
'SELECT "video"."id" AS "id" FROM "video" ' +
|
||||
'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
|
||||
'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
|
||||
'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
|
||||
'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
|
||||
'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
|
||||
localVideosReq +
|
||||
')'
|
||||
)
|
||||
whereAnd.push({
|
||||
id: {
|
||||
[ Op.in ]: Sequelize.literal(
|
||||
'(' +
|
||||
'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
|
||||
'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
|
||||
'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
|
||||
' UNION ALL ' +
|
||||
'SELECT "video"."id" AS "id" FROM "video" ' +
|
||||
'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
|
||||
'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
|
||||
'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
|
||||
'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
|
||||
'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
|
||||
localVideosReq +
|
||||
')'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (options.withFiles === true) {
|
||||
query.where[ 'id' ][ Op.and ].push({
|
||||
[ Op.in ]: Sequelize.literal(
|
||||
'(SELECT "videoId" FROM "videoFile")'
|
||||
)
|
||||
whereAnd.push({
|
||||
id: {
|
||||
[ Op.in ]: Sequelize.literal(
|
||||
'(SELECT "videoId" FROM "videoFile")'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FIXME: issues with sequelize count when making a join on n:m relation, so we just make a IN()
|
||||
if (options.tagsAllOf || options.tagsOneOf) {
|
||||
const createTagsIn = (tags: string[]) => {
|
||||
return tags.map(t => VideoModel.sequelize.escape(t))
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
if (options.tagsOneOf) {
|
||||
query.where[ 'id' ][ Op.and ].push({
|
||||
[ Op.in ]: Sequelize.literal(
|
||||
'(' +
|
||||
'SELECT "videoId" FROM "videoTag" ' +
|
||||
'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
|
||||
'WHERE "tag"."name" IN (' + createTagsIn(options.tagsOneOf) + ')' +
|
||||
')'
|
||||
)
|
||||
whereAnd.push({
|
||||
id: {
|
||||
[ Op.in ]: Sequelize.literal(
|
||||
'(' +
|
||||
'SELECT "videoId" FROM "videoTag" ' +
|
||||
'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
|
||||
'WHERE "tag"."name" IN (' + createSafeIn(VideoModel, options.tagsOneOf) + ')' +
|
||||
')'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (options.tagsAllOf) {
|
||||
query.where[ 'id' ][ Op.and ].push({
|
||||
[ Op.in ]: Sequelize.literal(
|
||||
'(' +
|
||||
'SELECT "videoId" FROM "videoTag" ' +
|
||||
'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
|
||||
'WHERE "tag"."name" IN (' + createTagsIn(options.tagsAllOf) + ')' +
|
||||
'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + options.tagsAllOf.length +
|
||||
')'
|
||||
)
|
||||
whereAnd.push({
|
||||
id: {
|
||||
[ Op.in ]: Sequelize.literal(
|
||||
'(' +
|
||||
'SELECT "videoId" FROM "videoTag" ' +
|
||||
'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
|
||||
'WHERE "tag"."name" IN (' + createSafeIn(VideoModel, options.tagsAllOf) + ')' +
|
||||
'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + options.tagsAllOf.length +
|
||||
')'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (options.nsfw === true || options.nsfw === false) {
|
||||
query.where[ 'nsfw' ] = options.nsfw
|
||||
whereAnd.push({ nsfw: options.nsfw })
|
||||
}
|
||||
|
||||
if (options.categoryOneOf) {
|
||||
query.where[ 'category' ] = {
|
||||
[ Op.or ]: options.categoryOneOf
|
||||
}
|
||||
whereAnd.push({
|
||||
category: {
|
||||
[ Op.or ]: options.categoryOneOf
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (options.licenceOneOf) {
|
||||
query.where[ 'licence' ] = {
|
||||
[ Op.or ]: options.licenceOneOf
|
||||
}
|
||||
whereAnd.push({
|
||||
licence: {
|
||||
[ Op.or ]: options.licenceOneOf
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (options.languageOneOf) {
|
||||
query.where[ 'language' ] = {
|
||||
[ Op.or ]: options.languageOneOf
|
||||
let videoLanguages = options.languageOneOf
|
||||
if (options.languageOneOf.find(l => l === '_unknown')) {
|
||||
videoLanguages = videoLanguages.concat([ null ])
|
||||
}
|
||||
|
||||
whereAnd.push({
|
||||
[Op.or]: [
|
||||
{
|
||||
language: {
|
||||
[ Op.or ]: videoLanguages
|
||||
}
|
||||
},
|
||||
{
|
||||
id: {
|
||||
[ Op.in ]: Sequelize.literal(
|
||||
'(' +
|
||||
'SELECT "videoId" FROM "videoCaption" ' +
|
||||
'WHERE "language" IN (' + createSafeIn(VideoModel, options.languageOneOf) + ') ' +
|
||||
')'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (options.trendingDays) {
|
||||
|
@ -490,6 +521,10 @@ type AvailableForListIDsOptions = {
|
|||
query.subQuery = false
|
||||
}
|
||||
|
||||
query.where = {
|
||||
[ Op.and ]: whereAnd
|
||||
}
|
||||
|
||||
return query
|
||||
},
|
||||
[ ScopeNames.WITH_THUMBNAILS ]: {
|
||||
|
@ -1175,7 +1210,7 @@ export class VideoModel extends Model<VideoModel> {
|
|||
throw new Error('Try to filter all-local but no user has not the see all videos right')
|
||||
}
|
||||
|
||||
const query: FindOptions = {
|
||||
const query: FindOptions & { where?: null } = {
|
||||
offset: options.start,
|
||||
limit: options.count,
|
||||
order: getVideoSort(options.sort)
|
||||
|
@ -1299,16 +1334,13 @@ export class VideoModel extends Model<VideoModel> {
|
|||
)
|
||||
}
|
||||
|
||||
const query: FindOptions = {
|
||||
const query = {
|
||||
attributes: {
|
||||
include: attributesInclude
|
||||
},
|
||||
offset: options.start,
|
||||
limit: options.count,
|
||||
order: getVideoSort(options.sort),
|
||||
where: {
|
||||
[ Op.and ]: whereAnd
|
||||
}
|
||||
order: getVideoSort(options.sort)
|
||||
}
|
||||
|
||||
const serverActor = await getServerActor()
|
||||
|
@ -1323,7 +1355,8 @@ export class VideoModel extends Model<VideoModel> {
|
|||
tagsOneOf: options.tagsOneOf,
|
||||
tagsAllOf: options.tagsAllOf,
|
||||
user: options.user,
|
||||
filter: options.filter
|
||||
filter: options.filter,
|
||||
baseWhere: whereAnd
|
||||
}
|
||||
|
||||
return VideoModel.getAvailableForApi(query, queryOptions)
|
||||
|
@ -1590,7 +1623,7 @@ export class VideoModel extends Model<VideoModel> {
|
|||
}
|
||||
|
||||
private static async getAvailableForApi (
|
||||
query: FindOptions,
|
||||
query: FindOptions & { where?: null }, // Forbid where field in query
|
||||
options: AvailableForListIDsOptions,
|
||||
countVideos = true
|
||||
) {
|
||||
|
@ -1609,11 +1642,15 @@ export class VideoModel extends Model<VideoModel> {
|
|||
]
|
||||
}
|
||||
|
||||
const [ count, rowsId ] = await Promise.all([
|
||||
countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve<number>(undefined),
|
||||
VideoModel.scope(idsScope).findAll(query)
|
||||
const [ count, ids ] = await Promise.all([
|
||||
countVideos
|
||||
? VideoModel.scope(countScope).count(countQuery)
|
||||
: Promise.resolve<number>(undefined),
|
||||
|
||||
VideoModel.scope(idsScope)
|
||||
.findAll(query)
|
||||
.then(rows => rows.map(r => r.id))
|
||||
])
|
||||
const ids = rowsId.map(r => r.id)
|
||||
|
||||
if (ids.length === 0) return { data: [], total: count }
|
||||
|
||||
|
|
|
@ -364,6 +364,29 @@ describe('Test users API validators', function () {
|
|||
await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
|
||||
})
|
||||
|
||||
it('Should fail with an invalid videoLanguages attribute', async function () {
|
||||
{
|
||||
const fields = {
|
||||
videoLanguages: 'toto'
|
||||
}
|
||||
|
||||
await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
|
||||
}
|
||||
|
||||
{
|
||||
const languages = []
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
languages.push('fr')
|
||||
}
|
||||
|
||||
const fields = {
|
||||
videoLanguages: languages
|
||||
}
|
||||
|
||||
await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
|
||||
}
|
||||
})
|
||||
|
||||
it('Should succeed to change password with the correct params', async function () {
|
||||
const fields = {
|
||||
currentPassword: 'my super password',
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
uploadVideo,
|
||||
wait
|
||||
} from '../../../../shared/extra-utils'
|
||||
import { createVideoCaption } from '../../../../shared/extra-utils/videos/video-captions'
|
||||
|
||||
const expect = chai.expect
|
||||
|
||||
|
@ -41,8 +42,29 @@ describe('Test videos search', function () {
|
|||
const attributes2 = immutableAssign(attributes1, { name: attributes1.name + ' - 2', fixture: 'video_short.mp4' })
|
||||
await uploadVideo(server.url, server.accessToken, attributes2)
|
||||
|
||||
const attributes3 = immutableAssign(attributes1, { name: attributes1.name + ' - 3', language: 'en' })
|
||||
await uploadVideo(server.url, server.accessToken, attributes3)
|
||||
{
|
||||
const attributes3 = immutableAssign(attributes1, { name: attributes1.name + ' - 3', language: undefined })
|
||||
const res = await uploadVideo(server.url, server.accessToken, attributes3)
|
||||
const videoId = res.body.video.id
|
||||
|
||||
await createVideoCaption({
|
||||
url: server.url,
|
||||
accessToken: server.accessToken,
|
||||
language: 'en',
|
||||
videoId,
|
||||
fixture: 'subtitle-good2.vtt',
|
||||
mimeType: 'application/octet-stream'
|
||||
})
|
||||
|
||||
await createVideoCaption({
|
||||
url: server.url,
|
||||
accessToken: server.accessToken,
|
||||
language: 'aa',
|
||||
videoId,
|
||||
fixture: 'subtitle-good2.vtt',
|
||||
mimeType: 'application/octet-stream'
|
||||
})
|
||||
}
|
||||
|
||||
const attributes4 = immutableAssign(attributes1, { name: attributes1.name + ' - 4', language: 'pl', nsfw: true })
|
||||
await uploadVideo(server.url, server.accessToken, attributes4)
|
||||
|
@ -51,7 +73,7 @@ describe('Test videos search', function () {
|
|||
|
||||
startDate = new Date().toISOString()
|
||||
|
||||
const attributes5 = immutableAssign(attributes1, { name: attributes1.name + ' - 5', licence: 2 })
|
||||
const attributes5 = immutableAssign(attributes1, { name: attributes1.name + ' - 5', licence: 2, language: undefined })
|
||||
await uploadVideo(server.url, server.accessToken, attributes5)
|
||||
|
||||
const attributes6 = immutableAssign(attributes1, { name: attributes1.name + ' - 6', tags: [ 't1', 't2 '] })
|
||||
|
@ -241,13 +263,26 @@ describe('Test videos search', function () {
|
|||
search: '1111 2222 3333',
|
||||
languageOneOf: [ 'pl', 'en' ]
|
||||
}
|
||||
const res1 = await advancedVideosSearch(server.url, query)
|
||||
expect(res1.body.total).to.equal(2)
|
||||
expect(res1.body.data[0].name).to.equal('1111 2222 3333 - 3')
|
||||
expect(res1.body.data[1].name).to.equal('1111 2222 3333 - 4')
|
||||
|
||||
const res2 = await advancedVideosSearch(server.url, immutableAssign(query, { languageOneOf: [ 'eo' ] }))
|
||||
expect(res2.body.total).to.equal(0)
|
||||
{
|
||||
const res = await advancedVideosSearch(server.url, query)
|
||||
expect(res.body.total).to.equal(2)
|
||||
expect(res.body.data[ 0 ].name).to.equal('1111 2222 3333 - 3')
|
||||
expect(res.body.data[ 1 ].name).to.equal('1111 2222 3333 - 4')
|
||||
}
|
||||
|
||||
{
|
||||
const res = await advancedVideosSearch(server.url, immutableAssign(query, { languageOneOf: [ 'pl', 'en', '_unknown' ] }))
|
||||
expect(res.body.total).to.equal(3)
|
||||
expect(res.body.data[ 0 ].name).to.equal('1111 2222 3333 - 3')
|
||||
expect(res.body.data[ 1 ].name).to.equal('1111 2222 3333 - 4')
|
||||
expect(res.body.data[ 2 ].name).to.equal('1111 2222 3333 - 5')
|
||||
}
|
||||
|
||||
{
|
||||
const res = await advancedVideosSearch(server.url, immutableAssign(query, { languageOneOf: [ 'eo' ] }))
|
||||
expect(res.body.total).to.equal(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('Should search by start date', async function () {
|
||||
|
|
|
@ -8,6 +8,7 @@ export interface UserUpdateMe {
|
|||
webTorrentEnabled?: boolean
|
||||
autoPlayVideo?: boolean
|
||||
videosHistoryEnabled?: boolean
|
||||
videoLanguages?: string[]
|
||||
|
||||
email?: string
|
||||
currentPassword?: string
|
||||
|
|
Loading…
Reference in New Issue