diff options
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 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | 1 | import { Component, Input, OnInit } from '@angular/core' |
2 | import { Notifier } from '@app/core' | 2 | import { Notifier, ServerService } from '@app/core' |
3 | import { UserUpdateMe } from '../../../../../../shared' | 3 | import { UserUpdateMe } from '../../../../../../shared' |
4 | import { AuthService } from '../../../core' | 4 | import { AuthService } from '../../../core' |
5 | import { FormReactive, User, UserService } from '../../../shared' | 5 | import { FormReactive, User, UserService } from '../../../shared' |
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | 6 | import { I18n } from '@ngx-translate/i18n-polyfill' |
7 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 7 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
8 | import { Subject } from 'rxjs' | 8 | import { Subject } from 'rxjs' |
9 | import { SelectItem } from 'primeng/api' | ||
10 | import { 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 | |||
25 | import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' | 25 | import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' |
26 | import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component' | 26 | import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component' |
27 | import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences' | 27 | import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences' |
28 | import { | 28 | import { MyAccountVideoPlaylistCreateComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component' |
29 | MyAccountVideoPlaylistCreateComponent | 29 | import { 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' | ||
31 | import { | ||
32 | MyAccountVideoPlaylistUpdateComponent | ||
33 | } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component' | ||
34 | import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component' | 30 | import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component' |
35 | import { | 31 | import { 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' | ||
38 | import { DragDropModule } from '@angular/cdk/drag-drop' | 32 | import { DragDropModule } from '@angular/cdk/drag-drop' |
39 | import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email' | 33 | import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email' |
34 | import { 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 @@ | |||
1 | import { debounceTime } from 'rxjs/operators' | 1 | import { debounceTime, first, tap } from 'rxjs/operators' |
2 | import { OnDestroy, OnInit } from '@angular/core' | 2 | import { OnDestroy, OnInit } from '@angular/core' |
3 | import { ActivatedRoute, Router } from '@angular/router' | 3 | import { ActivatedRoute, Router } from '@angular/router' |
4 | import { fromEvent, Observable, Subscription } from 'rxjs' | 4 | import { fromEvent, Observable, of, Subscription } from 'rxjs' |
5 | import { AuthService } from '../../core/auth' | 5 | import { AuthService } from '../../core/auth' |
6 | import { ComponentPagination } from '../rest/component-pagination.model' | 6 | import { ComponentPagination } from '../rest/component-pagination.model' |
7 | import { VideoSortField } from './sort-field.type' | 7 | import { 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' | |||
35 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | 35 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' |
36 | 36 | ||
37 | export interface VideosProvider { | 37 | export 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 | ||
236 | p-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 |
236 | p-calendar .ui-datepicker { | 273 | p-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' | |||
2 | import * as validator from 'validator' | 2 | import * as validator from 'validator' |
3 | import { UserRole } from '../../../shared' | 3 | import { UserRole } from '../../../shared' |
4 | import { CONSTRAINTS_FIELDS, NSFW_POLICY_TYPES } from '../../initializers/constants' | 4 | import { CONSTRAINTS_FIELDS, NSFW_POLICY_TYPES } from '../../initializers/constants' |
5 | import { exists, isBooleanValid, isFileValid } from './misc' | 5 | import { exists, isArray, isBooleanValid, isFileValid } from './misc' |
6 | import { values } from 'lodash' | 6 | import { values } from 'lodash' |
7 | 7 | ||
8 | const USERS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USERS | 8 | const 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 | ||
57 | function isUserVideoLanguages (value: any) { | ||
58 | return value === null || (isArray(value) && value.length < CONSTRAINTS_FIELDS.USERS.VIDEO_LANGUAGES.max) | ||
59 | } | ||
60 | |||
57 | function isUserAdminFlagsValid (value: any) { | 61 | function 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 | ||
17 | const LAST_MIGRATION_VERSION = 390 | 17 | const 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 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async 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 | |||
18 | function down (options) { | ||
19 | throw new Error('Not implemented.') | ||
20 | } | ||
21 | |||
22 | export { | ||
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 @@ | |||
1 | import { Sequelize } from 'sequelize-typescript' | 1 | import { Model, Sequelize } from 'sequelize-typescript' |
2 | import * as validator from 'validator' | 2 | import * as validator from 'validator' |
3 | import { OrderItem } from 'sequelize' | ||
4 | import { Col } from 'sequelize/types/lib/utils' | 3 | import { Col } from 'sequelize/types/lib/utils' |
4 | import { OrderItem } from 'sequelize/types' | ||
5 | 5 | ||
6 | type SortType = { sortModel: any, sortValue: string } | 6 | type 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 | ||
130 | const createSafeIn = (model: typeof Model, stringArr: string[]) => { | ||
131 | return stringArr.map(t => model.sequelize.escape(t)) | ||
132 | .join(', ') | ||
133 | } | ||
134 | |||
130 | // --------------------------------------------------------------------------- | 135 | // --------------------------------------------------------------------------- |
131 | 136 | ||
132 | export { | 137 | export { |
@@ -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' |
16 | import { createVideoCaption } from '../../../../shared/extra-utils/videos/video-captions' | ||
16 | 17 | ||
17 | const expect = chai.expect | 18 | const 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 |