aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2019-06-19 14:55:58 +0200
committerChocobozzz <me@florianbigard.com>2019-06-19 15:05:36 +0200
commit3caf77d3b11f2dbc12e52d665183d36604c1dab9 (patch)
tree53e08727d5f1dc8be2bd4f4a14dadc05f607a9fb
parentbbe078ba55be635b5fc92f8f6286c45792b9e7e5 (diff)
downloadPeerTube-3caf77d3b11f2dbc12e52d665183d36604c1dab9.tar.gz
PeerTube-3caf77d3b11f2dbc12e52d665183d36604c1dab9.tar.zst
PeerTube-3caf77d3b11f2dbc12e52d665183d36604c1dab9.zip
Add language filters in user preferences
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-settings.component.html6
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html15
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts64
-rw-r--r--client/src/app/+my-account/my-account.module.ts16
-rw-r--r--client/src/app/shared/users/user.model.ts1
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts27
-rw-r--r--client/src/app/shared/video/video.service.ts22
-rw-r--r--client/src/app/videos/recommendations/recent-videos-recommendation.service.ts2
-rw-r--r--client/src/app/videos/video-list/video-local.component.ts10
-rw-r--r--client/src/app/videos/video-list/video-recently-added.component.ts10
-rw-r--r--client/src/app/videos/video-list/video-trending.component.ts10
-rw-r--r--client/src/sass/include/_mixins.scss26
-rw-r--r--client/src/sass/primeng-custom.scss37
-rw-r--r--server/controllers/api/users/me.ts1
-rw-r--r--server/helpers/custom-validators/users.ts7
-rw-r--r--server/initializers/constants.ts3
-rw-r--r--server/initializers/migrations/0395-user-video-languages.ts25
-rw-r--r--server/middlewares/validators/users.ts5
-rw-r--r--server/models/account/user.ts8
-rw-r--r--server/models/utils.ts12
-rw-r--r--server/models/video/video.ts209
-rw-r--r--server/tests/api/check-params/users.ts23
-rw-r--r--server/tests/api/search/search-videos.ts53
-rw-r--r--shared/models/users/user-update-me.model.ts1
24 files changed, 443 insertions, 150 deletions
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
index f93d41110..e51302f7c 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
@@ -7,6 +7,9 @@
7<div i18n class="account-title">Profile</div> 7<div i18n class="account-title">Profile</div>
8<my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile> 8<my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile>
9 9
10<div i18n class="account-title">Video settings</div>
11<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
12
10<div i18n class="account-title" id="notifications">Notifications</div> 13<div i18n class="account-title" id="notifications">Notifications</div>
11<my-account-notification-preferences [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-notification-preferences> 14<my-account-notification-preferences [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-notification-preferences>
12 15
@@ -16,8 +19,5 @@
16<div i18n class="account-title">Email</div> 19<div i18n class="account-title">Email</div>
17<my-account-change-email></my-account-change-email> 20<my-account-change-email></my-account-change-email>
18 21
19<div i18n class="account-title">Video settings</div>
20<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
21
22<div i18n class="account-title">Danger zone</div> 22<div i18n class="account-title">Danger zone</div>
23<my-account-danger-zone [user]="user"></my-account-danger-zone> 23<my-account-danger-zone [user]="user"></my-account-danger-zone>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html
index 049119fa8..2796dd2db 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html
@@ -16,6 +16,21 @@
16 </div> 16 </div>
17 17
18 <div class="form-group"> 18 <div class="form-group">
19 <label i18n for="videoLanguages">Only display videos in the following languages</label>
20 <my-help i18n-customHtml
21 customHtml="In Recently added, Trending, Local and Search pages"
22 ></my-help>
23
24 <div>
25 <p-multiSelect
26 [options]="languageItems" formControlName="videoLanguages" showToggleAll="true"
27 [defaultLabel]="getDefaultVideoLanguageLabel()" [selectedItemsLabel]="getSelectedVideoLanguageLabel()"
28 emptyFilterMessage="No results found" i18n-emptyFilterMessage
29 ></p-multiSelect>
30 </div>
31 </div>
32
33 <div class="form-group">
19 <my-peertube-checkbox 34 <my-peertube-checkbox
20 inputName="webTorrentEnabled" formControlName="webTorrentEnabled" 35 inputName="webTorrentEnabled" formControlName="webTorrentEnabled"
21 i18n-labelText labelText="Use WebTorrent to exchange parts of the video with others" 36 i18n-labelText labelText="Use WebTorrent to exchange parts of the video with others"
diff --git a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts
index b8f80bc1a..77febf179 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts
@@ -1,11 +1,13 @@
1import { Component, Input, OnInit } from '@angular/core' 1import { Component, Input, OnInit } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier, ServerService } from '@app/core'
3import { UserUpdateMe } from '../../../../../../shared' 3import { UserUpdateMe } from '../../../../../../shared'
4import { AuthService } from '../../../core' 4import { AuthService } from '../../../core'
5import { FormReactive, User, UserService } from '../../../shared' 5import { FormReactive, User, UserService } from '../../../shared'
6import { I18n } from '@ngx-translate/i18n-polyfill' 6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 7import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
8import { Subject } from 'rxjs' 8import { Subject } from 'rxjs'
9import { SelectItem } from 'primeng/api'
10import { switchMap } from 'rxjs/operators'
9 11
10@Component({ 12@Component({
11 selector: 'my-account-video-settings', 13 selector: 'my-account-video-settings',
@@ -16,11 +18,14 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
16 @Input() user: User = null 18 @Input() user: User = null
17 @Input() userInformationLoaded: Subject<any> 19 @Input() userInformationLoaded: Subject<any>
18 20
21 languageItems: SelectItem[] = []
22
19 constructor ( 23 constructor (
20 protected formValidatorService: FormValidatorService, 24 protected formValidatorService: FormValidatorService,
21 private authService: AuthService, 25 private authService: AuthService,
22 private notifier: Notifier, 26 private notifier: Notifier,
23 private userService: UserService, 27 private userService: UserService,
28 private serverService: ServerService,
24 private i18n: I18n 29 private i18n: I18n
25 ) { 30 ) {
26 super() 31 super()
@@ -30,31 +35,60 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
30 this.buildForm({ 35 this.buildForm({
31 nsfwPolicy: null, 36 nsfwPolicy: null,
32 webTorrentEnabled: null, 37 webTorrentEnabled: null,
33 autoPlayVideo: null 38 autoPlayVideo: null,
39 videoLanguages: null
34 }) 40 })
35 41
36 this.userInformationLoaded.subscribe(() => { 42 this.serverService.videoLanguagesLoaded
37 this.form.patchValue({ 43 .pipe(switchMap(() => this.userInformationLoaded))
38 nsfwPolicy: this.user.nsfwPolicy, 44 .subscribe(() => {
39 webTorrentEnabled: this.user.webTorrentEnabled, 45 const languages = this.serverService.getVideoLanguages()
40 autoPlayVideo: this.user.autoPlayVideo === true 46
41 }) 47 this.languageItems = [ { label: this.i18n('Unknown language'), value: '_unknown' } ]
42 }) 48 this.languageItems = this.languageItems
49 .concat(languages.map(l => ({ label: l.label, value: l.id })))
50
51 const videoLanguages = this.user.videoLanguages
52 ? this.user.videoLanguages
53 : this.languageItems.map(l => l.value)
54
55 this.form.patchValue({
56 nsfwPolicy: this.user.nsfwPolicy,
57 webTorrentEnabled: this.user.webTorrentEnabled,
58 autoPlayVideo: this.user.autoPlayVideo === true,
59 videoLanguages
60 })
61 })
43 } 62 }
44 63
45 updateDetails () { 64 updateDetails () {
46 const nsfwPolicy = this.form.value['nsfwPolicy'] 65 const nsfwPolicy = this.form.value['nsfwPolicy']
47 const webTorrentEnabled = this.form.value['webTorrentEnabled'] 66 const webTorrentEnabled = this.form.value['webTorrentEnabled']
48 const autoPlayVideo = this.form.value['autoPlayVideo'] 67 const autoPlayVideo = this.form.value['autoPlayVideo']
68
69 let videoLanguages: string[] = this.form.value['videoLanguages']
70 if (Array.isArray(videoLanguages)) {
71 if (videoLanguages.length === this.languageItems.length) {
72 videoLanguages = null // null means "All"
73 } else if (videoLanguages.length > 20) {
74 this.notifier.error('Too many languages are enabled. Please enable them all or stay below 20 enabled languages.')
75 return
76 } else if (videoLanguages.length === 0) {
77 this.notifier.error('You need to enabled at least 1 video language.')
78 return
79 }
80 }
81
49 const details: UserUpdateMe = { 82 const details: UserUpdateMe = {
50 nsfwPolicy, 83 nsfwPolicy,
51 webTorrentEnabled, 84 webTorrentEnabled,
52 autoPlayVideo 85 autoPlayVideo,
86 videoLanguages
53 } 87 }
54 88
55 this.userService.updateMyProfile(details).subscribe( 89 this.userService.updateMyProfile(details).subscribe(
56 () => { 90 () => {
57 this.notifier.success(this.i18n('Information updated.')) 91 this.notifier.success(this.i18n('Video settings updated.'))
58 92
59 this.authService.refreshUserInformation() 93 this.authService.refreshUserInformation()
60 }, 94 },
@@ -62,4 +96,12 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
62 err => this.notifier.error(err.message) 96 err => this.notifier.error(err.message)
63 ) 97 )
64 } 98 }
99
100 getDefaultVideoLanguageLabel () {
101 return this.i18n('No language')
102 }
103
104 getSelectedVideoLanguageLabel () {
105 return this.i18n('{{\'{0} languages selected')
106 }
65} 107}
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index ca5b1f7cb..aeda637c2 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -25,18 +25,13 @@ import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-b
25import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' 25import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
26import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component' 26import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
27import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences' 27import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences'
28import { 28import { MyAccountVideoPlaylistCreateComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
29 MyAccountVideoPlaylistCreateComponent 29import { MyAccountVideoPlaylistUpdateComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
30} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
31import {
32 MyAccountVideoPlaylistUpdateComponent
33} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
34import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component' 30import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
35import { 31import { MyAccountVideoPlaylistElementsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
36 MyAccountVideoPlaylistElementsComponent
37} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
38import { DragDropModule } from '@angular/cdk/drag-drop' 32import { DragDropModule } from '@angular/cdk/drag-drop'
39import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email' 33import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email'
34import { MultiSelectModule } from 'primeng/primeng'
40 35
41@NgModule({ 36@NgModule({
42 imports: [ 37 imports: [
@@ -46,7 +41,8 @@ import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-setti
46 SharedModule, 41 SharedModule,
47 TableModule, 42 TableModule,
48 InputSwitchModule, 43 InputSwitchModule,
49 DragDropModule 44 DragDropModule,
45 MultiSelectModule
50 ], 46 ],
51 47
52 declarations: [ 48 declarations: [
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts
index 14d13959a..95a6ce9f9 100644
--- a/client/src/app/shared/users/user.model.ts
+++ b/client/src/app/shared/users/user.model.ts
@@ -18,6 +18,7 @@ export class User implements UserServerModel {
18 webTorrentEnabled: boolean 18 webTorrentEnabled: boolean
19 autoPlayVideo: boolean 19 autoPlayVideo: boolean
20 videosHistoryEnabled: boolean 20 videosHistoryEnabled: boolean
21 videoLanguages: string[]
21 22
22 videoQuota: number 23 videoQuota: number
23 videoQuotaDaily: number 24 videoQuotaDaily: number
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
index dc8f9cda9..cf4b5ef8e 100644
--- a/client/src/app/shared/video/abstract-video-list.ts
+++ b/client/src/app/shared/video/abstract-video-list.ts
@@ -1,7 +1,7 @@
1import { debounceTime } from 'rxjs/operators' 1import { debounceTime, first, tap } from 'rxjs/operators'
2import { OnDestroy, OnInit } from '@angular/core' 2import { OnDestroy, OnInit } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { fromEvent, Observable, Subscription } from 'rxjs' 4import { fromEvent, Observable, of, Subscription } from 'rxjs'
5import { AuthService } from '../../core/auth' 5import { AuthService } from '../../core/auth'
6import { ComponentPagination } from '../rest/component-pagination.model' 6import { ComponentPagination } from '../rest/component-pagination.model'
7import { VideoSortField } from './sort-field.type' 7import { VideoSortField } from './sort-field.type'
@@ -32,18 +32,20 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
32 sort: VideoSortField = '-publishedAt' 32 sort: VideoSortField = '-publishedAt'
33 33
34 categoryOneOf?: number 34 categoryOneOf?: number
35 languageOneOf?: string[]
35 defaultSort: VideoSortField = '-publishedAt' 36 defaultSort: VideoSortField = '-publishedAt'
36 37
37 syndicationItems: Syndication[] = [] 38 syndicationItems: Syndication[] = []
38 39
39 loadOnInit = true 40 loadOnInit = true
40 videos: Video[] = [] 41 useUserVideoLanguagePreferences = false
41 ownerDisplayType: OwnerDisplayType = 'account' 42 ownerDisplayType: OwnerDisplayType = 'account'
42 displayModerationBlock = false 43 displayModerationBlock = false
43 titleTooltip: string 44 titleTooltip: string
44 displayVideoActions = true 45 displayVideoActions = true
45 groupByDate = false 46 groupByDate = false
46 47
48 videos: Video[] = []
47 disabled = false 49 disabled = false
48 50
49 displayOptions: MiniatureDisplayOptions = { 51 displayOptions: MiniatureDisplayOptions = {
@@ -98,7 +100,12 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
98 .subscribe(() => this.calcPageSizes()) 100 .subscribe(() => this.calcPageSizes())
99 101
100 this.calcPageSizes() 102 this.calcPageSizes()
101 if (this.loadOnInit === true) this.loadMoreVideos() 103
104 const loadUserObservable = this.loadUserVideoLanguagesIfNeeded()
105
106 if (this.loadOnInit === true) {
107 loadUserObservable.subscribe(() => this.loadMoreVideos())
108 }
102 } 109 }
103 110
104 ngOnDestroy () { 111 ngOnDestroy () {
@@ -245,4 +252,16 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
245 252
246 this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' }) 253 this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' })
247 } 254 }
255
256 private loadUserVideoLanguagesIfNeeded () {
257 if (!this.authService.isLoggedIn() || !this.useUserVideoLanguagePreferences) {
258 return of(true)
259 }
260
261 return this.authService.userInformationLoaded
262 .pipe(
263 first(),
264 tap(() => this.languageOneOf = this.user.videoLanguages)
265 )
266 }
248} 267}
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index ef489648c..871bc9e46 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -35,12 +35,13 @@ import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
35import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' 35import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
36 36
37export interface VideosProvider { 37export interface VideosProvider {
38 getVideos ( 38 getVideos (parameters: {
39 videoPagination: ComponentPagination, 39 videoPagination: ComponentPagination,
40 sort: VideoSortField, 40 sort: VideoSortField,
41 filter?: VideoFilter, 41 filter?: VideoFilter,
42 categoryOneOf?: number 42 categoryOneOf?: number,
43 ): Observable<{ videos: Video[], totalVideos: number }> 43 languageOneOf?: string[]
44 }): Observable<{ videos: Video[], totalVideos: number }>
44} 45}
45 46
46@Injectable() 47@Injectable()
@@ -206,12 +207,15 @@ export class VideoService implements VideosProvider {
206 ) 207 )
207 } 208 }
208 209
209 getVideos ( 210 getVideos (parameters: {
210 videoPagination: ComponentPagination, 211 videoPagination: ComponentPagination,
211 sort: VideoSortField, 212 sort: VideoSortField,
212 filter?: VideoFilter, 213 filter?: VideoFilter,
213 categoryOneOf?: number 214 categoryOneOf?: number,
214 ): Observable<{ videos: Video[], totalVideos: number }> { 215 languageOneOf?: string[]
216 }): Observable<{ videos: Video[], totalVideos: number }> {
217 const { videoPagination, sort, filter, categoryOneOf, languageOneOf } = parameters
218
215 const pagination = this.restService.componentPaginationToRestPagination(videoPagination) 219 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
216 220
217 let params = new HttpParams() 221 let params = new HttpParams()
@@ -225,6 +229,12 @@ export class VideoService implements VideosProvider {
225 params = params.set('categoryOneOf', categoryOneOf + '') 229 params = params.set('categoryOneOf', categoryOneOf + '')
226 } 230 }
227 231
232 if (languageOneOf) {
233 for (const l of languageOneOf) {
234 params = params.append('languageOneOf[]', l)
235 }
236 }
237
228 return this.authHttp 238 return this.authHttp
229 .get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params }) 239 .get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params })
230 .pipe( 240 .pipe(
diff --git a/client/src/app/videos/recommendations/recent-videos-recommendation.service.ts b/client/src/app/videos/recommendations/recent-videos-recommendation.service.ts
index 6d7b159da..f975ff6ef 100644
--- a/client/src/app/videos/recommendations/recent-videos-recommendation.service.ts
+++ b/client/src/app/videos/recommendations/recent-videos-recommendation.service.ts
@@ -32,7 +32,7 @@ export class RecentVideosRecommendationService implements RecommendationService
32 32
33 private fetchPage (page: number, recommendation: RecommendationInfo): Observable<Video[]> { 33 private fetchPage (page: number, recommendation: RecommendationInfo): Observable<Video[]> {
34 const pagination = { currentPage: page, itemsPerPage: this.pageSize + 1 } 34 const pagination = { currentPage: page, itemsPerPage: this.pageSize + 1 }
35 const defaultSubscription = this.videos.getVideos(pagination, '-createdAt') 35 const defaultSubscription = this.videos.getVideos({ videoPagination: pagination, sort: '-createdAt' })
36 .pipe(map(v => v.videos)) 36 .pipe(map(v => v.videos))
37 37
38 if (!recommendation.tags || recommendation.tags.length === 0) return defaultSubscription 38 if (!recommendation.tags || recommendation.tags.length === 0) return defaultSubscription
diff --git a/client/src/app/videos/video-list/video-local.component.ts b/client/src/app/videos/video-list/video-local.component.ts
index 65543343c..5de4a13af 100644
--- a/client/src/app/videos/video-list/video-local.component.ts
+++ b/client/src/app/videos/video-list/video-local.component.ts
@@ -21,6 +21,8 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
21 sort = '-publishedAt' as VideoSortField 21 sort = '-publishedAt' as VideoSortField
22 filter: VideoFilter = 'local' 22 filter: VideoFilter = 'local'
23 23
24 useUserVideoLanguagePreferences = true
25
24 constructor ( 26 constructor (
25 protected i18n: I18n, 27 protected i18n: I18n,
26 protected router: Router, 28 protected router: Router,
@@ -54,7 +56,13 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
54 getVideosObservable (page: number) { 56 getVideosObservable (page: number) {
55 const newPagination = immutableAssign(this.pagination, { currentPage: page }) 57 const newPagination = immutableAssign(this.pagination, { currentPage: page })
56 58
57 return this.videoService.getVideos(newPagination, this.sort, this.filter, this.categoryOneOf) 59 return this.videoService.getVideos({
60 videoPagination: newPagination,
61 sort: this.sort,
62 filter: this.filter,
63 categoryOneOf: this.categoryOneOf,
64 languageOneOf: this.languageOneOf
65 })
58 } 66 }
59 67
60 generateSyndicationList () { 68 generateSyndicationList () {
diff --git a/client/src/app/videos/video-list/video-recently-added.component.ts b/client/src/app/videos/video-list/video-recently-added.component.ts
index f54bade98..19522e6b4 100644
--- a/client/src/app/videos/video-list/video-recently-added.component.ts
+++ b/client/src/app/videos/video-list/video-recently-added.component.ts
@@ -19,6 +19,8 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On
19 sort: VideoSortField = '-publishedAt' 19 sort: VideoSortField = '-publishedAt'
20 groupByDate = true 20 groupByDate = true
21 21
22 useUserVideoLanguagePreferences = true
23
22 constructor ( 24 constructor (
23 protected i18n: I18n, 25 protected i18n: I18n,
24 protected route: ActivatedRoute, 26 protected route: ActivatedRoute,
@@ -47,7 +49,13 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On
47 getVideosObservable (page: number) { 49 getVideosObservable (page: number) {
48 const newPagination = immutableAssign(this.pagination, { currentPage: page }) 50 const newPagination = immutableAssign(this.pagination, { currentPage: page })
49 51
50 return this.videoService.getVideos(newPagination, this.sort, undefined, this.categoryOneOf) 52 return this.videoService.getVideos({
53 videoPagination: newPagination,
54 sort: this.sort,
55 filter: undefined,
56 categoryOneOf: this.categoryOneOf,
57 languageOneOf: this.languageOneOf
58 })
51 } 59 }
52 60
53 generateSyndicationList () { 61 generateSyndicationList () {
diff --git a/client/src/app/videos/video-list/video-trending.component.ts b/client/src/app/videos/video-list/video-trending.component.ts
index a2c819ebe..5f1d5055b 100644
--- a/client/src/app/videos/video-list/video-trending.component.ts
+++ b/client/src/app/videos/video-list/video-trending.component.ts
@@ -18,6 +18,8 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
18 titlePage: string 18 titlePage: string
19 defaultSort: VideoSortField = '-trending' 19 defaultSort: VideoSortField = '-trending'
20 20
21 useUserVideoLanguagePreferences = true
22
21 constructor ( 23 constructor (
22 protected i18n: I18n, 24 protected i18n: I18n,
23 protected router: Router, 25 protected router: Router,
@@ -59,7 +61,13 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
59 61
60 getVideosObservable (page: number) { 62 getVideosObservable (page: number) {
61 const newPagination = immutableAssign(this.pagination, { currentPage: page }) 63 const newPagination = immutableAssign(this.pagination, { currentPage: page })
62 return this.videoService.getVideos(newPagination, this.sort, undefined, this.categoryOneOf) 64 return this.videoService.getVideos({
65 videoPagination: newPagination,
66 sort: this.sort,
67 filter: undefined,
68 categoryOneOf: this.categoryOneOf,
69 languageOneOf: this.languageOneOf
70 })
63 } 71 }
64 72
65 generateSyndicationList () { 73 generateSyndicationList () {
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index f608e9299..caa79bf04 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -224,6 +224,20 @@
224 cursor: pointer; 224 cursor: pointer;
225} 225}
226 226
227@mixin select-arrow-down {
228 top: 50%;
229 right: calc(0% + 15px);
230 content: " ";
231 height: 0;
232 width: 0;
233 position: absolute;
234 pointer-events: none;
235 border: 5px solid rgba(0, 0, 0, 0);
236 border-top-color: #000;
237 margin-top: -2px;
238 z-index: 100;
239}
240
227@mixin peertube-select-container ($width) { 241@mixin peertube-select-container ($width) {
228 padding: 0; 242 padding: 0;
229 margin: 0; 243 margin: 0;
@@ -248,17 +262,7 @@
248 } 262 }
249 263
250 &:after { 264 &:after {
251 top: 50%; 265 @include select-arrow-down;
252 right: calc(0% + 15px);
253 content: " ";
254 height: 0;
255 width: 0;
256 position: absolute;
257 pointer-events: none;
258 border: 5px solid rgba(0, 0, 0, 0);
259 border-top-color: #000;
260 margin-top: -2px;
261 z-index: 100;
262 } 266 }
263 267
264 select { 268 select {
diff --git a/client/src/sass/primeng-custom.scss b/client/src/sass/primeng-custom.scss
index 957b99356..6c3100746 100644
--- a/client/src/sass/primeng-custom.scss
+++ b/client/src/sass/primeng-custom.scss
@@ -232,6 +232,43 @@ p-table {
232 } 232 }
233} 233}
234 234
235// multiselect customizations
236p-multiselect {
237 .ui-multiselect-label {
238 font-size: 15px !important;
239 padding: 4px 30px 4px 12px !important;
240
241 $width: 338px;
242 width: $width !important;
243
244 @media screen and (max-width: $width) {
245 width: 100% !important;
246 }
247 }
248
249 .pi.pi-chevron-down{
250 margin-left: 0 !important;
251
252 &::after {
253 @include select-arrow-down;
254
255 right: 0;
256 margin-top: 6px;
257 }
258 }
259
260 .ui-chkbox-icon {
261 //position: absolute !important;
262 width: 18px;
263 height: 18px;
264 //left: 0;
265
266 //&::after {
267 // left: -2px !important;
268 //}
269 }
270}
271
235// PrimeNG calendar tweaks 272// PrimeNG calendar tweaks
236p-calendar .ui-datepicker { 273p-calendar .ui-datepicker {
237 a { 274 a {
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
index 1750a02e9..a078334fe 100644
--- a/server/controllers/api/users/me.ts
+++ b/server/controllers/api/users/me.ts
@@ -182,6 +182,7 @@ async function updateMe (req: express.Request, res: express.Response) {
182 if (body.webTorrentEnabled !== undefined) user.webTorrentEnabled = body.webTorrentEnabled 182 if (body.webTorrentEnabled !== undefined) user.webTorrentEnabled = body.webTorrentEnabled
183 if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo 183 if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo
184 if (body.videosHistoryEnabled !== undefined) user.videosHistoryEnabled = body.videosHistoryEnabled 184 if (body.videosHistoryEnabled !== undefined) user.videosHistoryEnabled = body.videosHistoryEnabled
185 if (body.videoLanguages !== undefined) user.videoLanguages = body.videoLanguages
185 186
186 if (body.email !== undefined) { 187 if (body.email !== undefined) {
187 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) { 188 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
diff --git a/server/helpers/custom-validators/users.ts b/server/helpers/custom-validators/users.ts
index 56bc10b16..738d5cbbf 100644
--- a/server/helpers/custom-validators/users.ts
+++ b/server/helpers/custom-validators/users.ts
@@ -2,7 +2,7 @@ import 'express-validator'
2import * as validator from 'validator' 2import * as validator from 'validator'
3import { UserRole } from '../../../shared' 3import { UserRole } from '../../../shared'
4import { CONSTRAINTS_FIELDS, NSFW_POLICY_TYPES } from '../../initializers/constants' 4import { CONSTRAINTS_FIELDS, NSFW_POLICY_TYPES } from '../../initializers/constants'
5import { exists, isBooleanValid, isFileValid } from './misc' 5import { exists, isArray, isBooleanValid, isFileValid } from './misc'
6import { values } from 'lodash' 6import { values } from 'lodash'
7 7
8const USERS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USERS 8const USERS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USERS
@@ -54,6 +54,10 @@ function isUserAutoPlayVideoValid (value: any) {
54 return isBooleanValid(value) 54 return isBooleanValid(value)
55} 55}
56 56
57function isUserVideoLanguages (value: any) {
58 return value === null || (isArray(value) && value.length < CONSTRAINTS_FIELDS.USERS.VIDEO_LANGUAGES.max)
59}
60
57function isUserAdminFlagsValid (value: any) { 61function isUserAdminFlagsValid (value: any) {
58 return exists(value) && validator.isInt('' + value) 62 return exists(value) && validator.isInt('' + value)
59} 63}
@@ -84,6 +88,7 @@ export {
84 isUserVideosHistoryEnabledValid, 88 isUserVideosHistoryEnabledValid,
85 isUserBlockedValid, 89 isUserBlockedValid,
86 isUserPasswordValid, 90 isUserPasswordValid,
91 isUserVideoLanguages,
87 isUserBlockedReasonValid, 92 isUserBlockedReasonValid,
88 isUserRoleValid, 93 isUserRoleValid,
89 isUserVideoQuotaValid, 94 isUserVideoQuotaValid,
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index c2b8eff95..500f8770a 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
14 14
15// --------------------------------------------------------------------------- 15// ---------------------------------------------------------------------------
16 16
17const LAST_MIGRATION_VERSION = 390 17const LAST_MIGRATION_VERSION = 395
18 18
19// --------------------------------------------------------------------------- 19// ---------------------------------------------------------------------------
20 20
@@ -177,6 +177,7 @@ let CONSTRAINTS_FIELDS = {
177 PASSWORD: { min: 6, max: 255 }, // Length 177 PASSWORD: { min: 6, max: 255 }, // Length
178 VIDEO_QUOTA: { min: -1 }, 178 VIDEO_QUOTA: { min: -1 },
179 VIDEO_QUOTA_DAILY: { min: -1 }, 179 VIDEO_QUOTA_DAILY: { min: -1 },
180 VIDEO_LANGUAGES: { max: 500 }, // Array length
180 BLOCKED_REASON: { min: 3, max: 250 } // Length 181 BLOCKED_REASON: { min: 3, max: 250 } // Length
181 }, 182 },
182 VIDEO_ABUSES: { 183 VIDEO_ABUSES: {
diff --git a/server/initializers/migrations/0395-user-video-languages.ts b/server/initializers/migrations/0395-user-video-languages.ts
new file mode 100644
index 000000000..278698bf4
--- /dev/null
+++ b/server/initializers/migrations/0395-user-video-languages.ts
@@ -0,0 +1,25 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize,
7 db: any
8}): Promise<void> {
9 const data = {
10 type: Sequelize.ARRAY(Sequelize.STRING),
11 allowNull: true,
12 defaultValue: null
13 }
14
15 await utils.queryInterface.addColumn('user', 'videoLanguages', data)
16}
17
18function down (options) {
19 throw new Error('Not implemented.')
20}
21
22export {
23 up,
24 down
25}
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index ec70fa0fd..947ed36c3 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -13,7 +13,7 @@ import {
13 isUserNSFWPolicyValid, 13 isUserNSFWPolicyValid,
14 isUserPasswordValid, 14 isUserPasswordValid,
15 isUserRoleValid, 15 isUserRoleValid,
16 isUserUsernameValid, 16 isUserUsernameValid, isUserVideoLanguages,
17 isUserVideoQuotaDailyValid, 17 isUserVideoQuotaDailyValid,
18 isUserVideoQuotaValid, 18 isUserVideoQuotaValid,
19 isUserVideosHistoryEnabledValid 19 isUserVideosHistoryEnabledValid
@@ -198,6 +198,9 @@ const usersUpdateMeValidator = [
198 body('autoPlayVideo') 198 body('autoPlayVideo')
199 .optional() 199 .optional()
200 .custom(isUserAutoPlayVideoValid).withMessage('Should have a valid automatically plays video attribute'), 200 .custom(isUserAutoPlayVideoValid).withMessage('Should have a valid automatically plays video attribute'),
201 body('videoLanguages')
202 .optional()
203 .custom(isUserVideoLanguages).withMessage('Should have a valid video languages attribute'),
201 body('videosHistoryEnabled') 204 body('videosHistoryEnabled')
202 .optional() 205 .optional()
203 .custom(isUserVideosHistoryEnabledValid).withMessage('Should have a valid videos history enabled attribute'), 206 .custom(isUserVideosHistoryEnabledValid).withMessage('Should have a valid videos history enabled attribute'),
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index e75039521..aac691d66 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -31,6 +31,7 @@ import {
31 isUserPasswordValid, 31 isUserPasswordValid,
32 isUserRoleValid, 32 isUserRoleValid,
33 isUserUsernameValid, 33 isUserUsernameValid,
34 isUserVideoLanguages,
34 isUserVideoQuotaDailyValid, 35 isUserVideoQuotaDailyValid,
35 isUserVideoQuotaValid, 36 isUserVideoQuotaValid,
36 isUserVideosHistoryEnabledValid, 37 isUserVideosHistoryEnabledValid,
@@ -147,6 +148,12 @@ export class UserModel extends Model<UserModel> {
147 @Column 148 @Column
148 autoPlayVideo: boolean 149 autoPlayVideo: boolean
149 150
151 @AllowNull(true)
152 @Default(null)
153 @Is('UserVideoLanguages', value => throwIfNotValid(value, isUserVideoLanguages, 'video languages'))
154 @Column(DataType.ARRAY(DataType.STRING))
155 videoLanguages: string[]
156
150 @AllowNull(false) 157 @AllowNull(false)
151 @Default(UserAdminFlag.NONE) 158 @Default(UserAdminFlag.NONE)
152 @Is('UserAdminFlags', value => throwIfNotValid(value, isUserAdminFlagsValid, 'user admin flags')) 159 @Is('UserAdminFlags', value => throwIfNotValid(value, isUserAdminFlagsValid, 'user admin flags'))
@@ -551,6 +558,7 @@ export class UserModel extends Model<UserModel> {
551 webTorrentEnabled: this.webTorrentEnabled, 558 webTorrentEnabled: this.webTorrentEnabled,
552 videosHistoryEnabled: this.videosHistoryEnabled, 559 videosHistoryEnabled: this.videosHistoryEnabled,
553 autoPlayVideo: this.autoPlayVideo, 560 autoPlayVideo: this.autoPlayVideo,
561 videoLanguages: this.videoLanguages,
554 role: this.role, 562 role: this.role,
555 roleLabel: USER_ROLE_LABELS[ this.role ], 563 roleLabel: USER_ROLE_LABELS[ this.role ],
556 videoQuota: this.videoQuota, 564 videoQuota: this.videoQuota,
diff --git a/server/models/utils.ts b/server/models/utils.ts
index 2b172f608..206e108c3 100644
--- a/server/models/utils.ts
+++ b/server/models/utils.ts
@@ -1,7 +1,7 @@
1import { Sequelize } from 'sequelize-typescript' 1import { Model, Sequelize } from 'sequelize-typescript'
2import * as validator from 'validator' 2import * as validator from 'validator'
3import { OrderItem } from 'sequelize'
4import { Col } from 'sequelize/types/lib/utils' 3import { Col } from 'sequelize/types/lib/utils'
4import { OrderItem } from 'sequelize/types'
5 5
6type SortType = { sortModel: any, sortValue: string } 6type SortType = { sortModel: any, sortValue: string }
7 7
@@ -127,6 +127,11 @@ function parseAggregateResult (result: any) {
127 return total 127 return total
128} 128}
129 129
130const createSafeIn = (model: typeof Model, stringArr: string[]) => {
131 return stringArr.map(t => model.sequelize.escape(t))
132 .join(', ')
133}
134
130// --------------------------------------------------------------------------- 135// ---------------------------------------------------------------------------
131 136
132export { 137export {
@@ -141,7 +146,8 @@ export {
141 buildTrigramSearchIndex, 146 buildTrigramSearchIndex,
142 buildWhereIdOrUUID, 147 buildWhereIdOrUUID,
143 isOutdated, 148 isOutdated,
144 parseAggregateResult 149 parseAggregateResult,
150 createSafeIn
145} 151}
146 152
147// --------------------------------------------------------------------------- 153// ---------------------------------------------------------------------------
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index eccf0a4fa..92d07b5bc 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -83,6 +83,7 @@ import {
83 buildBlockedAccountSQL, 83 buildBlockedAccountSQL,
84 buildTrigramSearchIndex, 84 buildTrigramSearchIndex,
85 buildWhereIdOrUUID, 85 buildWhereIdOrUUID,
86 createSafeIn,
86 createSimilarityAttribute, 87 createSimilarityAttribute,
87 getVideoSort, 88 getVideoSort,
88 isOutdated, 89 isOutdated,
@@ -227,6 +228,8 @@ type AvailableForListIDsOptions = {
227 trendingDays?: number 228 trendingDays?: number
228 user?: UserModel, 229 user?: UserModel,
229 historyOfUser?: UserModel 230 historyOfUser?: UserModel
231
232 baseWhere?: WhereOptions[]
230} 233}
231 234
232@Scopes(() => ({ 235@Scopes(() => ({
@@ -270,34 +273,34 @@ type AvailableForListIDsOptions = {
270 return query 273 return query
271 }, 274 },
272 [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => { 275 [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
273 const attributes = options.withoutId === true ? [] : [ 'id' ] 276 const whereAnd = options.baseWhere ? options.baseWhere : []
274 277
275 const query: FindOptions = { 278 const query: FindOptions = {
276 raw: true, 279 raw: true,
277 attributes, 280 attributes: options.withoutId === true ? [] : [ 'id' ],
278 where: {
279 id: {
280 [ Op.and ]: [
281 {
282 [ Op.notIn ]: Sequelize.literal(
283 '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
284 )
285 }
286 ]
287 },
288 channelId: {
289 [ Op.notIn ]: Sequelize.literal(
290 '(' +
291 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
292 buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
293 ')' +
294 ')'
295 )
296 }
297 },
298 include: [] 281 include: []
299 } 282 }
300 283
284 whereAnd.push({
285 id: {
286 [ Op.notIn ]: Sequelize.literal(
287 '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
288 )
289 }
290 })
291
292 whereAnd.push({
293 channelId: {
294 [ Op.notIn ]: Sequelize.literal(
295 '(' +
296 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
297 buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
298 ')' +
299 ')'
300 )
301 }
302 })
303
301 // Only list public/published videos 304 // Only list public/published videos
302 if (!options.filter || options.filter !== 'all-local') { 305 if (!options.filter || options.filter !== 'all-local') {
303 const privacyWhere = { 306 const privacyWhere = {
@@ -317,7 +320,7 @@ type AvailableForListIDsOptions = {
317 ] 320 ]
318 } 321 }
319 322
320 Object.assign(query.where, privacyWhere) 323 whereAnd.push(privacyWhere)
321 } 324 }
322 325
323 if (options.videoPlaylistId) { 326 if (options.videoPlaylistId) {
@@ -387,86 +390,114 @@ type AvailableForListIDsOptions = {
387 390
388 // Force actorId to be a number to avoid SQL injections 391 // Force actorId to be a number to avoid SQL injections
389 const actorIdNumber = parseInt(options.followerActorId.toString(), 10) 392 const actorIdNumber = parseInt(options.followerActorId.toString(), 10)
390 query.where[ 'id' ][ Op.and ].push({ 393 whereAnd.push({
391 [ Op.in ]: Sequelize.literal( 394 id: {
392 '(' + 395 [ Op.in ]: Sequelize.literal(
393 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' + 396 '(' +
394 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + 397 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
395 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + 398 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
396 ' UNION ALL ' + 399 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
397 'SELECT "video"."id" AS "id" FROM "video" ' + 400 ' UNION ALL ' +
398 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + 401 'SELECT "video"."id" AS "id" FROM "video" ' +
399 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' + 402 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
400 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' + 403 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
401 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' + 404 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
402 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + 405 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
403 localVideosReq + 406 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
404 ')' 407 localVideosReq +
405 ) 408 ')'
409 )
410 }
406 }) 411 })
407 } 412 }
408 413
409 if (options.withFiles === true) { 414 if (options.withFiles === true) {
410 query.where[ 'id' ][ Op.and ].push({ 415 whereAnd.push({
411 [ Op.in ]: Sequelize.literal( 416 id: {
412 '(SELECT "videoId" FROM "videoFile")' 417 [ Op.in ]: Sequelize.literal(
413 ) 418 '(SELECT "videoId" FROM "videoFile")'
419 )
420 }
414 }) 421 })
415 } 422 }
416 423
417 // FIXME: issues with sequelize count when making a join on n:m relation, so we just make a IN() 424 // FIXME: issues with sequelize count when making a join on n:m relation, so we just make a IN()
418 if (options.tagsAllOf || options.tagsOneOf) { 425 if (options.tagsAllOf || options.tagsOneOf) {
419 const createTagsIn = (tags: string[]) => {
420 return tags.map(t => VideoModel.sequelize.escape(t))
421 .join(', ')
422 }
423
424 if (options.tagsOneOf) { 426 if (options.tagsOneOf) {
425 query.where[ 'id' ][ Op.and ].push({ 427 whereAnd.push({
426 [ Op.in ]: Sequelize.literal( 428 id: {
427 '(' + 429 [ Op.in ]: Sequelize.literal(
428 'SELECT "videoId" FROM "videoTag" ' + 430 '(' +
429 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + 431 'SELECT "videoId" FROM "videoTag" ' +
430 'WHERE "tag"."name" IN (' + createTagsIn(options.tagsOneOf) + ')' + 432 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
431 ')' 433 'WHERE "tag"."name" IN (' + createSafeIn(VideoModel, options.tagsOneOf) + ')' +
432 ) 434 ')'
435 )
436 }
433 }) 437 })
434 } 438 }
435 439
436 if (options.tagsAllOf) { 440 if (options.tagsAllOf) {
437 query.where[ 'id' ][ Op.and ].push({ 441 whereAnd.push({
438 [ Op.in ]: Sequelize.literal( 442 id: {
439 '(' + 443 [ Op.in ]: Sequelize.literal(
440 'SELECT "videoId" FROM "videoTag" ' + 444 '(' +
441 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + 445 'SELECT "videoId" FROM "videoTag" ' +
442 'WHERE "tag"."name" IN (' + createTagsIn(options.tagsAllOf) + ')' + 446 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
443 'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + options.tagsAllOf.length + 447 'WHERE "tag"."name" IN (' + createSafeIn(VideoModel, options.tagsAllOf) + ')' +
444 ')' 448 'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + options.tagsAllOf.length +
445 ) 449 ')'
450 )
451 }
446 }) 452 })
447 } 453 }
448 } 454 }
449 455
450 if (options.nsfw === true || options.nsfw === false) { 456 if (options.nsfw === true || options.nsfw === false) {
451 query.where[ 'nsfw' ] = options.nsfw 457 whereAnd.push({ nsfw: options.nsfw })
452 } 458 }
453 459
454 if (options.categoryOneOf) { 460 if (options.categoryOneOf) {
455 query.where[ 'category' ] = { 461 whereAnd.push({
456 [ Op.or ]: options.categoryOneOf 462 category: {
457 } 463 [ Op.or ]: options.categoryOneOf
464 }
465 })
458 } 466 }
459 467
460 if (options.licenceOneOf) { 468 if (options.licenceOneOf) {
461 query.where[ 'licence' ] = { 469 whereAnd.push({
462 [ Op.or ]: options.licenceOneOf 470 licence: {
463 } 471 [ Op.or ]: options.licenceOneOf
472 }
473 })
464 } 474 }
465 475
466 if (options.languageOneOf) { 476 if (options.languageOneOf) {
467 query.where[ 'language' ] = { 477 let videoLanguages = options.languageOneOf
468 [ Op.or ]: options.languageOneOf 478 if (options.languageOneOf.find(l => l === '_unknown')) {
479 videoLanguages = videoLanguages.concat([ null ])
469 } 480 }
481
482 whereAnd.push({
483 [Op.or]: [
484 {
485 language: {
486 [ Op.or ]: videoLanguages
487 }
488 },
489 {
490 id: {
491 [ Op.in ]: Sequelize.literal(
492 '(' +
493 'SELECT "videoId" FROM "videoCaption" ' +
494 'WHERE "language" IN (' + createSafeIn(VideoModel, options.languageOneOf) + ') ' +
495 ')'
496 )
497 }
498 }
499 ]
500 })
470 } 501 }
471 502
472 if (options.trendingDays) { 503 if (options.trendingDays) {
@@ -490,6 +521,10 @@ type AvailableForListIDsOptions = {
490 query.subQuery = false 521 query.subQuery = false
491 } 522 }
492 523
524 query.where = {
525 [ Op.and ]: whereAnd
526 }
527
493 return query 528 return query
494 }, 529 },
495 [ ScopeNames.WITH_THUMBNAILS ]: { 530 [ ScopeNames.WITH_THUMBNAILS ]: {
@@ -1175,7 +1210,7 @@ export class VideoModel extends Model<VideoModel> {
1175 throw new Error('Try to filter all-local but no user has not the see all videos right') 1210 throw new Error('Try to filter all-local but no user has not the see all videos right')
1176 } 1211 }
1177 1212
1178 const query: FindOptions = { 1213 const query: FindOptions & { where?: null } = {
1179 offset: options.start, 1214 offset: options.start,
1180 limit: options.count, 1215 limit: options.count,
1181 order: getVideoSort(options.sort) 1216 order: getVideoSort(options.sort)
@@ -1299,16 +1334,13 @@ export class VideoModel extends Model<VideoModel> {
1299 ) 1334 )
1300 } 1335 }
1301 1336
1302 const query: FindOptions = { 1337 const query = {
1303 attributes: { 1338 attributes: {
1304 include: attributesInclude 1339 include: attributesInclude
1305 }, 1340 },
1306 offset: options.start, 1341 offset: options.start,
1307 limit: options.count, 1342 limit: options.count,
1308 order: getVideoSort(options.sort), 1343 order: getVideoSort(options.sort)
1309 where: {
1310 [ Op.and ]: whereAnd
1311 }
1312 } 1344 }
1313 1345
1314 const serverActor = await getServerActor() 1346 const serverActor = await getServerActor()
@@ -1323,7 +1355,8 @@ export class VideoModel extends Model<VideoModel> {
1323 tagsOneOf: options.tagsOneOf, 1355 tagsOneOf: options.tagsOneOf,
1324 tagsAllOf: options.tagsAllOf, 1356 tagsAllOf: options.tagsAllOf,
1325 user: options.user, 1357 user: options.user,
1326 filter: options.filter 1358 filter: options.filter,
1359 baseWhere: whereAnd
1327 } 1360 }
1328 1361
1329 return VideoModel.getAvailableForApi(query, queryOptions) 1362 return VideoModel.getAvailableForApi(query, queryOptions)
@@ -1590,7 +1623,7 @@ export class VideoModel extends Model<VideoModel> {
1590 } 1623 }
1591 1624
1592 private static async getAvailableForApi ( 1625 private static async getAvailableForApi (
1593 query: FindOptions, 1626 query: FindOptions & { where?: null }, // Forbid where field in query
1594 options: AvailableForListIDsOptions, 1627 options: AvailableForListIDsOptions,
1595 countVideos = true 1628 countVideos = true
1596 ) { 1629 ) {
@@ -1609,11 +1642,15 @@ export class VideoModel extends Model<VideoModel> {
1609 ] 1642 ]
1610 } 1643 }
1611 1644
1612 const [ count, rowsId ] = await Promise.all([ 1645 const [ count, ids ] = await Promise.all([
1613 countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve<number>(undefined), 1646 countVideos
1614 VideoModel.scope(idsScope).findAll(query) 1647 ? VideoModel.scope(countScope).count(countQuery)
1648 : Promise.resolve<number>(undefined),
1649
1650 VideoModel.scope(idsScope)
1651 .findAll(query)
1652 .then(rows => rows.map(r => r.id))
1615 ]) 1653 ])
1616 const ids = rowsId.map(r => r.id)
1617 1654
1618 if (ids.length === 0) return { data: [], total: count } 1655 if (ids.length === 0) return { data: [], total: count }
1619 1656
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts
index 2316033a1..5d62fe2b3 100644
--- a/server/tests/api/check-params/users.ts
+++ b/server/tests/api/check-params/users.ts
@@ -364,6 +364,29 @@ describe('Test users API validators', function () {
364 await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields }) 364 await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
365 }) 365 })
366 366
367 it('Should fail with an invalid videoLanguages attribute', async function () {
368 {
369 const fields = {
370 videoLanguages: 'toto'
371 }
372
373 await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
374 }
375
376 {
377 const languages = []
378 for (let i = 0; i < 1000; i++) {
379 languages.push('fr')
380 }
381
382 const fields = {
383 videoLanguages: languages
384 }
385
386 await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
387 }
388 })
389
367 it('Should succeed to change password with the correct params', async function () { 390 it('Should succeed to change password with the correct params', async function () {
368 const fields = { 391 const fields = {
369 currentPassword: 'my super password', 392 currentPassword: 'my super password',
diff --git a/server/tests/api/search/search-videos.ts b/server/tests/api/search/search-videos.ts
index 92cc0dc71..c06200ffe 100644
--- a/server/tests/api/search/search-videos.ts
+++ b/server/tests/api/search/search-videos.ts
@@ -13,6 +13,7 @@ import {
13 uploadVideo, 13 uploadVideo,
14 wait 14 wait
15} from '../../../../shared/extra-utils' 15} from '../../../../shared/extra-utils'
16import { createVideoCaption } from '../../../../shared/extra-utils/videos/video-captions'
16 17
17const expect = chai.expect 18const expect = chai.expect
18 19
@@ -41,8 +42,29 @@ describe('Test videos search', function () {
41 const attributes2 = immutableAssign(attributes1, { name: attributes1.name + ' - 2', fixture: 'video_short.mp4' }) 42 const attributes2 = immutableAssign(attributes1, { name: attributes1.name + ' - 2', fixture: 'video_short.mp4' })
42 await uploadVideo(server.url, server.accessToken, attributes2) 43 await uploadVideo(server.url, server.accessToken, attributes2)
43 44
44 const attributes3 = immutableAssign(attributes1, { name: attributes1.name + ' - 3', language: 'en' }) 45 {
45 await uploadVideo(server.url, server.accessToken, attributes3) 46 const attributes3 = immutableAssign(attributes1, { name: attributes1.name + ' - 3', language: undefined })
47 const res = await uploadVideo(server.url, server.accessToken, attributes3)
48 const videoId = res.body.video.id
49
50 await createVideoCaption({
51 url: server.url,
52 accessToken: server.accessToken,
53 language: 'en',
54 videoId,
55 fixture: 'subtitle-good2.vtt',
56 mimeType: 'application/octet-stream'
57 })
58
59 await createVideoCaption({
60 url: server.url,
61 accessToken: server.accessToken,
62 language: 'aa',
63 videoId,
64 fixture: 'subtitle-good2.vtt',
65 mimeType: 'application/octet-stream'
66 })
67 }
46 68
47 const attributes4 = immutableAssign(attributes1, { name: attributes1.name + ' - 4', language: 'pl', nsfw: true }) 69 const attributes4 = immutableAssign(attributes1, { name: attributes1.name + ' - 4', language: 'pl', nsfw: true })
48 await uploadVideo(server.url, server.accessToken, attributes4) 70 await uploadVideo(server.url, server.accessToken, attributes4)
@@ -51,7 +73,7 @@ describe('Test videos search', function () {
51 73
52 startDate = new Date().toISOString() 74 startDate = new Date().toISOString()
53 75
54 const attributes5 = immutableAssign(attributes1, { name: attributes1.name + ' - 5', licence: 2 }) 76 const attributes5 = immutableAssign(attributes1, { name: attributes1.name + ' - 5', licence: 2, language: undefined })
55 await uploadVideo(server.url, server.accessToken, attributes5) 77 await uploadVideo(server.url, server.accessToken, attributes5)
56 78
57 const attributes6 = immutableAssign(attributes1, { name: attributes1.name + ' - 6', tags: [ 't1', 't2 '] }) 79 const attributes6 = immutableAssign(attributes1, { name: attributes1.name + ' - 6', tags: [ 't1', 't2 '] })
@@ -241,13 +263,26 @@ describe('Test videos search', function () {
241 search: '1111 2222 3333', 263 search: '1111 2222 3333',
242 languageOneOf: [ 'pl', 'en' ] 264 languageOneOf: [ 'pl', 'en' ]
243 } 265 }
244 const res1 = await advancedVideosSearch(server.url, query)
245 expect(res1.body.total).to.equal(2)
246 expect(res1.body.data[0].name).to.equal('1111 2222 3333 - 3')
247 expect(res1.body.data[1].name).to.equal('1111 2222 3333 - 4')
248 266
249 const res2 = await advancedVideosSearch(server.url, immutableAssign(query, { languageOneOf: [ 'eo' ] })) 267 {
250 expect(res2.body.total).to.equal(0) 268 const res = await advancedVideosSearch(server.url, query)
269 expect(res.body.total).to.equal(2)
270 expect(res.body.data[ 0 ].name).to.equal('1111 2222 3333 - 3')
271 expect(res.body.data[ 1 ].name).to.equal('1111 2222 3333 - 4')
272 }
273
274 {
275 const res = await advancedVideosSearch(server.url, immutableAssign(query, { languageOneOf: [ 'pl', 'en', '_unknown' ] }))
276 expect(res.body.total).to.equal(3)
277 expect(res.body.data[ 0 ].name).to.equal('1111 2222 3333 - 3')
278 expect(res.body.data[ 1 ].name).to.equal('1111 2222 3333 - 4')
279 expect(res.body.data[ 2 ].name).to.equal('1111 2222 3333 - 5')
280 }
281
282 {
283 const res = await advancedVideosSearch(server.url, immutableAssign(query, { languageOneOf: [ 'eo' ] }))
284 expect(res.body.total).to.equal(0)
285 }
251 }) 286 })
252 287
253 it('Should search by start date', async function () { 288 it('Should search by start date', async function () {
diff --git a/shared/models/users/user-update-me.model.ts b/shared/models/users/user-update-me.model.ts
index e24afab94..6e6cd7115 100644
--- a/shared/models/users/user-update-me.model.ts
+++ b/shared/models/users/user-update-me.model.ts
@@ -8,6 +8,7 @@ export interface UserUpdateMe {
8 webTorrentEnabled?: boolean 8 webTorrentEnabled?: boolean
9 autoPlayVideo?: boolean 9 autoPlayVideo?: boolean
10 videosHistoryEnabled?: boolean 10 videosHistoryEnabled?: boolean
11 videoLanguages?: string[]
11 12
12 email?: string 13 email?: string
13 currentPassword?: string 14 currentPassword?: string