diff options
author | Chocobozzz <me@florianbigard.com> | 2020-05-29 16:16:24 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2020-06-10 14:02:41 +0200 |
commit | 5fb2e2888ce032c638e4b75d07458642f0833e52 (patch) | |
tree | 8830d873569316889b8134027e9a43b198cca38f | |
parent | 62e7be634bc189f942ae51cb4b080079ab503ff0 (diff) | |
download | PeerTube-5fb2e2888ce032c638e4b75d07458642f0833e52.tar.gz PeerTube-5fb2e2888ce032c638e4b75d07458642f0833e52.tar.zst PeerTube-5fb2e2888ce032c638e4b75d07458642f0833e52.zip |
First implem global search
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' | |||
8 | import { AppRoutingModule } from './app-routing.module' | 8 | import { AppRoutingModule } from './app-routing.module' |
9 | import { AppComponent } from './app.component' | 9 | import { AppComponent } from './app.component' |
10 | import { CoreModule } from './core' | 10 | import { CoreModule } from './core' |
11 | import { HeaderComponent, SearchTypeaheadComponent, SuggestionsComponent, SuggestionComponent } from './header' | 11 | import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header' |
12 | import { LoginModule } from './login' | 12 | import { LoginModule } from './login' |
13 | import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu' | 13 | import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu' |
14 | import { SharedModule } from './shared' | 14 | import { 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 @@ | |||
1 | import { Observable, of, Subject } from 'rxjs' | ||
1 | import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators' | 2 | import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators' |
2 | import { HttpClient } from '@angular/common/http' | 3 | import { HttpClient } from '@angular/common/http' |
3 | import { Inject, Injectable, LOCALE_ID } from '@angular/core' | 4 | import { Inject, Injectable, LOCALE_ID } from '@angular/core' |
4 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' | ||
5 | import { Observable, of, Subject } from 'rxjs' | ||
6 | import { getCompleteLocale, ServerConfig } from '../../../../../shared' | ||
7 | import { environment } from '../../../environments/environment' | ||
8 | import { VideoConstant } from '../../../../../shared/models/videos' | ||
9 | import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n' | ||
10 | import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' | 5 | import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' |
6 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' | ||
11 | import { sortBy } from '@app/shared/misc/utils' | 7 | import { sortBy } from '@app/shared/misc/utils' |
8 | import { SearchTargetType } from '@shared/models/search/search-target-query.model' | ||
12 | import { ServerStats } from '@shared/models/server' | 9 | import { ServerStats } from '@shared/models/server' |
10 | import { getCompleteLocale, ServerConfig } from '../../../../../shared' | ||
11 | import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n' | ||
12 | import { VideoConstant } from '../../../../../shared/models/videos' | ||
13 | import { environment } from '../../../environments/environment' | ||
13 | 14 | ||
14 | @Injectable() | 15 | @Injectable() |
15 | export class ServerService { | 16 | export 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 @@ | |||
1 | export * from './header.component' | 1 | export * from './header.component' |
2 | export * from './search-typeahead.component' | 2 | export * from './search-typeahead.component' |
3 | export * from './suggestions.component' | ||
4 | export * from './suggestion.component' | 3 | export * 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, |
39 | my-suggestions ::ng-deep ul { | 39 | li.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 @@ | |||
1 | import { Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild } from '@angular/core' | 1 | import { of } from 'rxjs' |
2 | import { first, tap, delay } from 'rxjs/operators' | ||
3 | import { ListKeyManager } from '@angular/cdk/a11y' | ||
4 | import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren, AfterViewChecked } from '@angular/core' | ||
2 | import { ActivatedRoute, Params, Router } from '@angular/router' | 5 | import { ActivatedRoute, Params, Router } from '@angular/router' |
3 | import { AuthService, ServerService } from '@app/core' | 6 | import { AuthService, ServerService } from '@app/core' |
4 | import { first, tap } from 'rxjs/operators' | ||
5 | import { ListKeyManager } from '@angular/cdk/a11y' | ||
6 | import { Result, SuggestionComponent } from './suggestion.component' | ||
7 | import { of } from 'rxjs' | ||
8 | import { ServerConfig } from '@shared/models' | 7 | import { ServerConfig } from '@shared/models' |
8 | import { SearchTargetType } from '@shared/models/search/search-target-query.model' | ||
9 | import { 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 | }) |
15 | export class SearchTypeaheadComponent implements OnInit, OnDestroy { | 16 | export 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 @@ | |||
1 | import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy } from '@angular/core' | 1 | import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy, OnChanges } from '@angular/core' |
2 | import { RouterLink } from '@angular/router' | 2 | import { RouterLink } from '@angular/router' |
3 | import { ListKeyManagerOption } from '@angular/cdk/a11y' | 3 | import { ListKeyManagerOption } from '@angular/cdk/a11y' |
4 | 4 | ||
5 | export type Result = { | 5 | export 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 | ||
12 | export 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 | }) |
18 | export class SuggestionComponent implements OnInit, ListKeyManagerOption { | 19 | export 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 @@ | |||
1 | import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren, ChangeDetectionStrategy } from '@angular/core' | ||
2 | import { SuggestionComponent } from './suggestion.component' | ||
3 | |||
4 | @Component({ | ||
5 | selector: 'my-suggestions', | ||
6 | templateUrl: './suggestions.component.html', | ||
7 | changeDetection: ChangeDetectionStrategy.OnPush | ||
8 | }) | ||
9 | export 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 @@ | |||
1 | import { SearchTargetType } from '@shared/models/search/search-target-query.model' | ||
1 | import { NSFWQuery } from '../../../../shared/models/search' | 2 | import { NSFWQuery } from '../../../../shared/models/search' |
2 | 3 | ||
3 | export class AdvancedSearch { | 4 | export 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 @@ | |||
1 | import { map } from 'rxjs/operators' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router' | ||
4 | import { SearchService } from './search.service' | ||
5 | import { RedirectService } from '@app/core' | ||
6 | |||
7 | @Injectable() | ||
8 | export 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 @@ | |||
1 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { RouterModule, Routes } from '@angular/router' | 2 | import { RouterModule, Routes } from '@angular/router' |
3 | import { MetaGuard } from '@ngx-meta/core' | ||
4 | import { SearchComponent } from '@app/search/search.component' | 3 | import { SearchComponent } from '@app/search/search.component' |
4 | import { MetaGuard } from '@ngx-meta/core' | ||
5 | import { VideoLazyLoadResolver } from './video-lazy-load.resolver' | ||
6 | import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver' | ||
5 | 7 | ||
6 | const searchRoutes: Routes = [ | 8 | const 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 @@ | |||
1 | import { forkJoin, of, Subscription } from 'rxjs' | ||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | 2 | import { Component, OnDestroy, OnInit } from '@angular/core' |
2 | import { ActivatedRoute, Router } from '@angular/router' | 3 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { AuthService, Notifier } from '@app/core' | 4 | import { AuthService, Notifier, ServerService } from '@app/core' |
4 | import { forkJoin, of, Subscription } from 'rxjs' | 5 | import { HooksService } from '@app/core/plugins/hooks.service' |
6 | import { AdvancedSearch } from '@app/search/advanced-search.model' | ||
5 | import { SearchService } from '@app/search/search.service' | 7 | import { SearchService } from '@app/search/search.service' |
8 | import { immutableAssign } from '@app/shared/misc/utils' | ||
6 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | 9 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' |
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
8 | import { MetaService } from '@ngx-meta/core' | ||
9 | import { AdvancedSearch } from '@app/search/advanced-search.model' | ||
10 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 10 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
11 | import { immutableAssign } from '@app/shared/misc/utils' | ||
12 | import { Video } from '@app/shared/video/video.model' | 11 | import { Video } from '@app/shared/video/video.model' |
13 | import { HooksService } from '@app/core/plugins/hooks.service' | 12 | import { MetaService } from '@ngx-meta/core' |
13 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
14 | import { ServerConfig } from '@shared/models' | ||
15 | import { 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 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { TagInputModule } from 'ngx-chips' | 1 | import { TagInputModule } from 'ngx-chips' |
3 | import { SharedModule } from '../shared' | 2 | import { NgModule } from '@angular/core' |
3 | import { SearchFiltersComponent } from '@app/search/search-filters.component' | ||
4 | import { SearchRoutingModule } from '@app/search/search-routing.module' | ||
4 | import { SearchComponent } from '@app/search/search.component' | 5 | import { SearchComponent } from '@app/search/search.component' |
5 | import { SearchService } from '@app/search/search.service' | 6 | import { SearchService } from '@app/search/search.service' |
6 | import { SearchRoutingModule } from '@app/search/search-routing.module' | 7 | import { SharedModule } from '../shared' |
7 | import { SearchFiltersComponent } from '@app/search/search-filters.component' | 8 | import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver' |
9 | import { 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 | }) |
31 | export class SearchModule { } | 35 | export 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 @@ | |||
1 | import { Observable } from 'rxjs' | ||
1 | import { catchError, map, switchMap } from 'rxjs/operators' | 2 | import { catchError, map, switchMap } from 'rxjs/operators' |
2 | import { HttpClient, HttpParams } from '@angular/common/http' | 3 | import { HttpClient, HttpParams } from '@angular/common/http' |
3 | import { Injectable } from '@angular/core' | 4 | import { Injectable } from '@angular/core' |
4 | import { Observable } from 'rxjs' | ||
5 | import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model' | ||
6 | import { VideoService } from '@app/shared/video/video.service' | ||
7 | import { RestExtractor, RestService } from '@app/shared' | ||
8 | import { environment } from '../../environments/environment' | ||
9 | import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared' | ||
10 | import { Video } from '@app/shared/video/video.model' | ||
11 | import { AdvancedSearch } from '@app/search/advanced-search.model' | 5 | import { AdvancedSearch } from '@app/search/advanced-search.model' |
6 | import { RestExtractor, RestPagination, RestService } from '@app/shared' | ||
7 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' | ||
8 | import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model' | ||
12 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 9 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
13 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | 10 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' |
14 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' | 11 | import { Video } from '@app/shared/video/video.model' |
12 | import { VideoService } from '@app/shared/video/video.service' | ||
13 | import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared' | ||
14 | import { environment } from '../../environments/environment' | ||
15 | import { SearchTargetType } from '@shared/models/search/search-target-query.model' | ||
15 | 16 | ||
16 | @Injectable() | 17 | @Injectable() |
17 | export class SearchService { | 18 | export 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 @@ | |||
1 | import { map } from 'rxjs/operators' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router' | ||
4 | import { SearchService } from './search.service' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '../../../../../shared' | 1 | import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, Avatar } from '../../../../../shared' |
2 | import { Actor } from '@app/shared/actor/actor.model' | 2 | import { Actor } from '@app/shared/actor/actor.model' |
3 | 3 | ||
4 | export class UserNotification implements UserNotificationServer { | 4 | export 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 @@ | |||
1 | import { switchMap } from 'rxjs/operators' | ||
1 | import { | 2 | import { |
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' |
12 | import { User } from '../users' | ||
13 | import { Video } from './video.model' | ||
14 | import { AuthService, ServerService } from '@app/core' | 13 | import { AuthService, ServerService } from '@app/core' |
15 | import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared' | ||
16 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
17 | import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component' | ||
18 | import { ScreenService } from '@app/shared/misc/screen.service' | 14 | import { ScreenService } from '@app/shared/misc/screen.service' |
19 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | 15 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' |
20 | import { switchMap } from 'rxjs/operators' | 16 | import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component' |
17 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
18 | import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared' | ||
19 | import { User } from '../users' | ||
20 | import { Video } from './video.model' | ||
21 | 21 | ||
22 | export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' | 22 | export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' |
23 | export type MiniatureDisplayOptions = { | 23 | export 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 | ||
97 | search: | ||
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 | |||
105 | trending: | 97 | trending: |
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 | |||
378 | search: | ||
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 | ||
98 | search: | ||
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 | |||
106 | trending: | 98 | trending: |
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 | |||
392 | search: | ||
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: | |||
98 | plugins: | 98 | plugins: |
99 | index: | 99 | index: |
100 | check_latest_versions_interval: '10 minutes' | 100 | check_latest_versions_interval: '10 minutes' |
101 | |||
102 | search: | ||
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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { sanitizeUrl } from '@server/helpers/core-utils' | ||
3 | import { doRequest } from '@server/helpers/requests' | ||
4 | import { CONFIG } from '@server/initializers/config' | ||
5 | import { getOrCreateVideoAndAccountAndChannel } from '@server/lib/activitypub/videos' | ||
6 | import { AccountBlocklistModel } from '@server/models/account/account-blocklist' | ||
7 | import { getServerActor } from '@server/models/application/application' | ||
8 | import { ServerBlocklistModel } from '@server/models/server/server-blocklist' | ||
9 | import { ResultList, Video, VideoChannel } from '@shared/models' | ||
10 | import { SearchTargetQuery } from '@shared/models/search/search-target-query.model' | ||
11 | import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search' | ||
2 | import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' | 12 | import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' |
13 | import { logger } from '../../helpers/logger' | ||
3 | import { getFormattedObjects } from '../../helpers/utils' | 14 | import { getFormattedObjects } from '../../helpers/utils' |
4 | import { VideoModel } from '../../models/video/video' | 15 | import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger' |
16 | import { getOrCreateActorAndServerAndModel } from '../../lib/activitypub/actor' | ||
5 | import { | 17 | import { |
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' |
17 | import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search' | 29 | import { VideoModel } from '../../models/video/video' |
18 | import { getOrCreateActorAndServerAndModel } from '../../lib/activitypub/actor' | ||
19 | import { logger } from '../../helpers/logger' | ||
20 | import { VideoChannelModel } from '../../models/video/video-channel' | 30 | import { VideoChannelModel } from '../../models/video/video-channel' |
21 | import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger' | ||
22 | import { MChannelAccountDefault, MVideoAccountLightBlacklistAllFiles } from '../../typings/models' | 31 | import { MChannelAccountDefault, MVideoAccountLightBlacklistAllFiles } from '../../typings/models' |
23 | import { getServerActor } from '@server/models/application/application' | ||
24 | import { getOrCreateVideoAndAccountAndChannel } from '@server/lib/activitypub/videos' | ||
25 | 32 | ||
26 | const searchRouter = express.Router() | 33 | const 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 | ||
86 | async 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 | |||
74 | async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) { | 106 | async 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 | |||
120 | function searchVideos (req: express.Request, res: express.Response) { | 152 | function 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 | ||
167 | async 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 | |||
130 | async function searchVideosDB (query: VideosSearchQuery, res: express.Response) { | 187 | async 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 | |||
229 | function 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 | |||
242 | async 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 | ||
653 | const 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 |
653 | if (isTestInstance() === true) { | 663 | if (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 | ||
282 | async function updateVideoFromAP (options: { | 293 | async 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' | |||
11 | import { PluginManager } from './plugin-manager' | 11 | import { PluginManager } from './plugin-manager' |
12 | import { logger } from '../../helpers/logger' | 12 | import { logger } from '../../helpers/logger' |
13 | import { PEERTUBE_VERSION } from '../../initializers/constants' | 13 | import { PEERTUBE_VERSION } from '../../initializers/constants' |
14 | import { sanitizeUrl } from '@server/helpers/core-utils' | ||
14 | 15 | ||
15 | async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) { | 16 | async 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' | |||
5 | import { Op } from 'sequelize' | 5 | import { Op } from 'sequelize' |
6 | import * as Bluebird from 'bluebird' | 6 | import * as Bluebird from 'bluebird' |
7 | import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/typings/models' | 7 | import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/typings/models' |
8 | import { ActorModel } from '../activitypub/actor' | ||
9 | import { ServerModel } from '../server/server' | ||
8 | 10 | ||
9 | enum ScopeNames { | 11 | enum 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 @@ | |||
1 | export interface Avatar { | 1 | export 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 @@ | |||
1 | export type SearchTargetType = 'local' | 'search-index' | ||
2 | |||
3 | export 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 @@ | |||
1 | export interface VideoChannelsSearchQuery { | 1 | import { SearchTargetQuery } from "./search-target-query.model" |
2 | |||
3 | export 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 @@ | |||
1 | import { NSFWQuery } from './nsfw-query.model' | 1 | import { NSFWQuery } from './nsfw-query.model' |
2 | import { VideoFilter } from '../videos' | 2 | import { VideoFilter } from '../videos' |
3 | import { SearchTargetQuery } from './search-target-query.model' | ||
4 | |||
5 | export interface VideosSearchQuery extends SearchTargetQuery { | ||
6 | forceLocalSearch?: boolean | ||
3 | 7 | ||
4 | export 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 |