Browse Source

Add language filters in user preferences

Chocobozzz 4 years ago
parent
commit
3caf77d3b1
24 changed files with 443 additions and 150 deletions
  1. 3 3
      client/src/app/+my-account/my-account-settings/my-account-settings.component.html
  2. 15 0
      client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html
  3. 53 11
      client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts
  4. 6 10
      client/src/app/+my-account/my-account.module.ts
  5. 1 0
      client/src/app/shared/users/user.model.ts
  6. 23 4
      client/src/app/shared/video/abstract-video-list.ts
  7. 16 6
      client/src/app/shared/video/video.service.ts
  8. 1 1
      client/src/app/videos/recommendations/recent-videos-recommendation.service.ts
  9. 9 1
      client/src/app/videos/video-list/video-local.component.ts
  10. 9 1
      client/src/app/videos/video-list/video-recently-added.component.ts
  11. 9 1
      client/src/app/videos/video-list/video-trending.component.ts
  12. 15 11
      client/src/sass/include/_mixins.scss
  13. 37 0
      client/src/sass/primeng-custom.scss
  14. 1 0
      server/controllers/api/users/me.ts
  15. 6 1
      server/helpers/custom-validators/users.ts
  16. 2 1
      server/initializers/constants.ts
  17. 25 0
      server/initializers/migrations/0395-user-video-languages.ts
  18. 4 1
      server/middlewares/validators/users.ts
  19. 8 0
      server/models/account/user.ts
  20. 9 3
      server/models/utils.ts
  21. 123 86
      server/models/video/video.ts
  22. 23 0
      server/tests/api/check-params/users.ts
  23. 44 9
      server/tests/api/search/search-videos.ts
  24. 1 0
      shared/models/users/user-update-me.model.ts

+ 3 - 3
client/src/app/+my-account/my-account-settings/my-account-settings.component.html

@@ -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 - 0
client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html

@@ -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"

+ 53 - 11
client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts

@@ -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')
+  }
 }

+ 6 - 10
client/src/app/+my-account/my-account.module.ts

@@ -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: [

+ 1 - 0
client/src/app/shared/users/user.model.ts

@@ -18,6 +18,7 @@ export class User implements UserServerModel {
   webTorrentEnabled: boolean
   autoPlayVideo: boolean
   videosHistoryEnabled: boolean
+  videoLanguages: string[]
 
   videoQuota: number
   videoQuotaDaily: number

+ 23 - 4
client/src/app/shared/video/abstract-video-list.ts

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

+ 16 - 6
client/src/app/shared/video/video.service.ts

@@ -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(

+ 1 - 1
client/src/app/videos/recommendations/recent-videos-recommendation.service.ts

@@ -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

+ 9 - 1
client/src/app/videos/video-list/video-local.component.ts

@@ -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 () {

+ 9 - 1
client/src/app/videos/video-list/video-recently-added.component.ts

@@ -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 () {

+ 9 - 1
client/src/app/videos/video-list/video-trending.component.ts

@@ -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 () {

+ 15 - 11
client/src/sass/include/_mixins.scss

@@ -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 {

+ 37 - 0
client/src/sass/primeng-custom.scss

@@ -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 {

+ 1 - 0
server/controllers/api/users/me.ts

@@ -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) {

+ 6 - 1
server/helpers/custom-validators/users.ts

@@ -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,

+ 2 - 1
server/initializers/constants.ts

@@ -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: {

+ 25 - 0
server/initializers/migrations/0395-user-video-languages.ts

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

+ 4 - 1
server/middlewares/validators/users.ts

@@ -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'),

+ 8 - 0
server/models/account/user.ts

@@ -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,

+ 9 - 3
server/models/utils.ts

@@ -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
 }
 
 // ---------------------------------------------------------------------------

+ 123 - 86
server/models/video/video.ts

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

+ 23 - 0
server/tests/api/check-params/users.ts

@@ -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',

+ 44 - 9
server/tests/api/search/search-videos.ts

@@ -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 () {

+ 1 - 0
shared/models/users/user-update-me.model.ts

@@ -8,6 +8,7 @@ export interface UserUpdateMe {
   webTorrentEnabled?: boolean
   autoPlayVideo?: boolean
   videosHistoryEnabled?: boolean
+  videoLanguages?: string[]
 
   email?: string
   currentPassword?: string