aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2020-05-29 16:16:24 +0200
committerChocobozzz <chocobozzz@cpy.re>2020-06-10 14:02:41 +0200
commit5fb2e2888ce032c638e4b75d07458642f0833e52 (patch)
tree8830d873569316889b8134027e9a43b198cca38f
parent62e7be634bc189f942ae51cb4b080079ab503ff0 (diff)
downloadPeerTube-5fb2e2888ce032c638e4b75d07458642f0833e52.tar.gz
PeerTube-5fb2e2888ce032c638e4b75d07458642f0833e52.tar.zst
PeerTube-5fb2e2888ce032c638e4b75d07458642f0833e52.zip
First implem global search
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html84
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss6
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts16
-rw-r--r--client/src/app/app.module.ts3
-rw-r--r--client/src/app/core/server/server.service.ts45
-rw-r--r--client/src/app/header/index.ts1
-rw-r--r--client/src/app/header/search-typeahead.component.html41
-rw-r--r--client/src/app/header/search-typeahead.component.scss8
-rw-r--r--client/src/app/header/search-typeahead.component.ts196
-rw-r--r--client/src/app/header/suggestion.component.html21
-rw-r--r--client/src/app/header/suggestion.component.ts22
-rw-r--r--client/src/app/header/suggestions.component.html6
-rw-r--r--client/src/app/header/suggestions.component.ts24
-rw-r--r--client/src/app/search/advanced-search.model.ts21
-rw-r--r--client/src/app/search/channel-lazy-load.resolver.ts45
-rw-r--r--client/src/app/search/search-filters.component.html64
-rw-r--r--client/src/app/search/search-filters.component.ts8
-rw-r--r--client/src/app/search/search-routing.module.ts20
-rw-r--r--client/src/app/search/search.component.html15
-rw-r--r--client/src/app/search/search.component.ts98
-rw-r--r--client/src/app/search/search.module.ts14
-rw-r--r--client/src/app/search/search.service.ts48
-rw-r--r--client/src/app/search/video-lazy-load.resolver.ts43
-rw-r--r--client/src/app/shared/actor/actor.model.ts10
-rw-r--r--client/src/app/shared/angular/highlight.pipe.ts20
-rw-r--r--client/src/app/shared/forms/form-validators/custom-config-validators.service.ts8
-rw-r--r--client/src/app/shared/users/user-notification.model.ts4
-rw-r--r--client/src/app/shared/video/video-miniature.component.html4
-rw-r--r--client/src/app/shared/video/video-miniature.component.ts38
-rw-r--r--client/src/app/shared/video/video.model.ts13
-rw-r--r--config/default.yaml33
-rw-r--r--config/production.yaml.example33
-rw-r--r--config/test.yaml22
-rw-r--r--server/controllers/api/config.ts20
-rw-r--r--server/controllers/api/search.ts103
-rw-r--r--server/initializers/checker-after-init.ts7
-rw-r--r--server/initializers/checker-before-init.ts4
-rw-r--r--server/initializers/config.ts18
-rw-r--r--server/initializers/constants.ts11
-rw-r--r--server/lib/activitypub/videos.ts17
-rw-r--r--server/lib/plugins/plugin-index.ts3
-rw-r--r--server/middlewares/validators/config.ts9
-rw-r--r--server/models/account/account-blocklist.ts38
-rw-r--r--server/models/server/server-blocklist.ts21
-rw-r--r--server/tests/api/check-params/config.ts12
-rw-r--r--server/tests/api/server/config.ts12
-rw-r--r--shared/extra-utils/server/config.ts12
-rw-r--r--shared/models/avatars/avatar.model.ts3
-rw-r--r--shared/models/search/search-target-query.model.ts5
-rw-r--r--shared/models/search/video-channels-search-query.model.ts4
-rw-r--r--shared/models/search/videos-search-query.model.ts5
-rw-r--r--shared/models/server/custom-config.model.ts14
-rw-r--r--shared/models/server/server-config.model.ts7
-rw-r--r--shared/models/videos/video.model.ts10
54 files changed, 1045 insertions, 324 deletions
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
index 4ee573696..b8682ffe0 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
@@ -396,9 +396,9 @@
396 </div> 396 </div>
397 </div> 397 </div>
398 398
399 <div class="form-row mt-4"> <!-- new videos grid --> 399 <div class="form-row mt-4"> <!-- videos grid -->
400 <div class="form-group col-12 col-lg-4 col-xl-3"> 400 <div class="form-group col-12 col-lg-4 col-xl-3">
401 <div i18n class="inner-form-title">NEW VIDEOS</div> 401 <div i18n class="inner-form-title">VIDEOS</div>
402 </div> 402 </div>
403 403
404 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> 404 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
@@ -445,6 +445,86 @@
445 </div> 445 </div>
446 </div> 446 </div>
447 447
448 <div class="form-row mt-4"> <!-- search grid -->
449 <div class="form-group col-12 col-lg-4 col-xl-3">
450 <div i18n class="inner-form-title">SEARCH</div>
451 </div>
452
453 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
454
455 <ng-container formGroupName="search">
456 <ng-container formGroupName="remoteUri">
457
458 <div class="form-group">
459 <my-peertube-checkbox
460 inputName="searchRemoteUriUsers" formControlName="users"
461 i18n-labelText labelText="Allow users to do remote URI/handle search"
462 >
463 <ng-container ngProjectAs="description">
464 <span i18n>Add ability for <strong>your users</strong> to fetch remote videos/actors by their URI, that may not be federated with your instance</span>
465 </ng-container>
466 </my-peertube-checkbox>
467 </div>
468
469 <div class="form-group">
470 <my-peertube-checkbox
471 inputName="searchRemoteUriAnonymous" formControlName="anonymous"
472 i18n-labelText labelText="Allow anonymous to do remote URI/handle search"
473 >
474 <ng-container ngProjectAs="description">
475 <span i18n>Add ability for <strong>anonymous</strong> to fetch remote videos/actors by their URI, that may not be federated with your instance</span>
476 </ng-container>
477 </my-peertube-checkbox>
478 </div>
479
480 </ng-container>
481
482 <ng-container formGroupName="searchIndex">
483 <div class="form-group">
484 <my-peertube-checkbox
485 inputName="searchIndexEnabled" formControlName="enabled"
486 i18n-labelText labelText="Enable search index"
487 >
488
489 <ng-container ngProjectAs="extra">
490 <div [ngClass]="{ 'disabled-checkbox-extra': !isSearchIndexEnabled() }">
491 <label i18n for="searchIndexUrl">Search index URL</label>
492 <input
493 type="text" id="searchIndexUrl" class="form-control"
494 formControlName="url" [ngClass]="{ 'input-error': formErrors['search.searchIndex.url'] }"
495 >
496 <div *ngIf="formErrors.search.searchIndex.url" class="form-error">{{ formErrors.search.searchIndex.url }}</div>
497 </div>
498
499 <div class="mt-3">
500 <my-peertube-checkbox [ngClass]="{ 'disabled-checkbox-extra': !isSearchIndexEnabled() }"
501 inputName="searchIndexDisableLocalSearch" formControlName="disableLocalSearch"
502 i18n-labelText labelText="Disable local search"
503 ></my-peertube-checkbox>
504 </div>
505
506 <div class="mt-3">
507 <my-peertube-checkbox [ngClass]="{ 'disabled-checkbox-extra': !isSearchIndexEnabled() }"
508 inputName="searchIndexIsDefaultSearch" formControlName="isDefaultSearch"
509 i18n-labelText labelText="Set search index as default"
510 >
511 <ng-container ngProjectAs="description">
512 <span i18n>The local search is used by default</span>
513 </ng-container>
514 </my-peertube-checkbox>
515 </div>
516
517 </ng-container>
518 </my-peertube-checkbox>
519 </div>
520
521 </ng-container>
522
523 </ng-container>
524
525 </div>
526 </div>
527
448 <div class="form-row mt-4"> <!-- federation grid --> 528 <div class="form-row mt-4"> <!-- federation grid -->
449 <div class="form-group col-12 col-lg-4 col-xl-3"> 529 <div class="form-group col-12 col-lg-4 col-xl-3">
450 <div i18n class="inner-form-title">FEDERATION</div> 530 <div i18n class="inner-form-title">FEDERATION</div>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
index 2bfa92da4..9618100b5 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
@@ -64,8 +64,10 @@ textarea {
64} 64}
65 65
66.disabled-checkbox-extra { 66.disabled-checkbox-extra {
67 opacity: .5; 67 &, ::ng-deep label {
68 pointer-events: none; 68 opacity: .5;
69 pointer-events: none;
70 }
69} 71}
70 72
71.form-group-right { 73.form-group-right {
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 6d59494c8..3a47ba25e 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -221,6 +221,18 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
221 level: null, 221 level: null,
222 dismissable: null, 222 dismissable: null,
223 message: null 223 message: null
224 },
225 search: {
226 remoteUri: {
227 users: null,
228 anonymous: null
229 },
230 searchIndex: {
231 enabled: null,
232 url: this.customConfigValidatorsService.SEARCH_INDEX_URL,
233 disableLocalSearch: null,
234 isDefaultSearch: null
235 }
224 } 236 }
225 } 237 }
226 238
@@ -254,6 +266,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
254 return this.form.value['signup']['enabled'] === true 266 return this.form.value['signup']['enabled'] === true
255 } 267 }
256 268
269 isSearchIndexEnabled () {
270 return this.form.value['search']['searchIndex']['enabled'] === true
271 }
272
257 isAutoFollowIndexEnabled () { 273 isAutoFollowIndexEnabled () {
258 return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true 274 return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true
259 } 275 }
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts
index e61346dac..89332ec5f 100644
--- a/client/src/app/app.module.ts
+++ b/client/src/app/app.module.ts
@@ -8,7 +8,7 @@ import 'focus-visible'
8import { AppRoutingModule } from './app-routing.module' 8import { AppRoutingModule } from './app-routing.module'
9import { AppComponent } from './app.component' 9import { AppComponent } from './app.component'
10import { CoreModule } from './core' 10import { CoreModule } from './core'
11import { HeaderComponent, SearchTypeaheadComponent, SuggestionsComponent, SuggestionComponent } from './header' 11import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header'
12import { LoginModule } from './login' 12import { LoginModule } from './login'
13import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu' 13import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
14import { SharedModule } from './shared' 14import { SharedModule } from './shared'
@@ -35,7 +35,6 @@ registerLocaleData(localeOc, 'oc')
35 AvatarNotificationComponent, 35 AvatarNotificationComponent,
36 HeaderComponent, 36 HeaderComponent,
37 SearchTypeaheadComponent, 37 SearchTypeaheadComponent,
38 SuggestionsComponent,
39 SuggestionComponent, 38 SuggestionComponent,
40 39
41 CustomModalComponent, 40 CustomModalComponent,
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index fdfbe4c02..a804efd28 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -1,15 +1,16 @@
1import { Observable, of, Subject } from 'rxjs'
1import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators' 2import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http' 3import { HttpClient } from '@angular/common/http'
3import { Inject, Injectable, LOCALE_ID } from '@angular/core' 4import { Inject, Injectable, LOCALE_ID } from '@angular/core'
4import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
5import { Observable, of, Subject } from 'rxjs'
6import { getCompleteLocale, ServerConfig } from '../../../../../shared'
7import { environment } from '../../../environments/environment'
8import { VideoConstant } from '../../../../../shared/models/videos'
9import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
10import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' 5import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
6import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
11import { sortBy } from '@app/shared/misc/utils' 7import { sortBy } from '@app/shared/misc/utils'
8import { SearchTargetType } from '@shared/models/search/search-target-query.model'
12import { ServerStats } from '@shared/models/server' 9import { ServerStats } from '@shared/models/server'
10import { getCompleteLocale, ServerConfig } from '../../../../../shared'
11import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
12import { VideoConstant } from '../../../../../shared/models/videos'
13import { environment } from '../../../environments/environment'
13 14
14@Injectable() 15@Injectable()
15export class ServerService { 16export class ServerService {
@@ -47,12 +48,6 @@ export class ServerService {
47 css: '' 48 css: ''
48 } 49 }
49 }, 50 },
50 search: {
51 remoteUri: {
52 users: true,
53 anonymous: false
54 }
55 },
56 plugin: { 51 plugin: {
57 registered: [], 52 registered: [],
58 registeredExternalAuths: [], 53 registeredExternalAuths: [],
@@ -145,6 +140,18 @@ export class ServerService {
145 message: '', 140 message: '',
146 level: 'info', 141 level: 'info',
147 dismissable: false 142 dismissable: false
143 },
144 search: {
145 remoteUri: {
146 users: true,
147 anonymous: false
148 },
149 searchIndex: {
150 enabled: false,
151 url: '',
152 disableLocalSearch: false,
153 isDefaultSearch: false
154 }
148 } 155 }
149 } 156 }
150 157
@@ -264,6 +271,20 @@ export class ServerService {
264 return this.http.get<ServerStats>(ServerService.BASE_STATS_URL) 271 return this.http.get<ServerStats>(ServerService.BASE_STATS_URL)
265 } 272 }
266 273
274 getDefaultSearchTarget (): Promise<SearchTargetType> {
275 return this.getConfig().pipe(
276 map(config => {
277 const searchIndexConfig = config.search.searchIndex
278
279 if (searchIndexConfig.enabled && (searchIndexConfig.isDefaultSearch || searchIndexConfig.disableLocalSearch)) {
280 return 'search-index'
281 }
282
283 return 'local'
284 })
285 ).toPromise()
286 }
287
267 private loadAttributeEnum <T extends string | number> ( 288 private loadAttributeEnum <T extends string | number> (
268 baseUrl: string, 289 baseUrl: string,
269 attributeName: 'categories' | 'licences' | 'languages' | 'privacies', 290 attributeName: 'categories' | 'licences' | 'languages' | 'privacies',
diff --git a/client/src/app/header/index.ts b/client/src/app/header/index.ts
index a882d4d1f..005e0c97d 100644
--- a/client/src/app/header/index.ts
+++ b/client/src/app/header/index.ts
@@ -1,4 +1,3 @@
1export * from './header.component' 1export * from './header.component'
2export * from './search-typeahead.component' 2export * from './search-typeahead.component'
3export * from './suggestions.component'
4export * from './suggestion.component' 3export * from './suggestion.component'
diff --git a/client/src/app/header/search-typeahead.component.html b/client/src/app/header/search-typeahead.component.html
index bbf3834c5..4355b67af 100644
--- a/client/src/app/header/search-typeahead.component.html
+++ b/client/src/app/header/search-typeahead.component.html
@@ -1,38 +1,43 @@
1<div class="d-inline-flex position-relative" id="typeahead-container"> 1<div class="d-inline-flex position-relative" id="typeahead-container">
2 <input 2 <input
3 type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…" 3 type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…"
4 [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keyup)="handleKey($event)" (keydown.enter)="doSearch()" 4 [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keydown)="handleKey($event)" (keydown.enter)="doSearch()"
5 aria-label="Search" 5 aria-label="Search" autocomplete="off"
6 > 6 >
7 <span class="icon icon-search" (click)="doSearch()"></span> 7 <span class="icon icon-search" (click)="doSearch()"></span>
8 8
9 <div class="position-absolute jump-to-suggestions"> 9 <div class="position-absolute jump-to-suggestions">
10 <!-- suggestions --> 10
11 <my-suggestions *ngIf="search && newSearch" [results]="results" [highlight]="search" (init)="initKeyboardEventsManager($event)"></my-suggestions> 11 <ul [hidden]="!search || !areSuggestionsOpened" role="listbox" class="p-0 m-0">
12 <li
13 *ngFor="let result of results; let i = index" class="suggestion d-flex flex-justify-start flex-items-center p-0 f5"
14 role="option" aria-selected="true" (mouseenter)="onSuggestionHover(i)" (click)="onSuggestionlicked(result)"
15 >
16 <my-suggestion [result]="result" [highlight]="search"></my-suggestion>
17 </li>
18 </ul>
12 19
13 <!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion --> 20 <!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion -->
14 <div *ngIf="showHelp" id="typeahead-help" class="overflow-hidden"> 21 <div *ngIf="showSearchGlobalHelp()" id="typeahead-help" class="overflow-hidden">
15 <ng-container *ngIf="activeResult.type === 'search-global'"> 22 <div class="d-flex justify-content-between">
16 <div class="d-flex justify-content-between"> 23 <label class="small-title" i18n>GLOBAL SEARCH</label>
17 <label class="small-title" i18n>GLOBAL SEARCH</label> 24 <div class="advanced-search-status text-muted">
18 <div class="advanced-search-status text-muted"> 25 <span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.search.searchIndex.url }}</span>
19 <span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.followings.instance.autoFollowIndex.indexUrl }}</span> 26 <i class="glyphicon glyphicon-globe"></i>
20 <i class="glyphicon glyphicon-globe"></i>
21 </div>
22 </div> 27 </div>
23 <div class="text-muted" i18n>Results will be augmented with those of a third-party index. Only data necessary to make the query will be sent.</div> 28 </div>
24 </ng-container> 29 <div class="text-muted" i18n>Results will be augmented with those of a third-party index. Only data necessary to make the query will be sent.</div>
25 </div> 30 </div>
26 31
27 <!-- search instructions, when search input is empty --> 32 <!-- search instructions, when search input is empty -->
28 <div *ngIf="areInstructionsDisplayed" id="typeahead-instructions" class="overflow-hidden"> 33 <div *ngIf="areInstructionsDisplayed()" id="typeahead-instructions" class="overflow-hidden">
29 <div class="d-flex justify-content-between"> 34 <div class="d-flex justify-content-between">
30 <label class="small-title" i18n>ADVANCED SEARCH</label> 35 <label class="small-title" i18n>ADVANCED SEARCH</label>
31 <div class="advanced-search-status c-help"> 36 <div class="advanced-search-status c-help">
32 <span [ngClass]="canSearchAnyURI ? 'text-success' : 'text-muted'" i18n-title title="Determines whether you can resolve any distant content, or if this instance only allows doing so for instances it follows."> 37 <span [ngClass]="canSearchAnyURI ? 'text-success' : 'text-muted'" i18n-title title="Determines whether you can resolve any distant content, or if this instance only allows doing so for instances it follows.">
33 <span *ngIf="canSearchAnyURI" class="mr-1" i18n>any instance</span> 38 <span *ngIf="canSearchAnyURI()" class="mr-1" i18n>any instance</span>
34 <span *ngIf="!canSearchAnyURI" class="mr-1" i18n>only followed instances</span> 39 <span *ngIf="!canSearchAnyURI()" class="mr-1" i18n>only followed instances</span>
35 <i [ngClass]="canSearchAnyURI ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i> 40 <i [ngClass]="canSearchAnyURI() ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i>
36 </span> 41 </span>
37 </div> 42 </div>
38 </div> 43 </div>
diff --git a/client/src/app/header/search-typeahead.component.scss b/client/src/app/header/search-typeahead.component.scss
index 0a30ebd55..4b56fd93a 100644
--- a/client/src/app/header/search-typeahead.component.scss
+++ b/client/src/app/header/search-typeahead.component.scss
@@ -36,7 +36,7 @@
36 36
37#typeahead-help, 37#typeahead-help,
38#typeahead-instructions, 38#typeahead-instructions,
39my-suggestions ::ng-deep ul { 39li.suggestion {
40 border: 1px solid pvar(--mainBackgroundColor); 40 border: 1px solid pvar(--mainBackgroundColor);
41 border-bottom-right-radius: 3px; 41 border-bottom-right-radius: 3px;
42 border-bottom-left-radius: 3px; 42 border-bottom-left-radius: 3px;
@@ -90,7 +90,7 @@ my-suggestions ::ng-deep ul {
90 } 90 }
91 91
92 & > div:last-child { 92 & > div:last-child {
93 // we have to switch the display and not the opacity, 93 // we have to switch the display and not the opacity,
94 // to avoid clashing with the rest of the interface. 94 // to avoid clashing with the rest of the interface.
95 display: none; 95 display: none;
96 } 96 }
@@ -101,10 +101,10 @@ my-suggestions ::ng-deep ul {
101 @media screen and (min-width: $mobile-view) { 101 @media screen and (min-width: $mobile-view) {
102 display: initial !important; 102 display: initial !important;
103 } 103 }
104 104
105 #typeahead-help, 105 #typeahead-help,
106 #typeahead-instructions, 106 #typeahead-instructions,
107 my-suggestions ::ng-deep ul { 107 li.suggestion {
108 box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px; 108 box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px;
109 } 109 }
110 } 110 }
diff --git a/client/src/app/header/search-typeahead.component.ts b/client/src/app/header/search-typeahead.component.ts
index 2bf1072f4..6c8b8efee 100644
--- a/client/src/app/header/search-typeahead.component.ts
+++ b/client/src/app/header/search-typeahead.component.ts
@@ -1,23 +1,24 @@
1import { Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild } from '@angular/core' 1import { of } from 'rxjs'
2import { first, tap, delay } from 'rxjs/operators'
3import { ListKeyManager } from '@angular/cdk/a11y'
4import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren, AfterViewChecked } from '@angular/core'
2import { ActivatedRoute, Params, Router } from '@angular/router' 5import { ActivatedRoute, Params, Router } from '@angular/router'
3import { AuthService, ServerService } from '@app/core' 6import { AuthService, ServerService } from '@app/core'
4import { first, tap } from 'rxjs/operators'
5import { ListKeyManager } from '@angular/cdk/a11y'
6import { Result, SuggestionComponent } from './suggestion.component'
7import { of } from 'rxjs'
8import { ServerConfig } from '@shared/models' 7import { ServerConfig } from '@shared/models'
8import { SearchTargetType } from '@shared/models/search/search-target-query.model'
9import { SuggestionComponent, SuggestionPayload, SuggestionPayloadType } from './suggestion.component'
9 10
10@Component({ 11@Component({
11 selector: 'my-search-typeahead', 12 selector: 'my-search-typeahead',
12 templateUrl: './search-typeahead.component.html', 13 templateUrl: './search-typeahead.component.html',
13 styleUrls: [ './search-typeahead.component.scss' ] 14 styleUrls: [ './search-typeahead.component.scss' ]
14}) 15})
15export class SearchTypeaheadComponent implements OnInit, OnDestroy { 16export class SearchTypeaheadComponent implements OnInit, AfterViewInit, AfterViewChecked, OnDestroy {
16 @ViewChild('searchVideo', { static: true }) searchInput: ElementRef<HTMLInputElement> 17 @ViewChildren(SuggestionComponent) suggestionItems: QueryList<SuggestionComponent>
17 18
18 hasChannel = false 19 hasChannel = false
19 inChannel = false 20 inChannel = false
20 newSearch = true 21 areSuggestionsOpened = true
21 22
22 search = '' 23 search = ''
23 serverConfig: ServerConfig 24 serverConfig: ServerConfig
@@ -25,7 +26,11 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
25 inThisChannelText: string 26 inThisChannelText: string
26 27
27 keyboardEventsManager: ListKeyManager<SuggestionComponent> 28 keyboardEventsManager: ListKeyManager<SuggestionComponent>
28 results: Result[] = [] 29 results: SuggestionPayload[] = []
30
31 activeSearch: SuggestionPayloadType
32
33 private scheduleKeyboardEventsInit = false
29 34
30 constructor ( 35 constructor (
31 private authService: AuthService, 36 private authService: AuthService,
@@ -38,109 +43,138 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
38 this.route.queryParams 43 this.route.queryParams
39 .pipe(first(params => this.isOnSearch() && params.search !== undefined && params.search !== null)) 44 .pipe(first(params => this.isOnSearch() && params.search !== undefined && params.search !== null))
40 .subscribe(params => this.search = params.search) 45 .subscribe(params => this.search = params.search)
46 }
47
48 ngAfterViewInit () {
41 this.serverService.getConfig() 49 this.serverService.getConfig()
42 .subscribe(config => this.serverConfig = config) 50 .subscribe(config => {
51 this.serverConfig = config
52
53 this.computeTypeahead()
54
55 this.serverService.configReloaded
56 .subscribe(config => {
57 this.serverConfig = config
58 this.computeTypeahead()
59 })
60 })
43 } 61 }
44 62
45 ngOnDestroy () { 63 ngAfterViewChecked () {
46 if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe() 64 if (this.scheduleKeyboardEventsInit && !this.keyboardEventsManager) {
65 // Avoid ExpressionChangedAfterItHasBeenCheckedError errors
66 setTimeout(() => this.initKeyboardEventsManager(), 0)
67 }
47 } 68 }
48 69
49 get activeResult () { 70 ngOnDestroy () {
50 return this.keyboardEventsManager?.activeItem?.result 71 if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
51 } 72 }
52 73
53 get areInstructionsDisplayed () { 74 areInstructionsDisplayed () {
54 return !this.search 75 return !this.search
55 } 76 }
56 77
57 get showHelp () { 78 showSearchGlobalHelp () {
58 return this.search && this.newSearch && this.activeResult?.type === 'search-global' 79 return this.search && this.areSuggestionsOpened && this.keyboardEventsManager?.activeItem?.result?.type === 'search-index'
59 } 80 }
60 81
61 get canSearchAnyURI () { 82 canSearchAnyURI () {
62 if (!this.serverConfig) return false 83 if (!this.serverConfig) return false
84
63 return this.authService.isLoggedIn() 85 return this.authService.isLoggedIn()
64 ? this.serverConfig.search.remoteUri.users 86 ? this.serverConfig.search.remoteUri.users
65 : this.serverConfig.search.remoteUri.anonymous 87 : this.serverConfig.search.remoteUri.anonymous
66 } 88 }
67 89
68 onSearchChange () { 90 onSearchChange () {
69 this.computeResults() 91 this.computeTypeahead()
70 } 92 }
71 93
72 computeResults () { 94 initKeyboardEventsManager () {
73 this.newSearch = true 95 if (this.keyboardEventsManager) return
74 let results: Result[] = [] 96
75 97 this.keyboardEventsManager = new ListKeyManager(this.suggestionItems)
76 if (this.search) { 98
77 results = [ 99 const activeIndex = this.suggestionItems.toArray().findIndex(i => i.result.default === true)
78 /* Channel search is still unimplemented. Uncomment when it is. 100 if (activeIndex === -1) {
79 { 101 console.error('Cannot find active index.', { suggestionItems: this.suggestionItems })
80 text: this.search,
81 type: 'search-channel'
82 },
83 */
84 {
85 text: this.search,
86 type: 'search-instance',
87 default: true
88 },
89 /* Global search is still unimplemented. Uncomment when it is.
90 {
91 text: this.search,
92 type: 'search-global'
93 },
94 */
95 ...results
96 ]
97 } 102 }
98 103
99 this.results = results.filter( 104 this.updateItemsState(activeIndex)
100 (result: Result) => { 105
101 // if we're not in a channel or one of its videos/playlits, show all channel-related results 106 this.keyboardEventsManager.change.subscribe(
102 if (!(this.hasChannel || this.inChannel)) return !result.type.includes('channel') 107 _ => this.updateItemsState()
103 // if we're in a channel, show all channel-related results except for the channel redirection itself
104 if (this.inChannel) return result.type !== 'channel'
105 // all other result types are kept
106 return true
107 }
108 ) 108 )
109 } 109 }
110 110
111 setEventItems (event: { items: QueryList<SuggestionComponent>, index?: number }) { 111 computeTypeahead () {
112 event.items.forEach(e => { 112 const searchIndexConfig = this.serverConfig.search.searchIndex
113 if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === e) { 113
114 this.keyboardEventsManager.activeItem.active = true 114 if (!this.activeSearch) {
115 if (searchIndexConfig.enabled && searchIndexConfig.isDefaultSearch) {
116 this.activeSearch = 'search-instance'
115 } else { 117 } else {
116 e.active = false 118 this.activeSearch = 'search-index'
117 } 119 }
118 }) 120 }
121
122 this.areSuggestionsOpened = true
123 this.results = []
124
125 if (!this.search) return
126
127 if (searchIndexConfig.enabled === false || searchIndexConfig.disableLocalSearch !== true) {
128 this.results.push({
129 text: this.search,
130 type: 'search-instance',
131 default: this.activeSearch === 'search-instance'
132 })
133 }
134
135 if (searchIndexConfig.enabled) {
136 this.results.push({
137 text: this.search,
138 type: 'search-index',
139 default: this.activeSearch === 'search-index'
140 })
141 }
142
143 this.scheduleKeyboardEventsInit = true
119 } 144 }
120 145
121 initKeyboardEventsManager (event: { items: QueryList<SuggestionComponent>, index?: number }) { 146 updateItemsState (index?: number) {
122 if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe() 147 if (index !== undefined) {
148 this.keyboardEventsManager.setActiveItem(index)
149 }
123 150
124 this.keyboardEventsManager = new ListKeyManager(event.items) 151 for (const item of this.suggestionItems) {
152 if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === item) {
153 item.active = true
154 this.activeSearch = item.result.type
155 continue
156 }
125 157
126 if (event.index !== undefined) { 158 item.active = false
127 this.keyboardEventsManager.setActiveItem(event.index)
128 } else {
129 this.keyboardEventsManager.setFirstItemActive()
130 } 159 }
160 }
131 161
132 this.keyboardEventsManager.change.subscribe( 162 onSuggestionlicked (payload: SuggestionPayload) {
133 _ => this.setEventItems(event) 163 this.doSearch(this.buildSearchTarget(payload))
134 ) 164 }
165
166 onSuggestionHover (index: number) {
167 this.updateItemsState(index)
135 } 168 }
136 169
137 handleKey (event: KeyboardEvent) { 170 handleKey (event: KeyboardEvent) {
138 event.stopImmediatePropagation()
139 if (!this.keyboardEventsManager) return 171 if (!this.keyboardEventsManager) return
140 172
141 switch (event.key) { 173 switch (event.key) {
142 case 'ArrowDown': 174 case 'ArrowDown':
143 case 'ArrowUp': 175 case 'ArrowUp':
176 event.stopPropagation()
177
144 this.keyboardEventsManager.onKeydown(event) 178 this.keyboardEventsManager.onKeydown(event)
145 break 179 break
146 } 180 }
@@ -150,15 +184,19 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
150 return window.location.pathname === '/search' 184 return window.location.pathname === '/search'
151 } 185 }
152 186
153 doSearch () { 187 doSearch (searchTarget?: SearchTargetType) {
154 this.newSearch = false 188 this.areSuggestionsOpened = false
155 const queryParams: Params = {} 189 const queryParams: Params = {}
156 190
157 if (this.isOnSearch() && this.route.snapshot.queryParams) { 191 if (this.isOnSearch() && this.route.snapshot.queryParams) {
158 Object.assign(queryParams, this.route.snapshot.queryParams) 192 Object.assign(queryParams, this.route.snapshot.queryParams)
159 } 193 }
160 194
161 Object.assign(queryParams, { search: this.search }) 195 if (!searchTarget) {
196 searchTarget = this.buildSearchTarget(this.keyboardEventsManager.activeItem.result)
197 }
198
199 Object.assign(queryParams, { search: this.search, searchTarget })
162 200
163 const o = this.authService.isLoggedIn() 201 const o = this.authService.isLoggedIn()
164 ? this.loadUserLanguagesIfNeeded(queryParams) 202 ? this.loadUserLanguagesIfNeeded(queryParams)
@@ -176,4 +214,12 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
176 tap(() => Object.assign(queryParams, { languageOneOf: this.authService.getUser().videoLanguages })) 214 tap(() => Object.assign(queryParams, { languageOneOf: this.authService.getUser().videoLanguages }))
177 ) 215 )
178 } 216 }
217
218 private buildSearchTarget (result: SuggestionPayload): SearchTargetType {
219 if (result.type === 'search-index') {
220 return 'search-index'
221 }
222
223 return 'local'
224 }
179} 225}
diff --git a/client/src/app/header/suggestion.component.html b/client/src/app/header/suggestion.component.html
index d7ae3450a..ab4b4b678 100644
--- a/client/src/app/header/suggestion.component.html
+++ b/client/src/app/header/suggestion.component.html
@@ -1,22 +1,17 @@
1<a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active"> 1<a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active">
2 <div class="flex-shrink-0 mr-2 text-center"> 2 <div class="flex-shrink-0 mr-2 text-center">
3 <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon> 3 <my-global-icon iconName="search"></my-global-icon>
4 <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon>
5 </div> 4 </div>
6 5
7 <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28"> 6 <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28">
8 7
9 <div class="flex-auto overflow-hidden text-left no-wrap css-truncate css-truncate-target" [attr.aria-label]="result.text" [innerHTML]="result.text | highlight : highlight"></div> 8 <div
9 class="flex-auto overflow-hidden text-left no-wrap css-truncate css-truncate-target"
10 [attr.aria-label]="result.text" [innerHTML]="result.text | highlight : highlight"
11 ></div>
10 12
11 <div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6"> 13 <div class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6">
12 <span *ngIf="result.type === 'search-channel'" i18n>In this channel</span>
13 <span *ngIf="result.type === 'search-instance'" i18n>In this instance</span> 14 <span *ngIf="result.type === 'search-instance'" i18n>In this instance</span>
14 <span *ngIf="result.type === 'search-global'" i18n>In the vidiverse</span> 15 <span *ngIf="result.type === 'search-index'" i18n>In the vidiverse</span>
15 <span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span>
16 </div> 16 </div>
17 17</a>
18 <div *ngIf="result.type === 'channel'" aria-hidden="true" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6 d-on-nav-focus" i18n>
19 Jump to channel
20 <span class="d-inline-block ml-1 v-align-middle">↵</span>
21 </div>
22</a> \ No newline at end of file
diff --git a/client/src/app/header/suggestion.component.ts b/client/src/app/header/suggestion.component.ts
index 69641b511..250a5411e 100644
--- a/client/src/app/header/suggestion.component.ts
+++ b/client/src/app/header/suggestion.component.ts
@@ -1,24 +1,24 @@
1import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy } from '@angular/core' 1import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy, OnChanges } from '@angular/core'
2import { RouterLink } from '@angular/router' 2import { RouterLink } from '@angular/router'
3import { ListKeyManagerOption } from '@angular/cdk/a11y' 3import { ListKeyManagerOption } from '@angular/cdk/a11y'
4 4
5export type Result = { 5export type SuggestionPayload = {
6 text: string 6 text: string
7 type: 'channel' | 'suggestion' | 'search-channel' | 'search-instance' | 'search-global' | 'search-any' 7 type: SuggestionPayloadType
8 routerLink?: RouterLink, 8 routerLink?: RouterLink
9 default?: boolean 9 default: boolean
10} 10}
11 11
12export type SuggestionPayloadType = 'search-instance' | 'search-index'
13
12@Component({ 14@Component({
13 selector: 'my-suggestion', 15 selector: 'my-suggestion',
14 templateUrl: './suggestion.component.html', 16 templateUrl: './suggestion.component.html',
15 styleUrls: [ './suggestion.component.scss' ], 17 styleUrls: [ './suggestion.component.scss' ]
16 changeDetection: ChangeDetectionStrategy.OnPush
17}) 18})
18export class SuggestionComponent implements OnInit, ListKeyManagerOption { 19export class SuggestionComponent implements OnInit, ListKeyManagerOption {
19 @Input() result: Result 20 @Input() result: SuggestionPayload
20 @Input() highlight: string 21 @Input() highlight: string
21 @Output() selected = new EventEmitter()
22 22
23 disabled = false 23 disabled = false
24 active = false 24 active = false
@@ -30,8 +30,4 @@ export class SuggestionComponent implements OnInit, ListKeyManagerOption {
30 ngOnInit () { 30 ngOnInit () {
31 if (this.result.default) this.active = true 31 if (this.result.default) this.active = true
32 } 32 }
33
34 selectItem () {
35 this.selected.emit(this.result)
36 }
37} 33}
diff --git a/client/src/app/header/suggestions.component.html b/client/src/app/header/suggestions.component.html
deleted file mode 100644
index 8d017d78d..000000000
--- a/client/src/app/header/suggestions.component.html
+++ /dev/null
@@ -1,6 +0,0 @@
1<ul role="listbox" class="p-0 m-0">
2 <li *ngFor="let result of results; let i = index" class="d-flex flex-justify-start flex-items-center p-0 f5"
3 role="option" aria-selected="true" (mouseenter)="hoverItem(i)">
4 <my-suggestion [result]="result" [highlight]="highlight"></my-suggestion>
5 </li>
6</ul> \ No newline at end of file
diff --git a/client/src/app/header/suggestions.component.ts b/client/src/app/header/suggestions.component.ts
deleted file mode 100644
index ee3ef73c2..000000000
--- a/client/src/app/header/suggestions.component.ts
+++ /dev/null
@@ -1,24 +0,0 @@
1import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren, ChangeDetectionStrategy } from '@angular/core'
2import { SuggestionComponent } from './suggestion.component'
3
4@Component({
5 selector: 'my-suggestions',
6 templateUrl: './suggestions.component.html',
7 changeDetection: ChangeDetectionStrategy.OnPush
8})
9export class SuggestionsComponent implements AfterViewInit {
10 @Input() results: any[]
11 @Input() highlight: string
12 @ViewChildren(SuggestionComponent) listItems: QueryList<SuggestionComponent>
13 @Output() init = new EventEmitter()
14
15 ngAfterViewInit () {
16 this.listItems.changes.subscribe(
17 _ => this.init.emit({ items: this.listItems })
18 )
19 }
20
21 hoverItem (index: number) {
22 this.init.emit({ items: this.listItems, index: index })
23 }
24}
diff --git a/client/src/app/search/advanced-search.model.ts b/client/src/app/search/advanced-search.model.ts
index 50f00bc27..643cc9a29 100644
--- a/client/src/app/search/advanced-search.model.ts
+++ b/client/src/app/search/advanced-search.model.ts
@@ -1,3 +1,4 @@
1import { SearchTargetType } from '@shared/models/search/search-target-query.model'
1import { NSFWQuery } from '../../../../shared/models/search' 2import { NSFWQuery } from '../../../../shared/models/search'
2 3
3export class AdvancedSearch { 4export class AdvancedSearch {
@@ -23,6 +24,11 @@ export class AdvancedSearch {
23 24
24 sort: string 25 sort: string
25 26
27 searchTarget: SearchTargetType
28
29 // Filters we don't want to count, because they are mandatory
30 private silentFilters = new Set([ 'sort', 'searchTarget' ])
31
26 constructor (options?: { 32 constructor (options?: {
27 startDate?: string 33 startDate?: string
28 endDate?: string 34 endDate?: string
@@ -37,6 +43,7 @@ export class AdvancedSearch {
37 durationMin?: string 43 durationMin?: string
38 durationMax?: string 44 durationMax?: string
39 sort?: string 45 sort?: string
46 searchTarget?: SearchTargetType
40 }) { 47 }) {
41 if (!options) return 48 if (!options) return
42 49
@@ -54,6 +61,8 @@ export class AdvancedSearch {
54 this.durationMin = parseInt(options.durationMin, 10) 61 this.durationMin = parseInt(options.durationMin, 10)
55 this.durationMax = parseInt(options.durationMax, 10) 62 this.durationMax = parseInt(options.durationMax, 10)
56 63
64 this.searchTarget = options.searchTarget || undefined
65
57 if (isNaN(this.durationMin)) this.durationMin = undefined 66 if (isNaN(this.durationMin)) this.durationMin = undefined
58 if (isNaN(this.durationMax)) this.durationMax = undefined 67 if (isNaN(this.durationMax)) this.durationMax = undefined
59 68
@@ -61,9 +70,11 @@ export class AdvancedSearch {
61 } 70 }
62 71
63 containsValues () { 72 containsValues () {
73 const exceptions = new Set([ 'sort', 'searchTarget' ])
74
64 const obj = this.toUrlObject() 75 const obj = this.toUrlObject()
65 for (const k of Object.keys(obj)) { 76 for (const k of Object.keys(obj)) {
66 if (k === 'sort') continue // Exception 77 if (this.silentFilters.has(k)) continue
67 78
68 if (obj[k] !== undefined && obj[k] !== '') return true 79 if (obj[k] !== undefined && obj[k] !== '') return true
69 } 80 }
@@ -102,7 +113,8 @@ export class AdvancedSearch {
102 tagsAllOf: this.tagsAllOf, 113 tagsAllOf: this.tagsAllOf,
103 durationMin: this.durationMin, 114 durationMin: this.durationMin,
104 durationMax: this.durationMax, 115 durationMax: this.durationMax,
105 sort: this.sort 116 sort: this.sort,
117 searchTarget: this.searchTarget
106 } 118 }
107 } 119 }
108 120
@@ -120,7 +132,8 @@ export class AdvancedSearch {
120 tagsAllOf: this.intoArray(this.tagsAllOf), 132 tagsAllOf: this.intoArray(this.tagsAllOf),
121 durationMin: this.durationMin, 133 durationMin: this.durationMin,
122 durationMax: this.durationMax, 134 durationMax: this.durationMax,
123 sort: this.sort 135 sort: this.sort,
136 searchTarget: this.searchTarget
124 } 137 }
125 } 138 }
126 139
@@ -129,7 +142,7 @@ export class AdvancedSearch {
129 142
130 const obj = this.toUrlObject() 143 const obj = this.toUrlObject()
131 for (const k of Object.keys(obj)) { 144 for (const k of Object.keys(obj)) {
132 if (k === 'sort') continue // Exception 145 if (this.silentFilters.has(k)) continue
133 146
134 if (obj[k] !== undefined && obj[k] !== '') acc++ 147 if (obj[k] !== undefined && obj[k] !== '') acc++
135 } 148 }
diff --git a/client/src/app/search/channel-lazy-load.resolver.ts b/client/src/app/search/channel-lazy-load.resolver.ts
new file mode 100644
index 000000000..8be089cdd
--- /dev/null
+++ b/client/src/app/search/channel-lazy-load.resolver.ts
@@ -0,0 +1,45 @@
1import { map } from 'rxjs/operators'
2import { Injectable } from '@angular/core'
3import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
4import { SearchService } from './search.service'
5import { RedirectService } from '@app/core'
6
7@Injectable()
8export class ChannelLazyLoadResolver implements Resolve<any> {
9 constructor (
10 private router: Router,
11 private searchService: SearchService,
12 private redirectService: RedirectService
13 ) { }
14
15 resolve (route: ActivatedRouteSnapshot) {
16 const url = route.params.url
17 const externalRedirect = route.params.externalRedirect
18 const fromPath = route.params.fromPath
19
20 if (!url) {
21 console.error('Could not find url param.', { params: route.params })
22 return this.router.navigateByUrl('/404')
23 }
24
25 if (externalRedirect === 'true') {
26 window.open(url)
27 this.router.navigateByUrl(fromPath)
28 return
29 }
30
31 return this.searchService.searchVideoChannels({ search: url })
32 .pipe(
33 map(result => {
34 if (result.data.length !== 1) {
35 console.error('Cannot find result for this URL')
36 return this.router.navigateByUrl('/404')
37 }
38
39 const channel = result.data[0]
40
41 return this.router.navigateByUrl('/video-channels/' + channel.nameWithHost)
42 })
43 )
44 }
45}
diff --git a/client/src/app/search/search-filters.component.html b/client/src/app/search/search-filters.component.html
index 54fc7338f..e20aef8fb 100644
--- a/client/src/app/search/search-filters.component.html
+++ b/client/src/app/search/search-filters.component.html
@@ -18,6 +18,25 @@
18 18
19 <div class="form-group"> 19 <div class="form-group">
20 <div class="radio-label label-container"> 20 <div class="radio-label label-container">
21 <label i18n>Display sensitive content</label>
22 <button i18n class="reset-button reset-button-small" (click)="resetField('nsfw')" *ngIf="advancedSearch.nsfw !== undefined">
23 Reset
24 </button>
25 </div>
26
27 <div class="peertube-radio-container">
28 <input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch.nsfw">
29 <label i18n for="sensitiveContentYes" class="radio">Yes</label>
30 </div>
31
32 <div class="peertube-radio-container">
33 <input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch.nsfw">
34 <label i18n for="sensitiveContentNo" class="radio">No</label>
35 </div>
36 </div>
37
38 <div class="form-group">
39 <div class="radio-label label-container">
21 <label i18n>Published date</label> 40 <label i18n>Published date</label>
22 <button i18n class="reset-button reset-button-small" (click)="resetLocalField('publishedDateRange')" *ngIf="publishedDateRange !== undefined"> 41 <button i18n class="reset-button reset-button-small" (click)="resetLocalField('publishedDateRange')" *ngIf="publishedDateRange !== undefined">
23 Reset 42 Reset
@@ -39,7 +58,7 @@
39 </div> 58 </div>
40 59
41 <div class="row"> 60 <div class="row">
42 <div class="col-sm-6"> 61 <div class="pl-0 col-sm-6">
43 <input 62 <input
44 (change)="inputUpdated()" 63 (change)="inputUpdated()"
45 (keydown.enter)="$event.preventDefault()" 64 (keydown.enter)="$event.preventDefault()"
@@ -49,7 +68,7 @@
49 class="form-control" 68 class="form-control"
50 > 69 >
51 </div> 70 </div>
52 <div class="col-sm-6"> 71 <div class="pr-0 col-sm-6">
53 <input 72 <input
54 (change)="inputUpdated()" 73 (change)="inputUpdated()"
55 (keydown.enter)="$event.preventDefault()" 74 (keydown.enter)="$event.preventDefault()"
@@ -62,6 +81,9 @@
62 </div> 81 </div>
63 </div> 82 </div>
64 83
84 </div>
85
86 <div class="col-lg-4 col-md-6 col-xs-12">
65 <div class="form-group"> 87 <div class="form-group">
66 <div class="radio-label label-container"> 88 <div class="radio-label label-container">
67 <label i18n>Duration</label> 89 <label i18n>Duration</label>
@@ -77,28 +99,6 @@
77 </div> 99 </div>
78 100
79 <div class="form-group"> 101 <div class="form-group">
80 <div class="radio-label label-container">
81 <label i18n>Display sensitive content</label>
82 <button i18n class="reset-button reset-button-small" (click)="resetField('nsfw')" *ngIf="advancedSearch.nsfw !== undefined">
83 Reset
84 </button>
85 </div>
86
87 <div class="peertube-radio-container">
88 <input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch.nsfw">
89 <label i18n for="sensitiveContentYes" class="radio">Yes</label>
90 </div>
91
92 <div class="peertube-radio-container">
93 <input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch.nsfw">
94 <label i18n for="sensitiveContentNo" class="radio">No</label>
95 </div>
96 </div>
97
98 </div>
99
100 <div class="col-lg-4 col-md-6 col-xs-12">
101 <div class="form-group">
102 <label i18n for="category">Category</label> 102 <label i18n for="category">Category</label>
103 <button i18n class="reset-button reset-button-small" (click)="resetField('categoryOneOf')" *ngIf="advancedSearch.categoryOneOf !== undefined"> 103 <button i18n class="reset-button reset-button-small" (click)="resetField('categoryOneOf')" *ngIf="advancedSearch.categoryOneOf !== undefined">
104 Reset 104 Reset
@@ -164,6 +164,22 @@
164 [maxItems]="5" [modelAsStrings]="true" 164 [maxItems]="5" [modelAsStrings]="true"
165 ></tag-input> 165 ></tag-input>
166 </div> 166 </div>
167
168 <div class="form-group" *ngIf="isSearchTargetEnabled()">
169 <div class="radio-label label-container">
170 <label i18n>Search target</label>
171 </div>
172
173 <div class="peertube-radio-container">
174 <input type="radio" name="searchTarget" id="searchTargetLocal" value="local" [(ngModel)]="advancedSearch.searchTarget">
175 <label i18n for="searchTargetLocal" class="radio">Instance</label>
176 </div>
177
178 <div class="peertube-radio-container">
179 <input type="radio" name="searchTarget" id="searchTargetSearchIndex" value="search-index" [(ngModel)]="advancedSearch.searchTarget">
180 <label i18n for="searchTargetSearchIndex" class="radio">Vidiverse</label>
181 </div>
182 </div>
167 </div> 183 </div>
168 </div> 184 </div>
169 185
diff --git a/client/src/app/search/search-filters.component.ts b/client/src/app/search/search-filters.component.ts
index 344a260df..af76260a7 100644
--- a/client/src/app/search/search-filters.component.ts
+++ b/client/src/app/search/search-filters.component.ts
@@ -44,7 +44,7 @@ export class SearchFiltersComponent implements OnInit {
44 this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES 44 this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES
45 this.publishedDateRanges = [ 45 this.publishedDateRanges = [
46 { 46 {
47 id: undefined, 47 id: 'any_published_date',
48 label: this.i18n('Any') 48 label: this.i18n('Any')
49 }, 49 },
50 { 50 {
@@ -67,7 +67,7 @@ export class SearchFiltersComponent implements OnInit {
67 67
68 this.durationRanges = [ 68 this.durationRanges = [
69 { 69 {
70 id: undefined, 70 id: 'any_duration',
71 label: this.i18n('Any') 71 label: this.i18n('Any')
72 }, 72 },
73 { 73 {
@@ -147,6 +147,10 @@ export class SearchFiltersComponent implements OnInit {
147 this.originallyPublishedStartYear = this.originallyPublishedEndYear = undefined 147 this.originallyPublishedStartYear = this.originallyPublishedEndYear = undefined
148 } 148 }
149 149
150 isSearchTargetEnabled () {
151 return this.serverConfig.search.searchIndex.enabled && this.serverConfig.search.searchIndex.disableLocalSearch !== true
152 }
153
150 private loadOriginallyPublishedAtYears () { 154 private loadOriginallyPublishedAtYears () {
151 this.originallyPublishedStartYear = this.advancedSearch.originallyPublishedStartDate 155 this.originallyPublishedStartYear = this.advancedSearch.originallyPublishedStartDate
152 ? new Date(this.advancedSearch.originallyPublishedStartDate).getFullYear().toString() 156 ? new Date(this.advancedSearch.originallyPublishedStartDate).getFullYear().toString()
diff --git a/client/src/app/search/search-routing.module.ts b/client/src/app/search/search-routing.module.ts
index 0ac9e6b57..9da900e9a 100644
--- a/client/src/app/search/search-routing.module.ts
+++ b/client/src/app/search/search-routing.module.ts
@@ -1,7 +1,9 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouterModule, Routes } from '@angular/router'
3import { MetaGuard } from '@ngx-meta/core'
4import { SearchComponent } from '@app/search/search.component' 3import { SearchComponent } from '@app/search/search.component'
4import { MetaGuard } from '@ngx-meta/core'
5import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
6import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
5 7
6const searchRoutes: Routes = [ 8const searchRoutes: Routes = [
7 { 9 {
@@ -13,6 +15,22 @@ const searchRoutes: Routes = [
13 title: 'Search' 15 title: 'Search'
14 } 16 }
15 } 17 }
18 },
19 {
20 path: 'search/lazy-load-video',
21 component: SearchComponent,
22 canActivate: [ MetaGuard ],
23 resolve: {
24 data: VideoLazyLoadResolver
25 }
26 },
27 {
28 path: 'search/lazy-load-channel',
29 component: SearchComponent,
30 canActivate: [ MetaGuard ],
31 resolve: {
32 data: ChannelLazyLoadResolver
33 }
16 } 34 }
17] 35]
18 36
diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html
index a4a1d41b3..3cafc676d 100644
--- a/client/src/app/search/search.component.html
+++ b/client/src/app/search/search.component.html
@@ -2,7 +2,11 @@
2 <div class="results-header"> 2 <div class="results-header">
3 <div class="first-line"> 3 <div class="first-line">
4 <div class="results-counter" *ngIf="pagination.totalItems"> 4 <div class="results-counter" *ngIf="pagination.totalItems">
5 <span i18n>{{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}}</span> 5 <span i18n>{{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}} </span>
6
7 <span i18n *ngIf="advancedSearch.searchTarget === 'local'">on this instance</span>
8 <span i18n *ngIf="advancedSearch.searchTarget === 'search-index'">on the vidiverse</span>
9
6 <span *ngIf="currentSearch" i18n> 10 <span *ngIf="currentSearch" i18n>
7 for <span class="search-value">{{ currentSearch }}</span> 11 for <span class="search-value">{{ currentSearch }}</span>
8 </span> 12 </span>
@@ -31,12 +35,12 @@
31 35
32 <ng-container *ngFor="let result of results"> 36 <ng-container *ngFor="let result of results">
33 <div *ngIf="isVideoChannel(result)" class="entry video-channel"> 37 <div *ngIf="isVideoChannel(result)" class="entry video-channel">
34 <a [routerLink]="[ '/video-channels', result.nameWithHost ]"> 38 <a [routerLink]="getChannelUrl(result)">
35 <img [src]="result.avatarUrl" alt="Avatar" /> 39 <img [src]="result.avatarUrl" alt="Avatar" />
36 </a> 40 </a>
37 41
38 <div class="video-channel-info"> 42 <div class="video-channel-info">
39 <a [routerLink]="[ '/video-channels', result.nameWithHost ]" class="video-channel-names"> 43 <a [routerLink]="getChannelUrl(result)" class="video-channel-names">
40 <div class="video-channel-display-name">{{ result.displayName }}</div> 44 <div class="video-channel-display-name">{{ result.displayName }}</div>
41 <div class="video-channel-name">{{ result.nameWithHost }}</div> 45 <div class="video-channel-name">{{ result.nameWithHost }}</div>
42 </a> 46 </a>
@@ -44,12 +48,13 @@
44 <div i18n class="video-channel-followers">{{ result.followersCount }} subscribers</div> 48 <div i18n class="video-channel-followers">{{ result.followersCount }} subscribers</div>
45 </div> 49 </div>
46 50
47 <my-subscribe-button [videoChannels]="[result]"></my-subscribe-button> 51 <my-subscribe-button *ngIf="!hideActions()" [videoChannels]="[result]"></my-subscribe-button>
48 </div> 52 </div>
49 53
50 <div *ngIf="isVideo(result)" class="entry video"> 54 <div *ngIf="isVideo(result)" class="entry video">
51 <my-video-miniature 55 <my-video-miniature
52 [video]="result" [user]="user" [displayAsRow]="true" 56 [video]="result" [user]="user" [displayAsRow]="true" [displayVideoActions]="!hideActions()"
57 [useLazyLoadUrl]="advancedSearch.searchTarget === 'search-index'"
53 (videoBlacklisted)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)" 58 (videoBlacklisted)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)"
54 ></my-video-miniature> 59 ></my-video-miniature>
55 </div> 60 </div>
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts
index 075994dd3..d3c0761d7 100644
--- a/client/src/app/search/search.component.ts
+++ b/client/src/app/search/search.component.ts
@@ -1,16 +1,18 @@
1import { forkJoin, of, Subscription } from 'rxjs'
1import { Component, OnDestroy, OnInit } from '@angular/core' 2import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
3import { AuthService, Notifier } from '@app/core' 4import { AuthService, Notifier, ServerService } from '@app/core'
4import { forkJoin, of, Subscription } from 'rxjs' 5import { HooksService } from '@app/core/plugins/hooks.service'
6import { AdvancedSearch } from '@app/search/advanced-search.model'
5import { SearchService } from '@app/search/search.service' 7import { SearchService } from '@app/search/search.service'
8import { immutableAssign } from '@app/shared/misc/utils'
6import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 9import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { MetaService } from '@ngx-meta/core'
9import { AdvancedSearch } from '@app/search/advanced-search.model'
10import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 10import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
11import { immutableAssign } from '@app/shared/misc/utils'
12import { Video } from '@app/shared/video/video.model' 11import { Video } from '@app/shared/video/video.model'
13import { HooksService } from '@app/core/plugins/hooks.service' 12import { MetaService } from '@ngx-meta/core'
13import { I18n } from '@ngx-translate/i18n-polyfill'
14import { ServerConfig } from '@shared/models'
15import { UserService } from '@app/shared'
14 16
15@Component({ 17@Component({
16 selector: 'my-search', 18 selector: 'my-search',
@@ -29,6 +31,9 @@ export class SearchComponent implements OnInit, OnDestroy {
29 isSearchFilterCollapsed = true 31 isSearchFilterCollapsed = true
30 currentSearch: string 32 currentSearch: string
31 33
34 errorMessage: string
35 serverConfig: ServerConfig
36
32 private subActivatedRoute: Subscription 37 private subActivatedRoute: Subscription
33 private isInitialLoad = false // set to false to show the search filters on first arrival 38 private isInitialLoad = false // set to false to show the search filters on first arrival
34 private firstSearch = true 39 private firstSearch = true
@@ -43,7 +48,8 @@ export class SearchComponent implements OnInit, OnDestroy {
43 private notifier: Notifier, 48 private notifier: Notifier,
44 private searchService: SearchService, 49 private searchService: SearchService,
45 private authService: AuthService, 50 private authService: AuthService,
46 private hooks: HooksService 51 private hooks: HooksService,
52 private serverService: ServerService
47 ) { } 53 ) { }
48 54
49 get user () { 55 get user () {
@@ -51,8 +57,11 @@ export class SearchComponent implements OnInit, OnDestroy {
51 } 57 }
52 58
53 ngOnInit () { 59 ngOnInit () {
60 this.serverService.getConfig()
61 .subscribe(config => this.serverConfig = config)
62
54 this.subActivatedRoute = this.route.queryParams.subscribe( 63 this.subActivatedRoute = this.route.queryParams.subscribe(
55 queryParams => { 64 async queryParams => {
56 const querySearch = queryParams['search'] 65 const querySearch = queryParams['search']
57 66
58 // Search updated, reset filters 67 // Search updated, reset filters
@@ -65,6 +74,9 @@ export class SearchComponent implements OnInit, OnDestroy {
65 } 74 }
66 75
67 this.advancedSearch = new AdvancedSearch(queryParams) 76 this.advancedSearch = new AdvancedSearch(queryParams)
77 if (!this.advancedSearch.searchTarget) {
78 this.advancedSearch.searchTarget = await this.serverService.getDefaultSearchTarget()
79 }
68 80
69 // Don't hide filters if we have some of them AND the user just came on the webpage 81 // Don't hide filters if we have some of them AND the user just came on the webpage
70 this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues() 82 this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues()
@@ -99,28 +111,37 @@ export class SearchComponent implements OnInit, OnDestroy {
99 forkJoin([ 111 forkJoin([
100 this.getVideosObs(), 112 this.getVideosObs(),
101 this.getVideoChannelObs() 113 this.getVideoChannelObs()
102 ]) 114 ]).subscribe(
103 .subscribe( 115 ([videosResult, videoChannelsResult]) => {
104 ([ videosResult, videoChannelsResult ]) => { 116 this.results = this.results
105 this.results = this.results 117 .concat(videoChannelsResult.data)
106 .concat(videoChannelsResult.data) 118 .concat(videosResult.data)
107 .concat(videosResult.data) 119
108 this.pagination.totalItems = videosResult.total + videoChannelsResult.total 120 this.pagination.totalItems = videosResult.total + videoChannelsResult.total
109
110 // Focus on channels if there are no enough videos
111 if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
112 this.resetPagination()
113 this.firstSearch = false
114
115 this.channelsPerPage = 10
116 this.search()
117 }
118 121
122 // Focus on channels if there are no enough videos
123 if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
124 this.resetPagination()
119 this.firstSearch = false 125 this.firstSearch = false
120 },
121 126
122 err => this.notifier.error(err.message) 127 this.channelsPerPage = 10
123 ) 128 this.search()
129 }
130
131 this.firstSearch = false
132 },
133
134 err => {
135 if (this.advancedSearch.searchTarget !== 'search-index') this.notifier.error(err.message)
136
137 this.notifier.error(
138 this.i18n('Search index is unavailable. Retrying with instance results instead.'),
139 this.i18n('Search error')
140 )
141 this.advancedSearch.searchTarget = 'local'
142 this.search()
143 }
144 )
124 } 145 }
125 146
126 onNearOfBottom () { 147 onNearOfBottom () {
@@ -146,6 +167,24 @@ export class SearchComponent implements OnInit, OnDestroy {
146 this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id) 167 this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
147 } 168 }
148 169
170 getChannelUrl (channel: VideoChannel) {
171 if (this.advancedSearch.searchTarget === 'search-index' && channel.url) {
172 const remoteUriConfig = this.serverConfig.search.remoteUri
173
174 // Redirect on the external instance if not allowed to fetch remote data
175 const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
176 const fromPath = window.location.pathname + window.location.search
177
178 return [ '/search/lazy-load-channel', { url: channel.url, externalRedirect, fromPath } ]
179 }
180
181 return [ '/video-channels', channel.nameWithHost ]
182 }
183
184 hideActions () {
185 return this.advancedSearch.searchTarget === 'search-index'
186 }
187
149 private resetPagination () { 188 private resetPagination () {
150 this.pagination.currentPage = 1 189 this.pagination.currentPage = 1
151 this.pagination.totalItems = null 190 this.pagination.totalItems = null
@@ -189,7 +228,8 @@ export class SearchComponent implements OnInit, OnDestroy {
189 228
190 const params = { 229 const params = {
191 search: this.currentSearch, 230 search: this.currentSearch,
192 componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }) 231 componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }),
232 searchTarget: this.advancedSearch.searchTarget
193 } 233 }
194 234
195 return this.hooks.wrapObsFun( 235 return this.hooks.wrapObsFun(
diff --git a/client/src/app/search/search.module.ts b/client/src/app/search/search.module.ts
index 3b0fd6ee2..df5459802 100644
--- a/client/src/app/search/search.module.ts
+++ b/client/src/app/search/search.module.ts
@@ -1,10 +1,12 @@
1import { NgModule } from '@angular/core'
2import { TagInputModule } from 'ngx-chips' 1import { TagInputModule } from 'ngx-chips'
3import { SharedModule } from '../shared' 2import { NgModule } from '@angular/core'
3import { SearchFiltersComponent } from '@app/search/search-filters.component'
4import { SearchRoutingModule } from '@app/search/search-routing.module'
4import { SearchComponent } from '@app/search/search.component' 5import { SearchComponent } from '@app/search/search.component'
5import { SearchService } from '@app/search/search.service' 6import { SearchService } from '@app/search/search.service'
6import { SearchRoutingModule } from '@app/search/search-routing.module' 7import { SharedModule } from '../shared'
7import { SearchFiltersComponent } from '@app/search/search-filters.component' 8import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
9import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
8 10
9@NgModule({ 11@NgModule({
10 imports: [ 12 imports: [
@@ -25,7 +27,9 @@ import { SearchFiltersComponent } from '@app/search/search-filters.component'
25 ], 27 ],
26 28
27 providers: [ 29 providers: [
28 SearchService 30 SearchService,
31 VideoLazyLoadResolver,
32 ChannelLazyLoadResolver
29 ] 33 ]
30}) 34})
31export class SearchModule { } 35export class SearchModule { }
diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts
index 3cad5aaa7..fdb12ea2c 100644
--- a/client/src/app/search/search.service.ts
+++ b/client/src/app/search/search.service.ts
@@ -1,17 +1,18 @@
1import { Observable } from 'rxjs'
1import { catchError, map, switchMap } from 'rxjs/operators' 2import { catchError, map, switchMap } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http' 3import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
4import { Observable } from 'rxjs'
5import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
6import { VideoService } from '@app/shared/video/video.service'
7import { RestExtractor, RestService } from '@app/shared'
8import { environment } from '../../environments/environment'
9import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared'
10import { Video } from '@app/shared/video/video.model'
11import { AdvancedSearch } from '@app/search/advanced-search.model' 5import { AdvancedSearch } from '@app/search/advanced-search.model'
6import { RestExtractor, RestPagination, RestService } from '@app/shared'
7import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
8import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
12import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 9import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
13import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 10import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
14import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' 11import { Video } from '@app/shared/video/video.model'
12import { VideoService } from '@app/shared/video/video.service'
13import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared'
14import { environment } from '../../environments/environment'
15import { SearchTargetType } from '@shared/models/search/search-target-query.model'
15 16
16@Injectable() 17@Injectable()
17export class SearchService { 18export class SearchService {
@@ -30,21 +31,27 @@ export class SearchService {
30 31
31 searchVideos (parameters: { 32 searchVideos (parameters: {
32 search: string, 33 search: string,
33 componentPagination: ComponentPaginationLight, 34 componentPagination?: ComponentPaginationLight,
34 advancedSearch: AdvancedSearch 35 advancedSearch?: AdvancedSearch
35 }): Observable<ResultList<Video>> { 36 }): Observable<ResultList<Video>> {
36 const { search, componentPagination, advancedSearch } = parameters 37 const { search, componentPagination, advancedSearch } = parameters
37 38
38 const url = SearchService.BASE_SEARCH_URL + 'videos' 39 const url = SearchService.BASE_SEARCH_URL + 'videos'
39 const pagination = this.restService.componentPaginationToRestPagination(componentPagination) 40 let pagination: RestPagination
41
42 if (componentPagination) {
43 pagination = this.restService.componentPaginationToRestPagination(componentPagination)
44 }
40 45
41 let params = new HttpParams() 46 let params = new HttpParams()
42 params = this.restService.addRestGetParams(params, pagination) 47 params = this.restService.addRestGetParams(params, pagination)
43 48
44 if (search) params = params.append('search', search) 49 if (search) params = params.append('search', search)
45 50
46 const advancedSearchObject = advancedSearch.toAPIObject() 51 if (advancedSearch) {
47 params = this.restService.addObjectParams(params, advancedSearchObject) 52 const advancedSearchObject = advancedSearch.toAPIObject()
53 params = this.restService.addObjectParams(params, advancedSearchObject)
54 }
48 55
49 return this.authHttp 56 return this.authHttp
50 .get<ResultList<VideoServerModel>>(url, { params }) 57 .get<ResultList<VideoServerModel>>(url, { params })
@@ -56,17 +63,26 @@ export class SearchService {
56 63
57 searchVideoChannels (parameters: { 64 searchVideoChannels (parameters: {
58 search: string, 65 search: string,
59 componentPagination: ComponentPaginationLight 66 searchTarget?: SearchTargetType,
67 componentPagination?: ComponentPaginationLight
60 }): Observable<ResultList<VideoChannel>> { 68 }): Observable<ResultList<VideoChannel>> {
61 const { search, componentPagination } = parameters 69 const { search, componentPagination, searchTarget } = parameters
62 70
63 const url = SearchService.BASE_SEARCH_URL + 'video-channels' 71 const url = SearchService.BASE_SEARCH_URL + 'video-channels'
64 const pagination = this.restService.componentPaginationToRestPagination(componentPagination) 72
73 let pagination: RestPagination
74 if (componentPagination) {
75 pagination = this.restService.componentPaginationToRestPagination(componentPagination)
76 }
65 77
66 let params = new HttpParams() 78 let params = new HttpParams()
67 params = this.restService.addRestGetParams(params, pagination) 79 params = this.restService.addRestGetParams(params, pagination)
68 params = params.append('search', search) 80 params = params.append('search', search)
69 81
82 if (searchTarget) {
83 params = params.append('searchTarget', searchTarget as string)
84 }
85
70 return this.authHttp 86 return this.authHttp
71 .get<ResultList<VideoChannelServerModel>>(url, { params }) 87 .get<ResultList<VideoChannelServerModel>>(url, { params })
72 .pipe( 88 .pipe(
diff --git a/client/src/app/search/video-lazy-load.resolver.ts b/client/src/app/search/video-lazy-load.resolver.ts
new file mode 100644
index 000000000..8d846d367
--- /dev/null
+++ b/client/src/app/search/video-lazy-load.resolver.ts
@@ -0,0 +1,43 @@
1import { map } from 'rxjs/operators'
2import { Injectable } from '@angular/core'
3import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
4import { SearchService } from './search.service'
5
6@Injectable()
7export class VideoLazyLoadResolver implements Resolve<any> {
8 constructor (
9 private router: Router,
10 private searchService: SearchService
11 ) { }
12
13 resolve (route: ActivatedRouteSnapshot) {
14 const url = route.params.url
15 const externalRedirect = route.params.externalRedirect
16 const fromPath = route.params.fromPath
17
18 if (!url) {
19 console.error('Could not find url param.', { params: route.params })
20 return this.router.navigateByUrl('/404')
21 }
22
23 if (externalRedirect === 'true') {
24 window.open(url)
25 this.router.navigateByUrl(fromPath)
26 return
27 }
28
29 return this.searchService.searchVideos({ search: url })
30 .pipe(
31 map(result => {
32 if (result.data.length !== 1) {
33 console.error('Cannot find result for this URL')
34 return this.router.navigateByUrl('/404')
35 }
36
37 const video = result.data[0]
38
39 return this.router.navigateByUrl('/videos/watch/' + video.uuid)
40 })
41 )
42 }
43}
diff --git a/client/src/app/shared/actor/actor.model.ts b/client/src/app/shared/actor/actor.model.ts
index 0e5060f67..a78303a2f 100644
--- a/client/src/app/shared/actor/actor.model.ts
+++ b/client/src/app/shared/actor/actor.model.ts
@@ -15,10 +15,14 @@ export abstract class Actor implements ActorServer {
15 15
16 avatarUrl: string 16 avatarUrl: string
17 17
18 static GET_ACTOR_AVATAR_URL (actor: { avatar?: { path: string } }) { 18 static GET_ACTOR_AVATAR_URL (actor: { avatar?: Avatar }) {
19 const absoluteAPIUrl = getAbsoluteAPIUrl() 19 if (actor?.avatar?.url) return actor.avatar.url
20
21 if (actor && actor.avatar) {
22 const absoluteAPIUrl = getAbsoluteAPIUrl()
20 23
21 if (actor && actor.avatar) return absoluteAPIUrl + actor.avatar.path 24 return absoluteAPIUrl + actor.avatar.path
25 }
22 26
23 return this.GET_DEFAULT_AVATAR_URL() 27 return this.GET_DEFAULT_AVATAR_URL()
24 } 28 }
diff --git a/client/src/app/shared/angular/highlight.pipe.ts b/client/src/app/shared/angular/highlight.pipe.ts
index fb6042280..50ee5c1bd 100644
--- a/client/src/app/shared/angular/highlight.pipe.ts
+++ b/client/src/app/shared/angular/highlight.pipe.ts
@@ -11,19 +11,17 @@ export class HighlightPipe implements PipeTransform {
11 /* use this for global search */ 11 /* use this for global search */
12 static MULTI_MATCH = 'Multi-Match' 12 static MULTI_MATCH = 'Multi-Match'
13 13
14 // tslint:disable-next-line:no-empty
15 constructor () {}
16
17 transform ( 14 transform (
18 contentString: string = null, 15 contentString: string = null,
19 stringToHighlight: string = null, 16 stringToHighlight: string = null,
20 option = 'Single-And-StartsWith-Match', 17 option = 'Single-And-StartsWith-Match',
21 caseSensitive = false, 18 caseSensitive = false,
22 highlightStyleName = 'search-highlight' 19 highlightStyleName = 'search-highlight'
23 ): SafeHtml { 20 ): SafeHtml {
24 if (stringToHighlight && contentString && option) { 21 if (stringToHighlight && contentString && option) {
25 let regex: any = '' 22 let regex: any = ''
26 const caseFlag: string = !caseSensitive ? 'i' : '' 23 const caseFlag: string = !caseSensitive ? 'i' : ''
24
27 switch (option) { 25 switch (option) {
28 case 'Single-Match': { 26 case 'Single-Match': {
29 regex = new RegExp(stringToHighlight, caseFlag) 27 regex = new RegExp(stringToHighlight, caseFlag)
@@ -42,10 +40,12 @@ export class HighlightPipe implements PipeTransform {
42 regex = new RegExp(stringToHighlight, 'gi') 40 regex = new RegExp(stringToHighlight, 'gi')
43 } 41 }
44 } 42 }
43
45 const replaced = contentString.replace( 44 const replaced = contentString.replace(
46 regex, 45 regex,
47 (match) => `<span class="${highlightStyleName}">${match}</span>` 46 (match) => `<span class="${highlightStyleName}">${match}</span>`
48 ) 47 )
48
49 return replaced 49 return replaced
50 } else { 50 } else {
51 return contentString 51 return contentString
diff --git a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
index abcbca817..fdb19e06a 100644
--- a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
+++ b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
@@ -14,6 +14,7 @@ export class CustomConfigValidatorsService {
14 readonly ADMIN_EMAIL: BuildFormValidator 14 readonly ADMIN_EMAIL: BuildFormValidator
15 readonly TRANSCODING_THREADS: BuildFormValidator 15 readonly TRANSCODING_THREADS: BuildFormValidator
16 readonly INDEX_URL: BuildFormValidator 16 readonly INDEX_URL: BuildFormValidator
17 readonly SEARCH_INDEX_URL: BuildFormValidator
17 18
18 constructor (private i18n: I18n) { 19 constructor (private i18n: I18n) {
19 this.INSTANCE_NAME = { 20 this.INSTANCE_NAME = {
@@ -86,5 +87,12 @@ export class CustomConfigValidatorsService {
86 'pattern': this.i18n('Index URL should be a URL') 87 'pattern': this.i18n('Index URL should be a URL')
87 } 88 }
88 } 89 }
90
91 this.SEARCH_INDEX_URL = {
92 VALIDATORS: [ Validators.pattern(/^https?:\/\//) ],
93 MESSAGES: {
94 'pattern': this.i18n('Search index URL should be a URL')
95 }
96 }
89 } 97 }
90} 98}
diff --git a/client/src/app/shared/users/user-notification.model.ts b/client/src/app/shared/users/user-notification.model.ts
index ba29cb462..7b8368d87 100644
--- a/client/src/app/shared/users/user-notification.model.ts
+++ b/client/src/app/shared/users/user-notification.model.ts
@@ -1,4 +1,4 @@
1import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '../../../../../shared' 1import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, Avatar } from '../../../../../shared'
2import { Actor } from '@app/shared/actor/actor.model' 2import { Actor } from '@app/shared/actor/actor.model'
3 3
4export class UserNotification implements UserNotificationServer { 4export class UserNotification implements UserNotificationServer {
@@ -178,7 +178,7 @@ export class UserNotification implements UserNotificationServer {
178 return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName 178 return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
179 } 179 }
180 180
181 private setAvatarUrl (actor: { avatarUrl?: string, avatar?: { path: string } }) { 181 private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) {
182 actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor) 182 actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
183 } 183 }
184} 184}
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html
index d354a2930..3e23cf18c 100644
--- a/client/src/app/shared/video/video-miniature.component.html
+++ b/client/src/app/shared/video/video-miniature.component.html
@@ -1,6 +1,6 @@
1<div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow, 'fit-width': fitWidth }" (mouseenter)="loadActions()"> 1<div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow, 'fit-width': fitWidth }" (mouseenter)="loadActions()">
2 <my-video-thumbnail 2 <my-video-thumbnail
3 [video]="video" [nsfw]="isVideoBlur" 3 [video]="video" [nsfw]="isVideoBlur" [routerLink]="videoLink"
4 [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)" 4 [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
5 > 5 >
6 <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container> 6 <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
@@ -12,7 +12,7 @@
12 <a 12 <a
13 tabindex="-1" 13 tabindex="-1"
14 class="video-miniature-name" 14 class="video-miniature-name"
15 [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" 15 [routerLink]="videoLink" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
16 >{{ video.name }}</a> 16 >{{ video.name }}</a>
17 17
18 <div class="d-inline-flex"> 18 <div class="d-inline-flex">
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts
index a1d4f0e81..aa1726ca7 100644
--- a/client/src/app/shared/video/video-miniature.component.ts
+++ b/client/src/app/shared/video/video-miniature.component.ts
@@ -1,3 +1,4 @@
1import { switchMap } from 'rxjs/operators'
1import { 2import {
2 ChangeDetectionStrategy, 3 ChangeDetectionStrategy,
3 ChangeDetectorRef, 4 ChangeDetectorRef,
@@ -9,15 +10,14 @@ import {
9 OnInit, 10 OnInit,
10 Output 11 Output
11} from '@angular/core' 12} from '@angular/core'
12import { User } from '../users'
13import { Video } from './video.model'
14import { AuthService, ServerService } from '@app/core' 13import { AuthService, ServerService } from '@app/core'
15import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared'
16import { I18n } from '@ngx-translate/i18n-polyfill'
17import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component'
18import { ScreenService } from '@app/shared/misc/screen.service' 14import { ScreenService } from '@app/shared/misc/screen.service'
19import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' 15import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
20import { switchMap } from 'rxjs/operators' 16import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component'
17import { I18n } from '@ngx-translate/i18n-polyfill'
18import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared'
19import { User } from '../users'
20import { Video } from './video.model'
21 21
22export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' 22export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto'
23export type MiniatureDisplayOptions = { 23export type MiniatureDisplayOptions = {
@@ -57,6 +57,8 @@ export class VideoMiniatureComponent implements OnInit {
57 @Input() displayVideoActions = true 57 @Input() displayVideoActions = true
58 @Input() fitWidth = false 58 @Input() fitWidth = false
59 59
60 @Input() useLazyLoadUrl = false
61
60 @Output() videoBlacklisted = new EventEmitter() 62 @Output() videoBlacklisted = new EventEmitter()
61 @Output() videoUnblacklisted = new EventEmitter() 63 @Output() videoUnblacklisted = new EventEmitter()
62 @Output() videoRemoved = new EventEmitter() 64 @Output() videoRemoved = new EventEmitter()
@@ -82,6 +84,8 @@ export class VideoMiniatureComponent implements OnInit {
82 playlistElementId?: number 84 playlistElementId?: number
83 } 85 }
84 86
87 videoLink: any[] = []
88
85 private ownerDisplayTypeChosen: 'account' | 'videoChannel' 89 private ownerDisplayTypeChosen: 'account' | 'videoChannel'
86 90
87 constructor ( 91 constructor (
@@ -103,7 +107,10 @@ export class VideoMiniatureComponent implements OnInit {
103 ngOnInit () { 107 ngOnInit () {
104 this.serverConfig = this.serverService.getTmpConfig() 108 this.serverConfig = this.serverService.getTmpConfig()
105 this.serverService.getConfig() 109 this.serverService.getConfig()
106 .subscribe(config => this.serverConfig = config) 110 .subscribe(config => {
111 this.serverConfig = config
112 this.buildVideoLink()
113 })
107 114
108 this.setUpBy() 115 this.setUpBy()
109 116
@@ -113,6 +120,21 @@ export class VideoMiniatureComponent implements OnInit {
113 } 120 }
114 } 121 }
115 122
123 buildVideoLink () {
124 if (this.useLazyLoadUrl && this.video.url) {
125 const remoteUriConfig = this.serverConfig.search.remoteUri
126
127 // Redirect on the external instance if not allowed to fetch remote data
128 const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
129 const fromPath = window.location.pathname + window.location.search
130
131 this.videoLink = [ '/search/lazy-load-video', { url: this.video.url, externalRedirect, fromPath } ]
132 return
133 }
134
135 this.videoLink = [ '/videos/watch', this.video.uuid ]
136 }
137
116 displayOwnerAccount () { 138 displayOwnerAccount () {
117 return this.ownerDisplayTypeChosen === 'account' 139 return this.ownerDisplayTypeChosen === 'account'
118 } 140 }
@@ -203,7 +225,7 @@ export class VideoMiniatureComponent implements OnInit {
203 } 225 }
204 226
205 isWatchLaterPlaylistDisplayed () { 227 isWatchLaterPlaylistDisplayed () {
206 return this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined 228 return this.displayVideoActions && this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
207 } 229 }
208 230
209 private setUpBy () { 231 private setUpBy () {
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index 546518cca..97759f9c1 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -33,10 +33,15 @@ export class Video implements VideoServerModel {
33 serverHost: string 33 serverHost: string
34 thumbnailPath: string 34 thumbnailPath: string
35 thumbnailUrl: string 35 thumbnailUrl: string
36
36 previewPath: string 37 previewPath: string
37 previewUrl: string 38 previewUrl: string
39
38 embedPath: string 40 embedPath: string
39 embedUrl: string 41 embedUrl: string
42
43 url?: string
44
40 views: number 45 views: number
41 likes: number 46 likes: number
42 dislikes: number 47 dislikes: number
@@ -100,13 +105,15 @@ export class Video implements VideoServerModel {
100 this.name = hash.name 105 this.name = hash.name
101 106
102 this.thumbnailPath = hash.thumbnailPath 107 this.thumbnailPath = hash.thumbnailPath
103 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath 108 this.thumbnailUrl = hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
104 109
105 this.previewPath = hash.previewPath 110 this.previewPath = hash.previewPath
106 this.previewUrl = absoluteAPIUrl + hash.previewPath 111 this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
107 112
108 this.embedPath = hash.embedPath 113 this.embedPath = hash.embedPath
109 this.embedUrl = absoluteAPIUrl + hash.embedPath 114 this.embedUrl = hash.embedUrl || (absoluteAPIUrl + hash.embedPath)
115
116 this.url = hash.url
110 117
111 this.views = hash.views 118 this.views = hash.views
112 this.likes = hash.likes 119 this.likes = hash.likes
diff --git a/config/default.yaml b/config/default.yaml
index f6e944298..050019670 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -94,14 +94,6 @@ log:
94 maxFiles: 20 94 maxFiles: 20
95 anonymizeIP: false 95 anonymizeIP: false
96 96
97search:
98 # Add ability to fetch remote videos/actors by their URI, that may not be federated with your instance
99 # If enabled, the associated group will be able to "escape" from the instance follows
100 # That means they will be able to follow channels, watch videos, list videos of non followed instances
101 remote_uri:
102 users: true
103 anonymous: false
104
105trending: 97trending:
106 videos: 98 videos:
107 interval_days: 7 # Compute trending videos for the last x days 99 interval_days: 7 # Compute trending videos for the last x days
@@ -382,3 +374,28 @@ broadcast_message:
382 message: '' # Support markdown 374 message: '' # Support markdown
383 level: 'info' # 'info' | 'warning' | 'error' 375 level: 'info' # 'info' | 'warning' | 'error'
384 dismissable: false 376 dismissable: false
377
378search:
379 # Add ability to fetch remote videos/actors by their URI, that may not be federated with your instance
380 # If enabled, the associated group will be able to "escape" from the instance follows
381 # That means they will be able to follow channels, watch videos, list videos of non followed instances
382 remote_uri:
383 users: true
384 anonymous: false
385
386 # Use a third party index instead of your local index, only for search results
387 # Useful to discover content outside of your instance
388 # If you enable search_index, you must enable remote_uri search for users
389 # If you do not enable remote_uri search for anonymous user, your instance will redirect the user on the origin instance
390 # instead of loading the video locally
391 search_index:
392 enabled: false
393 # URL of the search index, that should use the same search API and routes
394 # than PeerTube: https://docs.joinpeertube.org/api-rest-reference.html
395 # You should deploy your own with https://framagit.org/framasoft/peertube/search-index,
396 # and can use https://search.joinpeertube.org/ for tests, but keep in mind the latter is an unmoderated search index
397 url: ''
398 # You can disable local search, so users only use the search index
399 disable_local_search: false
400 # If you did not disable local search, you can decide to use the search index by default
401 is_default_search: false
diff --git a/config/production.yaml.example b/config/production.yaml.example
index e21528821..6f658e61a 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -95,14 +95,6 @@ log:
95 maxFiles: 20 95 maxFiles: 20
96 anonymizeIP: false 96 anonymizeIP: false
97 97
98search:
99 # Add ability to fetch remote videos/actors by their URI, that may not be federated with your instance
100 # If enabled, the associated group will be able to "escape" from the instance follows
101 # That means they will be able to follow channels, watch videos, list videos of non followed instances
102 remote_uri:
103 users: true
104 anonymous: false
105
106trending: 98trending:
107 videos: 99 videos:
108 interval_days: 7 # Compute trending videos for the last x days 100 interval_days: 7 # Compute trending videos for the last x days
@@ -396,3 +388,28 @@ broadcast_message:
396 message: '' # Support markdown 388 message: '' # Support markdown
397 level: 'info' # 'info' | 'warning' | 'error' 389 level: 'info' # 'info' | 'warning' | 'error'
398 dismissable: false 390 dismissable: false
391
392search:
393 # Add ability to fetch remote videos/actors by their URI, that may not be federated with your instance
394 # If enabled, the associated group will be able to "escape" from the instance follows
395 # That means they will be able to follow channels, watch videos, list videos of non followed instances
396 remote_uri:
397 users: true
398 anonymous: false
399
400 # Use a third party index instead of your local index, only for search results
401 # Useful to discover content outside of your instance
402 # If you enable search_index, you must enable remote_uri search for users
403 # If you do not enable remote_uri search for anonymous user, your instance will redirect the user on the origin instance
404 # instead of loading the video locally
405 search_index:
406 enabled: false
407 # URL of the search index, that should use the same search API and routes
408 # than PeerTube: https://docs.joinpeertube.org/api-rest-reference.html
409 # You should deploy your own with https://framagit.org/framasoft/peertube/search-index,
410 # and can use https://search.joinpeertube.org/ for tests, but keep in mind the latter is an unmoderated search index
411 url: ''
412 # You can disable local search, so users only use the search index
413 disable_local_search: false
414 # If you did not disable local search, you can decide to use the search index by default
415 is_default_search: false
diff --git a/config/test.yaml b/config/test.yaml
index 74979f3a7..da34ccd03 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -98,3 +98,25 @@ instance:
98plugins: 98plugins:
99 index: 99 index:
100 check_latest_versions_interval: '10 minutes' 100 check_latest_versions_interval: '10 minutes'
101
102search:
103 # Add ability to fetch remote videos/actors by their URI, that may not be federated with your instance
104 # If enabled, the associated group will be able to "escape" from the instance follows
105 # That means they will be able to follow channels, watch videos, list videos of non followed instances
106 remote_uri:
107 users: true
108 anonymous: false
109
110 # Use a third party index instead of your local index, only for search results
111 # Useful to discover content outside of your instance
112 search_index:
113 enabled: true
114 # URL of the search index, that should use the same search API and routes
115 # than PeerTube: https://docs.joinpeertube.org/api-rest-reference.html
116 # You should deploy your own with https://framagit.org/framasoft/peertube/search-index,
117 # and can use https://search.joinpeertube.org/ for tests, but keep in mind the latter is an unmoderated search index
118 url: 'http://localhost:3234'
119 # You can disable local search, so users only use the search index
120 disable_local_search: false
121 # If you did not disable local search, you can decide to use the search index by default
122 is_default_search: true
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index 41e5027b9..1d48b4b26 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -76,6 +76,12 @@ async function getConfig (req: express.Request, res: express.Response) {
76 remoteUri: { 76 remoteUri: {
77 users: CONFIG.SEARCH.REMOTE_URI.USERS, 77 users: CONFIG.SEARCH.REMOTE_URI.USERS,
78 anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS 78 anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
79 },
80 searchIndex: {
81 enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
82 url: CONFIG.SEARCH.SEARCH_INDEX.URL,
83 disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
84 isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
79 } 85 }
80 }, 86 },
81 plugin: { 87 plugin: {
@@ -445,7 +451,19 @@ function customConfig (): CustomConfig {
445 message: CONFIG.BROADCAST_MESSAGE.MESSAGE, 451 message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
446 level: CONFIG.BROADCAST_MESSAGE.LEVEL, 452 level: CONFIG.BROADCAST_MESSAGE.LEVEL,
447 dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE 453 dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
448 } 454 },
455 search: {
456 remoteUri: {
457 users: CONFIG.SEARCH.REMOTE_URI.USERS,
458 anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
459 },
460 searchIndex: {
461 enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
462 url: CONFIG.SEARCH.SEARCH_INDEX.URL,
463 disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
464 isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
465 }
466 },
449 } 467 }
450} 468}
451 469
diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts
index 35d94d747..e08e1d79f 100644
--- a/server/controllers/api/search.ts
+++ b/server/controllers/api/search.ts
@@ -1,7 +1,19 @@
1import * as express from 'express' 1import * as express from 'express'
2import { sanitizeUrl } from '@server/helpers/core-utils'
3import { doRequest } from '@server/helpers/requests'
4import { CONFIG } from '@server/initializers/config'
5import { getOrCreateVideoAndAccountAndChannel } from '@server/lib/activitypub/videos'
6import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
7import { getServerActor } from '@server/models/application/application'
8import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
9import { ResultList, Video, VideoChannel } from '@shared/models'
10import { SearchTargetQuery } from '@shared/models/search/search-target-query.model'
11import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search'
2import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' 12import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
13import { logger } from '../../helpers/logger'
3import { getFormattedObjects } from '../../helpers/utils' 14import { getFormattedObjects } from '../../helpers/utils'
4import { VideoModel } from '../../models/video/video' 15import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger'
16import { getOrCreateActorAndServerAndModel } from '../../lib/activitypub/actor'
5import { 17import {
6 asyncMiddleware, 18 asyncMiddleware,
7 commonVideosFiltersValidator, 19 commonVideosFiltersValidator,
@@ -14,14 +26,9 @@ import {
14 videosSearchSortValidator, 26 videosSearchSortValidator,
15 videosSearchValidator 27 videosSearchValidator
16} from '../../middlewares' 28} from '../../middlewares'
17import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search' 29import { VideoModel } from '../../models/video/video'
18import { getOrCreateActorAndServerAndModel } from '../../lib/activitypub/actor'
19import { logger } from '../../helpers/logger'
20import { VideoChannelModel } from '../../models/video/video-channel' 30import { VideoChannelModel } from '../../models/video/video-channel'
21import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger'
22import { MChannelAccountDefault, MVideoAccountLightBlacklistAllFiles } from '../../typings/models' 31import { MChannelAccountDefault, MVideoAccountLightBlacklistAllFiles } from '../../typings/models'
23import { getServerActor } from '@server/models/application/application'
24import { getOrCreateVideoAndAccountAndChannel } from '@server/lib/activitypub/videos'
25 32
26const searchRouter = express.Router() 33const searchRouter = express.Router()
27 34
@@ -68,9 +75,34 @@ function searchVideoChannels (req: express.Request, res: express.Response) {
68 75
69 // @username -> username to search in DB 76 // @username -> username to search in DB
70 if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '') 77 if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '')
78
79 if (isSearchIndexEnabled(query)) {
80 return searchVideoChannelsIndex(query, res)
81 }
82
71 return searchVideoChannelsDB(query, res) 83 return searchVideoChannelsDB(query, res)
72} 84}
73 85
86async function searchVideoChannelsIndex (query: VideoChannelsSearchQuery, res: express.Response) {
87 logger.debug('Doing channels search on search index.')
88
89 const result = await buildMutedForSearchIndex(res)
90
91 const body = Object.assign(query, result)
92
93 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels'
94
95 try {
96 const searchIndexResult = await doRequest<ResultList<VideoChannel>>({ uri: url, body, json: true })
97
98 return res.json(searchIndexResult.body)
99 } catch (err) {
100 logger.warn('Cannot use search index to make video channels search.', { err })
101
102 return res.sendStatus(500)
103 }
104}
105
74async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) { 106async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) {
75 const serverActor = await getServerActor() 107 const serverActor = await getServerActor()
76 108
@@ -120,13 +152,38 @@ async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean
120function searchVideos (req: express.Request, res: express.Response) { 152function searchVideos (req: express.Request, res: express.Response) {
121 const query: VideosSearchQuery = req.query 153 const query: VideosSearchQuery = req.query
122 const search = query.search 154 const search = query.search
155
123 if (search && (search.startsWith('http://') || search.startsWith('https://'))) { 156 if (search && (search.startsWith('http://') || search.startsWith('https://'))) {
124 return searchVideoURI(search, res) 157 return searchVideoURI(search, res)
125 } 158 }
126 159
160 if (isSearchIndexEnabled(query)) {
161 return searchVideosIndex(query, res)
162 }
163
127 return searchVideosDB(query, res) 164 return searchVideosDB(query, res)
128} 165}
129 166
167async function searchVideosIndex (query: VideosSearchQuery, res: express.Response) {
168 logger.debug('Doing videos search on search index.')
169
170 const result = await buildMutedForSearchIndex(res)
171
172 const body = Object.assign(query, result)
173
174 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos'
175
176 try {
177 const searchIndexResult = await doRequest<ResultList<Video>>({ uri: url, body, json: true })
178
179 return res.json(searchIndexResult.body)
180 } catch (err) {
181 logger.warn('Cannot use search index to make video search.', { err })
182
183 return res.sendStatus(500)
184 }
185}
186
130async function searchVideosDB (query: VideosSearchQuery, res: express.Response) { 187async function searchVideosDB (query: VideosSearchQuery, res: express.Response) {
131 const options = Object.assign(query, { 188 const options = Object.assign(query, {
132 includeLocalVideos: true, 189 includeLocalVideos: true,
@@ -168,3 +225,35 @@ async function searchVideoURI (url: string, res: express.Response) {
168 data: video ? [ video.toFormattedJSON() ] : [] 225 data: video ? [ video.toFormattedJSON() ] : []
169 }) 226 })
170} 227}
228
229function isSearchIndexEnabled (query: SearchTargetQuery) {
230 if (query.searchTarget === 'search-index') return true
231
232 const searchIndexConfig = CONFIG.SEARCH.SEARCH_INDEX
233
234 if (searchIndexConfig.ENABLED !== true) return false
235
236 if (searchIndexConfig.DISABLE_LOCAL_SEARCH) return true
237 if (searchIndexConfig.IS_DEFAULT_SEARCH && !query.searchTarget) return true
238
239 return false
240}
241
242async function buildMutedForSearchIndex (res: express.Response) {
243 const serverActor = await getServerActor()
244 const accountIds = [ serverActor.Account.id ]
245
246 if (res.locals.oauth) {
247 accountIds.push(res.locals.oauth.token.User.Account.id)
248 }
249
250 const [ blockedHosts, blockedAccounts ] = await Promise.all([
251 ServerBlocklistModel.listHostsBlockedBy(accountIds),
252 AccountBlocklistModel.listHandlesBlockedBy(accountIds)
253 ])
254
255 return {
256 blockedHosts,
257 blockedAccounts
258 }
259}
diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts
index b5b854137..b49ab6bca 100644
--- a/server/initializers/checker-after-init.ts
+++ b/server/initializers/checker-after-init.ts
@@ -128,6 +128,13 @@ function checkConfig () {
128 } 128 }
129 } 129 }
130 130
131 // Search index
132 if (CONFIG.SEARCH.SEARCH_INDEX.ENABLED === true) {
133 if (CONFIG.SEARCH.REMOTE_URI.USERS === false) {
134 return 'You cannot enable search index without enabling remote URI search for users.'
135 }
136 }
137
131 return null 138 return null
132} 139}
133 140
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index bd8f02bc0..e0819c4aa 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -35,7 +35,9 @@ function checkMissedConfig () {
35 'rates_limit.login.window', 'rates_limit.login.max', 'rates_limit.ask_send_email.window', 'rates_limit.ask_send_email.max', 35 'rates_limit.login.window', 'rates_limit.login.max', 'rates_limit.ask_send_email.window', 'rates_limit.ask_send_email.max',
36 'theme.default', 36 'theme.default',
37 'remote_redundancy.videos.accept_from', 37 'remote_redundancy.videos.accept_from',
38 'federation.videos.federate_unlisted' 38 'federation.videos.federate_unlisted',
39 'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url',
40 'search.search_index.disable_local_search', 'search.search_index.is_default_search'
39 ] 41 ]
40 const requiredAlternatives = [ 42 const requiredAlternatives = [
41 [ // set 43 [ // set
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index 44fd9045b..5b402dd74 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -104,12 +104,6 @@ const CONFIG = {
104 }, 104 },
105 ANONYMIZE_IP: config.get<boolean>('log.anonymizeIP') 105 ANONYMIZE_IP: config.get<boolean>('log.anonymizeIP')
106 }, 106 },
107 SEARCH: {
108 REMOTE_URI: {
109 USERS: config.get<boolean>('search.remote_uri.users'),
110 ANONYMOUS: config.get<boolean>('search.remote_uri.anonymous')
111 }
112 },
113 TRENDING: { 107 TRENDING: {
114 VIDEOS: { 108 VIDEOS: {
115 INTERVAL_DAYS: config.get<number>('trending.videos.interval_days') 109 INTERVAL_DAYS: config.get<number>('trending.videos.interval_days')
@@ -297,6 +291,18 @@ const CONFIG = {
297 get MESSAGE () { return config.get<string>('broadcast_message.message') }, 291 get MESSAGE () { return config.get<string>('broadcast_message.message') },
298 get LEVEL () { return config.get<BroadcastMessageLevel>('broadcast_message.level') }, 292 get LEVEL () { return config.get<BroadcastMessageLevel>('broadcast_message.level') },
299 get DISMISSABLE () { return config.get<boolean>('broadcast_message.dismissable') } 293 get DISMISSABLE () { return config.get<boolean>('broadcast_message.dismissable') }
294 },
295 SEARCH: {
296 REMOTE_URI: {
297 USERS: config.get<boolean>('search.remote_uri.users'),
298 ANONYMOUS: config.get<boolean>('search.remote_uri.anonymous')
299 },
300 SEARCH_INDEX: {
301 get ENABLED () { return config.get<boolean>('search.search_index.enabled') },
302 get URL () { return config.get<string>('search.search_index.url') },
303 get DISABLE_LOCAL_SEARCH () { return config.get<boolean>('search.search_index.disable_local_search') },
304 get IS_DEFAULT_SEARCH () { return config.get<boolean>('search.search_index.is_default_search') }
305 }
300 } 306 }
301} 307}
302 308
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index d201df3d8..314f094b3 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -61,6 +61,7 @@ const SORTABLE_COLUMNS = {
61 61
62 VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending' ], 62 VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending' ],
63 63
64 // Don't forget to update peertube-search-index with the same values
64 VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ], 65 VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
65 VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ], 66 VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
66 67
@@ -649,6 +650,15 @@ const DEFAULT_USER_THEME_NAME = 'instance-default'
649 650
650// --------------------------------------------------------------------------- 651// ---------------------------------------------------------------------------
651 652
653const SEARCH_INDEX = {
654 ROUTES: {
655 VIDEOS: '/api/v1/search/videos',
656 VIDEO_CHANNELS: '/api/v1/search/video-channels'
657 }
658}
659
660// ---------------------------------------------------------------------------
661
652// Special constants for a test instance 662// Special constants for a test instance
653if (isTestInstance() === true) { 663if (isTestInstance() === true) {
654 PRIVATE_RSA_KEY_SIZE = 1024 664 PRIVATE_RSA_KEY_SIZE = 1024
@@ -704,6 +714,7 @@ export {
704 API_VERSION, 714 API_VERSION,
705 PEERTUBE_VERSION, 715 PEERTUBE_VERSION,
706 LAZY_STATIC_PATHS, 716 LAZY_STATIC_PATHS,
717 SEARCH_INDEX,
707 HLS_REDUNDANCY_DIRECTORY, 718 HLS_REDUNDANCY_DIRECTORY,
708 P2P_MEDIA_LOADER_PEER_VERSION, 719 P2P_MEDIA_LOADER_PEER_VERSION,
709 AVATARS_SIZE, 720 AVATARS_SIZE,
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index 7d16bd390..6d20e0e65 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -272,11 +272,22 @@ async function getOrCreateVideoAndAccountAndChannel (
272 272
273 const actor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo) 273 const actor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
274 const videoChannel = actor.VideoChannel 274 const videoChannel = actor.VideoChannel
275 const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(createVideo, fetchedVideo, videoChannel, syncParam.thumbnail)
276 275
277 await syncVideoExternalAttributes(videoCreated, fetchedVideo, syncParam) 276 try {
277 const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(createVideo, fetchedVideo, videoChannel, syncParam.thumbnail)
278
279 await syncVideoExternalAttributes(videoCreated, fetchedVideo, syncParam)
278 280
279 return { video: videoCreated, created: true, autoBlacklisted } 281 return { video: videoCreated, created: true, autoBlacklisted }
282 } catch (err) {
283 // Maybe a concurrent getOrCreateVideoAndAccountAndChannel call created this video
284 if (err.name === 'SequelizeUniqueConstraintError') {
285 const fallbackVideo = await fetchVideoByUrl(videoUrl, fetchType)
286 if (fallbackVideo) return { video: fallbackVideo, created: false }
287 }
288
289 throw err
290 }
280} 291}
281 292
282async function updateVideoFromAP (options: { 293async function updateVideoFromAP (options: {
diff --git a/server/lib/plugins/plugin-index.ts b/server/lib/plugins/plugin-index.ts
index 170f0c7e2..7bcb6ed4c 100644
--- a/server/lib/plugins/plugin-index.ts
+++ b/server/lib/plugins/plugin-index.ts
@@ -11,6 +11,7 @@ import { PluginModel } from '../../models/server/plugin'
11import { PluginManager } from './plugin-manager' 11import { PluginManager } from './plugin-manager'
12import { logger } from '../../helpers/logger' 12import { logger } from '../../helpers/logger'
13import { PEERTUBE_VERSION } from '../../initializers/constants' 13import { PEERTUBE_VERSION } from '../../initializers/constants'
14import { sanitizeUrl } from '@server/helpers/core-utils'
14 15
15async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) { 16async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) {
16 const { start = 0, count = 20, search, sort = 'npmName', pluginType } = options 17 const { start = 0, count = 20, search, sort = 'npmName', pluginType } = options
@@ -55,7 +56,7 @@ async function getLatestPluginsVersion (npmNames: string[]): Promise<PeertubePlu
55 currentPeerTubeEngine: PEERTUBE_VERSION 56 currentPeerTubeEngine: PEERTUBE_VERSION
56 } 57 }
57 58
58 const uri = CONFIG.PLUGINS.INDEX.URL + '/api/v1/plugins/latest-version' 59 const uri = sanitizeUrl(CONFIG.PLUGINS.INDEX.URL) + '/api/v1/plugins/latest-version'
59 60
60 const { body } = await doRequest<any>({ uri, body: bodyRequest, json: true, method: 'POST' }) 61 const { body } = await doRequest<any>({ uri, body: bodyRequest, json: true, method: 'POST' })
61 62
diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts
index 6905ac762..d3669f6be 100644
--- a/server/middlewares/validators/config.ts
+++ b/server/middlewares/validators/config.ts
@@ -58,7 +58,14 @@ const customConfigUpdateValidator = [
58 body('broadcastMessage.enabled').isBoolean().withMessage('Should have a valid broadcast message enabled boolean'), 58 body('broadcastMessage.enabled').isBoolean().withMessage('Should have a valid broadcast message enabled boolean'),
59 body('broadcastMessage.message').exists().withMessage('Should have a valid broadcast message'), 59 body('broadcastMessage.message').exists().withMessage('Should have a valid broadcast message'),
60 body('broadcastMessage.level').exists().withMessage('Should have a valid broadcast level'), 60 body('broadcastMessage.level').exists().withMessage('Should have a valid broadcast level'),
61 body('broadcastMessage.dismissable').exists().withMessage('Should have a valid broadcast dismissable boolean'), 61 body('broadcastMessage.dismissable').isBoolean().withMessage('Should have a valid broadcast dismissable boolean'),
62
63 body('search.remoteUri.users').isBoolean().withMessage('Should have a remote URI search for users boolean'),
64 body('search.remoteUri.anonymous').isBoolean().withMessage('Should have a valid remote URI search for anonymous boolean'),
65 body('search.searchIndex.enabled').isBoolean().withMessage('Should have a valid search index enabled boolean'),
66 body('search.searchIndex.url').exists().withMessage('Should have a valid search index URL'),
67 body('search.searchIndex.disableLocalSearch').isBoolean().withMessage('Should have a valid search index disable local search boolean'),
68 body('search.searchIndex.isDefaultSearch').isBoolean().withMessage('Should have a valid search index default enabled boolean'),
62 69
63 (req: express.Request, res: express.Response, next: express.NextFunction) => { 70 (req: express.Request, res: express.Response, next: express.NextFunction) => {
64 logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body }) 71 logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
index d8a7ce4b4..2c6b756d2 100644
--- a/server/models/account/account-blocklist.ts
+++ b/server/models/account/account-blocklist.ts
@@ -5,6 +5,8 @@ import { AccountBlock } from '../../../shared/models/blocklist'
5import { Op } from 'sequelize' 5import { Op } from 'sequelize'
6import * as Bluebird from 'bluebird' 6import * as Bluebird from 'bluebird'
7import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/typings/models' 7import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/typings/models'
8import { ActorModel } from '../activitypub/actor'
9import { ServerModel } from '../server/server'
8 10
9enum ScopeNames { 11enum ScopeNames {
10 WITH_ACCOUNTS = 'WITH_ACCOUNTS' 12 WITH_ACCOUNTS = 'WITH_ACCOUNTS'
@@ -149,6 +151,42 @@ export class AccountBlocklistModel extends Model<AccountBlocklistModel> {
149 }) 151 })
150 } 152 }
151 153
154 static listHandlesBlockedBy (accountIds: number[]): Bluebird<string[]> {
155 const query = {
156 attributes: [],
157 where: {
158 accountId: {
159 [Op.in]: accountIds
160 }
161 },
162 include: [
163 {
164 attributes: [ 'id' ],
165 model: AccountModel.unscoped(),
166 required: true,
167 as: 'BlockedAccount',
168 include: [
169 {
170 attributes: [ 'preferredUsername' ],
171 model: ActorModel.unscoped(),
172 required: true,
173 include: [
174 {
175 attributes: [ 'host' ],
176 model: ServerModel.unscoped(),
177 required: true
178 }
179 ]
180 }
181 ]
182 }
183 ]
184 }
185
186 return AccountBlocklistModel.findAll(query)
187 .then(entries => entries.map(e => `${e.BlockedAccount.Actor.preferredUsername}@${e.BlockedAccount.Actor.Server.host}`))
188 }
189
152 toFormattedJSON (this: MAccountBlocklistFormattable): AccountBlock { 190 toFormattedJSON (this: MAccountBlocklistFormattable): AccountBlock {
153 return { 191 return {
154 byAccount: this.ByAccount.toFormattedJSON(), 192 byAccount: this.ByAccount.toFormattedJSON(),
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
index 892024c04..ad8e3d1e8 100644
--- a/server/models/server/server-blocklist.ts
+++ b/server/models/server/server-blocklist.ts
@@ -120,6 +120,27 @@ export class ServerBlocklistModel extends Model<ServerBlocklistModel> {
120 return ServerBlocklistModel.findOne(query) 120 return ServerBlocklistModel.findOne(query)
121 } 121 }
122 122
123 static listHostsBlockedBy (accountIds: number[]): Bluebird<string[]> {
124 const query = {
125 attributes: [ ],
126 where: {
127 accountId: {
128 [Op.in]: accountIds
129 }
130 },
131 include: [
132 {
133 attributes: [ 'host' ],
134 model: ServerModel.unscoped(),
135 required: true
136 }
137 ]
138 }
139
140 return ServerBlocklistModel.findAll(query)
141 .then(entries => entries.map(e => e.BlockedServer.host))
142 }
143
123 static listForApi (parameters: { 144 static listForApi (parameters: {
124 start: number 145 start: number
125 count: number 146 count: number
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 7c96fa762..3f2708f94 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -139,6 +139,18 @@ describe('Test config API validators', function () {
139 dismissable: true, 139 dismissable: true,
140 message: 'super message', 140 message: 'super message',
141 level: 'warning' 141 level: 'warning'
142 },
143 search: {
144 remoteUri: {
145 users: true,
146 anonymous: true
147 },
148 searchIndex: {
149 enabled: true,
150 url: 'https://search.joinpeertube.org',
151 disableLocalSearch: true,
152 isDefaultSearch: true
153 }
142 } 154 }
143 } 155 }
144 156
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index d18a93082..597233588 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -340,6 +340,18 @@ describe('Test config', function () {
340 level: 'error', 340 level: 'error',
341 message: 'super bad message', 341 message: 'super bad message',
342 dismissable: true 342 dismissable: true
343 },
344 search: {
345 remoteUri: {
346 anonymous: true,
347 users: true
348 },
349 searchIndex: {
350 enabled: true,
351 url: 'https://search.joinpeertube.org',
352 disableLocalSearch: true,
353 isDefaultSearch: true
354 }
343 } 355 }
344 } 356 }
345 await updateCustomConfig(server.url, server.accessToken, newCustomConfig) 357 await updateCustomConfig(server.url, server.accessToken, newCustomConfig)
diff --git a/shared/extra-utils/server/config.ts b/shared/extra-utils/server/config.ts
index 98cd435f6..eb06a1516 100644
--- a/shared/extra-utils/server/config.ts
+++ b/shared/extra-utils/server/config.ts
@@ -165,6 +165,18 @@ function updateCustomSubConfig (url: string, token: string, newConfig: DeepParti
165 level: 'warning', 165 level: 'warning',
166 message: 'hello', 166 message: 'hello',
167 dismissable: true 167 dismissable: true
168 },
169 search: {
170 remoteUri: {
171 users: true,
172 anonymous: true
173 },
174 searchIndex: {
175 enabled: true,
176 url: 'https://search.joinpeertube.org',
177 disableLocalSearch: true,
178 isDefaultSearch: true
179 }
168 } 180 }
169 } 181 }
170 182
diff --git a/shared/models/avatars/avatar.model.ts b/shared/models/avatars/avatar.model.ts
index 301d00929..f7fa16f49 100644
--- a/shared/models/avatars/avatar.model.ts
+++ b/shared/models/avatars/avatar.model.ts
@@ -1,5 +1,8 @@
1export interface Avatar { 1export interface Avatar {
2 path: string 2 path: string
3
4 url?: string
5
3 createdAt: Date | string 6 createdAt: Date | string
4 updatedAt: Date | string 7 updatedAt: Date | string
5} 8}
diff --git a/shared/models/search/search-target-query.model.ts b/shared/models/search/search-target-query.model.ts
new file mode 100644
index 000000000..3bb2e0d31
--- /dev/null
+++ b/shared/models/search/search-target-query.model.ts
@@ -0,0 +1,5 @@
1export type SearchTargetType = 'local' | 'search-index'
2
3export interface SearchTargetQuery {
4 searchTarget?: SearchTargetType
5}
diff --git a/shared/models/search/video-channels-search-query.model.ts b/shared/models/search/video-channels-search-query.model.ts
index de2741e14..c96aa8c1d 100644
--- a/shared/models/search/video-channels-search-query.model.ts
+++ b/shared/models/search/video-channels-search-query.model.ts
@@ -1,4 +1,6 @@
1export interface VideoChannelsSearchQuery { 1import { SearchTargetQuery } from "./search-target-query.model"
2
3export interface VideoChannelsSearchQuery extends SearchTargetQuery {
2 search: string 4 search: string
3 5
4 start?: number 6 start?: number
diff --git a/shared/models/search/videos-search-query.model.ts b/shared/models/search/videos-search-query.model.ts
index 838063095..bd6bb5bc1 100644
--- a/shared/models/search/videos-search-query.model.ts
+++ b/shared/models/search/videos-search-query.model.ts
@@ -1,7 +1,10 @@
1import { NSFWQuery } from './nsfw-query.model' 1import { NSFWQuery } from './nsfw-query.model'
2import { VideoFilter } from '../videos' 2import { VideoFilter } from '../videos'
3import { SearchTargetQuery } from './search-target-query.model'
4
5export interface VideosSearchQuery extends SearchTargetQuery {
6 forceLocalSearch?: boolean
3 7
4export interface VideosSearchQuery {
5 search?: string 8 search?: string
6 9
7 start?: number 10 start?: number
diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts
index 851bf1854..338a59341 100644
--- a/shared/models/server/custom-config.model.ts
+++ b/shared/models/server/custom-config.model.ts
@@ -139,4 +139,18 @@ export interface CustomConfig {
139 level: BroadcastMessageLevel 139 level: BroadcastMessageLevel
140 dismissable: boolean 140 dismissable: boolean
141 } 141 }
142
143 search: {
144 remoteUri: {
145 users: boolean
146 anonymous: boolean
147 }
148
149 searchIndex: {
150 enabled: boolean
151 url: string
152 disableLocalSearch: boolean
153 isDefaultSearch: boolean
154 }
155 }
142} 156}
diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts
index 9c903b7ee..a8e5dfbff 100644
--- a/shared/models/server/server-config.model.ts
+++ b/shared/models/server/server-config.model.ts
@@ -50,6 +50,13 @@ export interface ServerConfig {
50 users: boolean 50 users: boolean
51 anonymous: boolean 51 anonymous: boolean
52 } 52 }
53
54 searchIndex: {
55 enabled: boolean
56 url: string
57 disableLocalSearch: boolean
58 isDefaultSearch: boolean
59 }
53 } 60 }
54 61
55 plugin: { 62 plugin: {
diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts
index a69152759..0f8822125 100644
--- a/shared/models/videos/video.model.ts
+++ b/shared/models/videos/video.model.ts
@@ -22,9 +22,19 @@ export interface Video {
22 duration: number 22 duration: number
23 isLocal: boolean 23 isLocal: boolean
24 name: string 24 name: string
25
25 thumbnailPath: string 26 thumbnailPath: string
27 thumbnailUrl?: string
28
26 previewPath: string 29 previewPath: string
30 previewUrl?: string
31
27 embedPath: string 32 embedPath: string
33 embedUrl?: string
34
35 // When using the search index
36 url?: string
37
28 views: number 38 views: number
29 likes: number 39 likes: number
30 dislikes: number 40 dislikes: number