First implem global search

This commit is contained in:
Chocobozzz 2020-05-29 16:16:24 +02:00 committed by Chocobozzz
parent 62e7be634b
commit 5fb2e2888c
54 changed files with 1052 additions and 331 deletions

View File

@ -396,9 +396,9 @@
</div> </div>
</div> </div>
<div class="form-row mt-4"> <!-- new videos grid --> <div class="form-row mt-4"> <!-- videos grid -->
<div class="form-group col-12 col-lg-4 col-xl-3"> <div class="form-group col-12 col-lg-4 col-xl-3">
<div i18n class="inner-form-title">NEW VIDEOS</div> <div i18n class="inner-form-title">VIDEOS</div>
</div> </div>
<div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
@ -445,6 +445,86 @@
</div> </div>
</div> </div>
<div class="form-row mt-4"> <!-- search grid -->
<div class="form-group col-12 col-lg-4 col-xl-3">
<div i18n class="inner-form-title">SEARCH</div>
</div>
<div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
<ng-container formGroupName="search">
<ng-container formGroupName="remoteUri">
<div class="form-group">
<my-peertube-checkbox
inputName="searchRemoteUriUsers" formControlName="users"
i18n-labelText labelText="Allow users to do remote URI/handle search"
>
<ng-container ngProjectAs="description">
<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>
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="searchRemoteUriAnonymous" formControlName="anonymous"
i18n-labelText labelText="Allow anonymous to do remote URI/handle search"
>
<ng-container ngProjectAs="description">
<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>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="searchIndex">
<div class="form-group">
<my-peertube-checkbox
inputName="searchIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Enable search index"
>
<ng-container ngProjectAs="extra">
<div [ngClass]="{ 'disabled-checkbox-extra': !isSearchIndexEnabled() }">
<label i18n for="searchIndexUrl">Search index URL</label>
<input
type="text" id="searchIndexUrl" class="form-control"
formControlName="url" [ngClass]="{ 'input-error': formErrors['search.searchIndex.url'] }"
>
<div *ngIf="formErrors.search.searchIndex.url" class="form-error">{{ formErrors.search.searchIndex.url }}</div>
</div>
<div class="mt-3">
<my-peertube-checkbox [ngClass]="{ 'disabled-checkbox-extra': !isSearchIndexEnabled() }"
inputName="searchIndexDisableLocalSearch" formControlName="disableLocalSearch"
i18n-labelText labelText="Disable local search"
></my-peertube-checkbox>
</div>
<div class="mt-3">
<my-peertube-checkbox [ngClass]="{ 'disabled-checkbox-extra': !isSearchIndexEnabled() }"
inputName="searchIndexIsDefaultSearch" formControlName="isDefaultSearch"
i18n-labelText labelText="Set search index as default"
>
<ng-container ngProjectAs="description">
<span i18n>The local search is used by default</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</div>
</div>
<div class="form-row mt-4"> <!-- federation grid --> <div class="form-row mt-4"> <!-- federation grid -->
<div class="form-group col-12 col-lg-4 col-xl-3"> <div class="form-group col-12 col-lg-4 col-xl-3">
<div i18n class="inner-form-title">FEDERATION</div> <div i18n class="inner-form-title">FEDERATION</div>

View File

@ -64,8 +64,10 @@ textarea {
} }
.disabled-checkbox-extra { .disabled-checkbox-extra {
&, ::ng-deep label {
opacity: .5; opacity: .5;
pointer-events: none; pointer-events: none;
}
} }
.form-group-right { .form-group-right {

View File

@ -221,6 +221,18 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
level: null, level: null,
dismissable: null, dismissable: null,
message: null message: null
},
search: {
remoteUri: {
users: null,
anonymous: null
},
searchIndex: {
enabled: null,
url: this.customConfigValidatorsService.SEARCH_INDEX_URL,
disableLocalSearch: null,
isDefaultSearch: null
}
} }
} }
@ -254,6 +266,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
return this.form.value['signup']['enabled'] === true return this.form.value['signup']['enabled'] === true
} }
isSearchIndexEnabled () {
return this.form.value['search']['searchIndex']['enabled'] === true
}
isAutoFollowIndexEnabled () { isAutoFollowIndexEnabled () {
return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true
} }

View File

@ -8,7 +8,7 @@ import 'focus-visible'
import { AppRoutingModule } from './app-routing.module' import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component' import { AppComponent } from './app.component'
import { CoreModule } from './core' import { CoreModule } from './core'
import { HeaderComponent, SearchTypeaheadComponent, SuggestionsComponent, SuggestionComponent } from './header' import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header'
import { LoginModule } from './login' import { LoginModule } from './login'
import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu' import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
import { SharedModule } from './shared' import { SharedModule } from './shared'
@ -35,7 +35,6 @@ registerLocaleData(localeOc, 'oc')
AvatarNotificationComponent, AvatarNotificationComponent,
HeaderComponent, HeaderComponent,
SearchTypeaheadComponent, SearchTypeaheadComponent,
SuggestionsComponent,
SuggestionComponent, SuggestionComponent,
CustomModalComponent, CustomModalComponent,

View File

@ -1,15 +1,16 @@
import { Observable, of, Subject } from 'rxjs'
import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators' import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http' import { HttpClient } from '@angular/common/http'
import { Inject, Injectable, LOCALE_ID } from '@angular/core' import { Inject, Injectable, LOCALE_ID } from '@angular/core'
import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
import { Observable, of, Subject } from 'rxjs'
import { getCompleteLocale, ServerConfig } from '../../../../../shared'
import { environment } from '../../../environments/environment'
import { VideoConstant } from '../../../../../shared/models/videos'
import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
import { sortBy } from '@app/shared/misc/utils' import { sortBy } from '@app/shared/misc/utils'
import { SearchTargetType } from '@shared/models/search/search-target-query.model'
import { ServerStats } from '@shared/models/server' import { ServerStats } from '@shared/models/server'
import { getCompleteLocale, ServerConfig } from '../../../../../shared'
import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
import { VideoConstant } from '../../../../../shared/models/videos'
import { environment } from '../../../environments/environment'
@Injectable() @Injectable()
export class ServerService { export class ServerService {
@ -47,12 +48,6 @@ export class ServerService {
css: '' css: ''
} }
}, },
search: {
remoteUri: {
users: true,
anonymous: false
}
},
plugin: { plugin: {
registered: [], registered: [],
registeredExternalAuths: [], registeredExternalAuths: [],
@ -145,6 +140,18 @@ export class ServerService {
message: '', message: '',
level: 'info', level: 'info',
dismissable: false dismissable: false
},
search: {
remoteUri: {
users: true,
anonymous: false
},
searchIndex: {
enabled: false,
url: '',
disableLocalSearch: false,
isDefaultSearch: false
}
} }
} }
@ -264,6 +271,20 @@ export class ServerService {
return this.http.get<ServerStats>(ServerService.BASE_STATS_URL) return this.http.get<ServerStats>(ServerService.BASE_STATS_URL)
} }
getDefaultSearchTarget (): Promise<SearchTargetType> {
return this.getConfig().pipe(
map(config => {
const searchIndexConfig = config.search.searchIndex
if (searchIndexConfig.enabled && (searchIndexConfig.isDefaultSearch || searchIndexConfig.disableLocalSearch)) {
return 'search-index'
}
return 'local'
})
).toPromise()
}
private loadAttributeEnum <T extends string | number> ( private loadAttributeEnum <T extends string | number> (
baseUrl: string, baseUrl: string,
attributeName: 'categories' | 'licences' | 'languages' | 'privacies', attributeName: 'categories' | 'licences' | 'languages' | 'privacies',

View File

@ -1,4 +1,3 @@
export * from './header.component' export * from './header.component'
export * from './search-typeahead.component' export * from './search-typeahead.component'
export * from './suggestions.component'
export * from './suggestion.component' export * from './suggestion.component'

View File

@ -1,38 +1,43 @@
<div class="d-inline-flex position-relative" id="typeahead-container"> <div class="d-inline-flex position-relative" id="typeahead-container">
<input <input
type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…" type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…"
[(ngModel)]="search" (ngModelChange)="onSearchChange()" (keyup)="handleKey($event)" (keydown.enter)="doSearch()" [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keydown)="handleKey($event)" (keydown.enter)="doSearch()"
aria-label="Search" aria-label="Search" autocomplete="off"
> >
<span class="icon icon-search" (click)="doSearch()"></span> <span class="icon icon-search" (click)="doSearch()"></span>
<div class="position-absolute jump-to-suggestions"> <div class="position-absolute jump-to-suggestions">
<!-- suggestions -->
<my-suggestions *ngIf="search && newSearch" [results]="results" [highlight]="search" (init)="initKeyboardEventsManager($event)"></my-suggestions> <ul [hidden]="!search || !areSuggestionsOpened" role="listbox" class="p-0 m-0">
<li
*ngFor="let result of results; let i = index" class="suggestion d-flex flex-justify-start flex-items-center p-0 f5"
role="option" aria-selected="true" (mouseenter)="onSuggestionHover(i)" (click)="onSuggestionlicked(result)"
>
<my-suggestion [result]="result" [highlight]="search"></my-suggestion>
</li>
</ul>
<!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion --> <!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion -->
<div *ngIf="showHelp" id="typeahead-help" class="overflow-hidden"> <div *ngIf="showSearchGlobalHelp()" id="typeahead-help" class="overflow-hidden">
<ng-container *ngIf="activeResult.type === 'search-global'">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<label class="small-title" i18n>GLOBAL SEARCH</label> <label class="small-title" i18n>GLOBAL SEARCH</label>
<div class="advanced-search-status text-muted"> <div class="advanced-search-status text-muted">
<span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.followings.instance.autoFollowIndex.indexUrl }}</span> <span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.search.searchIndex.url }}</span>
<i class="glyphicon glyphicon-globe"></i> <i class="glyphicon glyphicon-globe"></i>
</div> </div>
</div> </div>
<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> <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>
</ng-container>
</div> </div>
<!-- search instructions, when search input is empty --> <!-- search instructions, when search input is empty -->
<div *ngIf="areInstructionsDisplayed" id="typeahead-instructions" class="overflow-hidden"> <div *ngIf="areInstructionsDisplayed()" id="typeahead-instructions" class="overflow-hidden">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<label class="small-title" i18n>ADVANCED SEARCH</label> <label class="small-title" i18n>ADVANCED SEARCH</label>
<div class="advanced-search-status c-help"> <div class="advanced-search-status c-help">
<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."> <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.">
<span *ngIf="canSearchAnyURI" class="mr-1" i18n>any instance</span> <span *ngIf="canSearchAnyURI()" class="mr-1" i18n>any instance</span>
<span *ngIf="!canSearchAnyURI" class="mr-1" i18n>only followed instances</span> <span *ngIf="!canSearchAnyURI()" class="mr-1" i18n>only followed instances</span>
<i [ngClass]="canSearchAnyURI ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i> <i [ngClass]="canSearchAnyURI() ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i>
</span> </span>
</div> </div>
</div> </div>

View File

@ -36,7 +36,7 @@
#typeahead-help, #typeahead-help,
#typeahead-instructions, #typeahead-instructions,
my-suggestions ::ng-deep ul { li.suggestion {
border: 1px solid pvar(--mainBackgroundColor); border: 1px solid pvar(--mainBackgroundColor);
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px; border-bottom-left-radius: 3px;
@ -104,7 +104,7 @@ my-suggestions ::ng-deep ul {
#typeahead-help, #typeahead-help,
#typeahead-instructions, #typeahead-instructions,
my-suggestions ::ng-deep ul { li.suggestion {
box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px; box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px;
} }
} }

View File

@ -1,23 +1,24 @@
import { Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild } from '@angular/core' import { of } from 'rxjs'
import { first, tap, delay } from 'rxjs/operators'
import { ListKeyManager } from '@angular/cdk/a11y'
import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren, AfterViewChecked } from '@angular/core'
import { ActivatedRoute, Params, Router } from '@angular/router' import { ActivatedRoute, Params, Router } from '@angular/router'
import { AuthService, ServerService } from '@app/core' import { AuthService, ServerService } from '@app/core'
import { first, tap } from 'rxjs/operators'
import { ListKeyManager } from '@angular/cdk/a11y'
import { Result, SuggestionComponent } from './suggestion.component'
import { of } from 'rxjs'
import { ServerConfig } from '@shared/models' import { ServerConfig } from '@shared/models'
import { SearchTargetType } from '@shared/models/search/search-target-query.model'
import { SuggestionComponent, SuggestionPayload, SuggestionPayloadType } from './suggestion.component'
@Component({ @Component({
selector: 'my-search-typeahead', selector: 'my-search-typeahead',
templateUrl: './search-typeahead.component.html', templateUrl: './search-typeahead.component.html',
styleUrls: [ './search-typeahead.component.scss' ] styleUrls: [ './search-typeahead.component.scss' ]
}) })
export class SearchTypeaheadComponent implements OnInit, OnDestroy { export class SearchTypeaheadComponent implements OnInit, AfterViewInit, AfterViewChecked, OnDestroy {
@ViewChild('searchVideo', { static: true }) searchInput: ElementRef<HTMLInputElement> @ViewChildren(SuggestionComponent) suggestionItems: QueryList<SuggestionComponent>
hasChannel = false hasChannel = false
inChannel = false inChannel = false
newSearch = true areSuggestionsOpened = true
search = '' search = ''
serverConfig: ServerConfig serverConfig: ServerConfig
@ -25,7 +26,11 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
inThisChannelText: string inThisChannelText: string
keyboardEventsManager: ListKeyManager<SuggestionComponent> keyboardEventsManager: ListKeyManager<SuggestionComponent>
results: Result[] = [] results: SuggestionPayload[] = []
activeSearch: SuggestionPayloadType
private scheduleKeyboardEventsInit = false
constructor ( constructor (
private authService: AuthService, private authService: AuthService,
@ -38,109 +43,138 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
this.route.queryParams this.route.queryParams
.pipe(first(params => this.isOnSearch() && params.search !== undefined && params.search !== null)) .pipe(first(params => this.isOnSearch() && params.search !== undefined && params.search !== null))
.subscribe(params => this.search = params.search) .subscribe(params => this.search = params.search)
}
ngAfterViewInit () {
this.serverService.getConfig() this.serverService.getConfig()
.subscribe(config => this.serverConfig = config) .subscribe(config => {
this.serverConfig = config
this.computeTypeahead()
this.serverService.configReloaded
.subscribe(config => {
this.serverConfig = config
this.computeTypeahead()
})
})
}
ngAfterViewChecked () {
if (this.scheduleKeyboardEventsInit && !this.keyboardEventsManager) {
// Avoid ExpressionChangedAfterItHasBeenCheckedError errors
setTimeout(() => this.initKeyboardEventsManager(), 0)
}
} }
ngOnDestroy () { ngOnDestroy () {
if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe() if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
} }
get activeResult () { areInstructionsDisplayed () {
return this.keyboardEventsManager?.activeItem?.result
}
get areInstructionsDisplayed () {
return !this.search return !this.search
} }
get showHelp () { showSearchGlobalHelp () {
return this.search && this.newSearch && this.activeResult?.type === 'search-global' return this.search && this.areSuggestionsOpened && this.keyboardEventsManager?.activeItem?.result?.type === 'search-index'
} }
get canSearchAnyURI () { canSearchAnyURI () {
if (!this.serverConfig) return false if (!this.serverConfig) return false
return this.authService.isLoggedIn() return this.authService.isLoggedIn()
? this.serverConfig.search.remoteUri.users ? this.serverConfig.search.remoteUri.users
: this.serverConfig.search.remoteUri.anonymous : this.serverConfig.search.remoteUri.anonymous
} }
onSearchChange () { onSearchChange () {
this.computeResults() this.computeTypeahead()
} }
computeResults () { initKeyboardEventsManager () {
this.newSearch = true if (this.keyboardEventsManager) return
let results: Result[] = []
if (this.search) { this.keyboardEventsManager = new ListKeyManager(this.suggestionItems)
results = [
/* Channel search is still unimplemented. Uncomment when it is. const activeIndex = this.suggestionItems.toArray().findIndex(i => i.result.default === true)
{ if (activeIndex === -1) {
text: this.search, console.error('Cannot find active index.', { suggestionItems: this.suggestionItems })
type: 'search-channel'
},
*/
{
text: this.search,
type: 'search-instance',
default: true
},
/* Global search is still unimplemented. Uncomment when it is.
{
text: this.search,
type: 'search-global'
},
*/
...results
]
} }
this.results = results.filter( this.updateItemsState(activeIndex)
(result: Result) => {
// if we're not in a channel or one of its videos/playlits, show all channel-related results this.keyboardEventsManager.change.subscribe(
if (!(this.hasChannel || this.inChannel)) return !result.type.includes('channel') _ => this.updateItemsState()
// if we're in a channel, show all channel-related results except for the channel redirection itself
if (this.inChannel) return result.type !== 'channel'
// all other result types are kept
return true
}
) )
} }
setEventItems (event: { items: QueryList<SuggestionComponent>, index?: number }) { computeTypeahead () {
event.items.forEach(e => { const searchIndexConfig = this.serverConfig.search.searchIndex
if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === e) {
this.keyboardEventsManager.activeItem.active = true if (!this.activeSearch) {
if (searchIndexConfig.enabled && searchIndexConfig.isDefaultSearch) {
this.activeSearch = 'search-instance'
} else { } else {
e.active = false this.activeSearch = 'search-index'
} }
}
this.areSuggestionsOpened = true
this.results = []
if (!this.search) return
if (searchIndexConfig.enabled === false || searchIndexConfig.disableLocalSearch !== true) {
this.results.push({
text: this.search,
type: 'search-instance',
default: this.activeSearch === 'search-instance'
}) })
} }
initKeyboardEventsManager (event: { items: QueryList<SuggestionComponent>, index?: number }) { if (searchIndexConfig.enabled) {
if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe() this.results.push({
text: this.search,
this.keyboardEventsManager = new ListKeyManager(event.items) type: 'search-index',
default: this.activeSearch === 'search-index'
if (event.index !== undefined) { })
this.keyboardEventsManager.setActiveItem(event.index)
} else {
this.keyboardEventsManager.setFirstItemActive()
} }
this.keyboardEventsManager.change.subscribe( this.scheduleKeyboardEventsInit = true
_ => this.setEventItems(event) }
)
updateItemsState (index?: number) {
if (index !== undefined) {
this.keyboardEventsManager.setActiveItem(index)
}
for (const item of this.suggestionItems) {
if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === item) {
item.active = true
this.activeSearch = item.result.type
continue
}
item.active = false
}
}
onSuggestionlicked (payload: SuggestionPayload) {
this.doSearch(this.buildSearchTarget(payload))
}
onSuggestionHover (index: number) {
this.updateItemsState(index)
} }
handleKey (event: KeyboardEvent) { handleKey (event: KeyboardEvent) {
event.stopImmediatePropagation()
if (!this.keyboardEventsManager) return if (!this.keyboardEventsManager) return
switch (event.key) { switch (event.key) {
case 'ArrowDown': case 'ArrowDown':
case 'ArrowUp': case 'ArrowUp':
event.stopPropagation()
this.keyboardEventsManager.onKeydown(event) this.keyboardEventsManager.onKeydown(event)
break break
} }
@ -150,15 +184,19 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
return window.location.pathname === '/search' return window.location.pathname === '/search'
} }
doSearch () { doSearch (searchTarget?: SearchTargetType) {
this.newSearch = false this.areSuggestionsOpened = false
const queryParams: Params = {} const queryParams: Params = {}
if (this.isOnSearch() && this.route.snapshot.queryParams) { if (this.isOnSearch() && this.route.snapshot.queryParams) {
Object.assign(queryParams, this.route.snapshot.queryParams) Object.assign(queryParams, this.route.snapshot.queryParams)
} }
Object.assign(queryParams, { search: this.search }) if (!searchTarget) {
searchTarget = this.buildSearchTarget(this.keyboardEventsManager.activeItem.result)
}
Object.assign(queryParams, { search: this.search, searchTarget })
const o = this.authService.isLoggedIn() const o = this.authService.isLoggedIn()
? this.loadUserLanguagesIfNeeded(queryParams) ? this.loadUserLanguagesIfNeeded(queryParams)
@ -176,4 +214,12 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
tap(() => Object.assign(queryParams, { languageOneOf: this.authService.getUser().videoLanguages })) tap(() => Object.assign(queryParams, { languageOneOf: this.authService.getUser().videoLanguages }))
) )
} }
private buildSearchTarget (result: SuggestionPayload): SearchTargetType {
if (result.type === 'search-index') {
return 'search-index'
}
return 'local'
}
} }

View File

@ -1,22 +1,17 @@
<a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active"> <a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active">
<div class="flex-shrink-0 mr-2 text-center"> <div class="flex-shrink-0 mr-2 text-center">
<my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon> <my-global-icon iconName="search"></my-global-icon>
<my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon>
</div> </div>
<img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28"> <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28">
<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> <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>
<div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6"> <div class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6">
<span *ngIf="result.type === 'search-channel'" i18n>In this channel</span>
<span *ngIf="result.type === 'search-instance'" i18n>In this instance</span> <span *ngIf="result.type === 'search-instance'" i18n>In this instance</span>
<span *ngIf="result.type === 'search-global'" i18n>In the vidiverse</span> <span *ngIf="result.type === 'search-index'" i18n>In the vidiverse</span>
<span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle"></span>
</div>
<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>
Jump to channel
<span class="d-inline-block ml-1 v-align-middle"></span>
</div> </div>
</a> </a>

View File

@ -1,24 +1,24 @@
import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy } from '@angular/core' import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy, OnChanges } from '@angular/core'
import { RouterLink } from '@angular/router' import { RouterLink } from '@angular/router'
import { ListKeyManagerOption } from '@angular/cdk/a11y' import { ListKeyManagerOption } from '@angular/cdk/a11y'
export type Result = { export type SuggestionPayload = {
text: string text: string
type: 'channel' | 'suggestion' | 'search-channel' | 'search-instance' | 'search-global' | 'search-any' type: SuggestionPayloadType
routerLink?: RouterLink, routerLink?: RouterLink
default?: boolean default: boolean
} }
export type SuggestionPayloadType = 'search-instance' | 'search-index'
@Component({ @Component({
selector: 'my-suggestion', selector: 'my-suggestion',
templateUrl: './suggestion.component.html', templateUrl: './suggestion.component.html',
styleUrls: [ './suggestion.component.scss' ], styleUrls: [ './suggestion.component.scss' ]
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class SuggestionComponent implements OnInit, ListKeyManagerOption { export class SuggestionComponent implements OnInit, ListKeyManagerOption {
@Input() result: Result @Input() result: SuggestionPayload
@Input() highlight: string @Input() highlight: string
@Output() selected = new EventEmitter()
disabled = false disabled = false
active = false active = false
@ -30,8 +30,4 @@ export class SuggestionComponent implements OnInit, ListKeyManagerOption {
ngOnInit () { ngOnInit () {
if (this.result.default) this.active = true if (this.result.default) this.active = true
} }
selectItem () {
this.selected.emit(this.result)
}
} }

View File

@ -1,6 +0,0 @@
<ul role="listbox" class="p-0 m-0">
<li *ngFor="let result of results; let i = index" class="d-flex flex-justify-start flex-items-center p-0 f5"
role="option" aria-selected="true" (mouseenter)="hoverItem(i)">
<my-suggestion [result]="result" [highlight]="highlight"></my-suggestion>
</li>
</ul>

View File

@ -1,24 +0,0 @@
import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren, ChangeDetectionStrategy } from '@angular/core'
import { SuggestionComponent } from './suggestion.component'
@Component({
selector: 'my-suggestions',
templateUrl: './suggestions.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SuggestionsComponent implements AfterViewInit {
@Input() results: any[]
@Input() highlight: string
@ViewChildren(SuggestionComponent) listItems: QueryList<SuggestionComponent>
@Output() init = new EventEmitter()
ngAfterViewInit () {
this.listItems.changes.subscribe(
_ => this.init.emit({ items: this.listItems })
)
}
hoverItem (index: number) {
this.init.emit({ items: this.listItems, index: index })
}
}

View File

@ -1,3 +1,4 @@
import { SearchTargetType } from '@shared/models/search/search-target-query.model'
import { NSFWQuery } from '../../../../shared/models/search' import { NSFWQuery } from '../../../../shared/models/search'
export class AdvancedSearch { export class AdvancedSearch {
@ -23,6 +24,11 @@ export class AdvancedSearch {
sort: string sort: string
searchTarget: SearchTargetType
// Filters we don't want to count, because they are mandatory
private silentFilters = new Set([ 'sort', 'searchTarget' ])
constructor (options?: { constructor (options?: {
startDate?: string startDate?: string
endDate?: string endDate?: string
@ -37,6 +43,7 @@ export class AdvancedSearch {
durationMin?: string durationMin?: string
durationMax?: string durationMax?: string
sort?: string sort?: string
searchTarget?: SearchTargetType
}) { }) {
if (!options) return if (!options) return
@ -54,6 +61,8 @@ export class AdvancedSearch {
this.durationMin = parseInt(options.durationMin, 10) this.durationMin = parseInt(options.durationMin, 10)
this.durationMax = parseInt(options.durationMax, 10) this.durationMax = parseInt(options.durationMax, 10)
this.searchTarget = options.searchTarget || undefined
if (isNaN(this.durationMin)) this.durationMin = undefined if (isNaN(this.durationMin)) this.durationMin = undefined
if (isNaN(this.durationMax)) this.durationMax = undefined if (isNaN(this.durationMax)) this.durationMax = undefined
@ -61,9 +70,11 @@ export class AdvancedSearch {
} }
containsValues () { containsValues () {
const exceptions = new Set([ 'sort', 'searchTarget' ])
const obj = this.toUrlObject() const obj = this.toUrlObject()
for (const k of Object.keys(obj)) { for (const k of Object.keys(obj)) {
if (k === 'sort') continue // Exception if (this.silentFilters.has(k)) continue
if (obj[k] !== undefined && obj[k] !== '') return true if (obj[k] !== undefined && obj[k] !== '') return true
} }
@ -102,7 +113,8 @@ export class AdvancedSearch {
tagsAllOf: this.tagsAllOf, tagsAllOf: this.tagsAllOf,
durationMin: this.durationMin, durationMin: this.durationMin,
durationMax: this.durationMax, durationMax: this.durationMax,
sort: this.sort sort: this.sort,
searchTarget: this.searchTarget
} }
} }
@ -120,7 +132,8 @@ export class AdvancedSearch {
tagsAllOf: this.intoArray(this.tagsAllOf), tagsAllOf: this.intoArray(this.tagsAllOf),
durationMin: this.durationMin, durationMin: this.durationMin,
durationMax: this.durationMax, durationMax: this.durationMax,
sort: this.sort sort: this.sort,
searchTarget: this.searchTarget
} }
} }
@ -129,7 +142,7 @@ export class AdvancedSearch {
const obj = this.toUrlObject() const obj = this.toUrlObject()
for (const k of Object.keys(obj)) { for (const k of Object.keys(obj)) {
if (k === 'sort') continue // Exception if (this.silentFilters.has(k)) continue
if (obj[k] !== undefined && obj[k] !== '') acc++ if (obj[k] !== undefined && obj[k] !== '') acc++
} }

View File

@ -0,0 +1,45 @@
import { map } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
import { SearchService } from './search.service'
import { RedirectService } from '@app/core'
@Injectable()
export class ChannelLazyLoadResolver implements Resolve<any> {
constructor (
private router: Router,
private searchService: SearchService,
private redirectService: RedirectService
) { }
resolve (route: ActivatedRouteSnapshot) {
const url = route.params.url
const externalRedirect = route.params.externalRedirect
const fromPath = route.params.fromPath
if (!url) {
console.error('Could not find url param.', { params: route.params })
return this.router.navigateByUrl('/404')
}
if (externalRedirect === 'true') {
window.open(url)
this.router.navigateByUrl(fromPath)
return
}
return this.searchService.searchVideoChannels({ search: url })
.pipe(
map(result => {
if (result.data.length !== 1) {
console.error('Cannot find result for this URL')
return this.router.navigateByUrl('/404')
}
const channel = result.data[0]
return this.router.navigateByUrl('/video-channels/' + channel.nameWithHost)
})
)
}
}

View File

@ -16,6 +16,25 @@
</div> </div>
</div> </div>
<div class="form-group">
<div class="radio-label label-container">
<label i18n>Display sensitive content</label>
<button i18n class="reset-button reset-button-small" (click)="resetField('nsfw')" *ngIf="advancedSearch.nsfw !== undefined">
Reset
</button>
</div>
<div class="peertube-radio-container">
<input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch.nsfw">
<label i18n for="sensitiveContentYes" class="radio">Yes</label>
</div>
<div class="peertube-radio-container">
<input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch.nsfw">
<label i18n for="sensitiveContentNo" class="radio">No</label>
</div>
</div>
<div class="form-group"> <div class="form-group">
<div class="radio-label label-container"> <div class="radio-label label-container">
<label i18n>Published date</label> <label i18n>Published date</label>
@ -39,7 +58,7 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm-6"> <div class="pl-0 col-sm-6">
<input <input
(change)="inputUpdated()" (change)="inputUpdated()"
(keydown.enter)="$event.preventDefault()" (keydown.enter)="$event.preventDefault()"
@ -49,7 +68,7 @@
class="form-control" class="form-control"
> >
</div> </div>
<div class="col-sm-6"> <div class="pr-0 col-sm-6">
<input <input
(change)="inputUpdated()" (change)="inputUpdated()"
(keydown.enter)="$event.preventDefault()" (keydown.enter)="$event.preventDefault()"
@ -62,6 +81,9 @@
</div> </div>
</div> </div>
</div>
<div class="col-lg-4 col-md-6 col-xs-12">
<div class="form-group"> <div class="form-group">
<div class="radio-label label-container"> <div class="radio-label label-container">
<label i18n>Duration</label> <label i18n>Duration</label>
@ -76,28 +98,6 @@
</div> </div>
</div> </div>
<div class="form-group">
<div class="radio-label label-container">
<label i18n>Display sensitive content</label>
<button i18n class="reset-button reset-button-small" (click)="resetField('nsfw')" *ngIf="advancedSearch.nsfw !== undefined">
Reset
</button>
</div>
<div class="peertube-radio-container">
<input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch.nsfw">
<label i18n for="sensitiveContentYes" class="radio">Yes</label>
</div>
<div class="peertube-radio-container">
<input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch.nsfw">
<label i18n for="sensitiveContentNo" class="radio">No</label>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-xs-12">
<div class="form-group"> <div class="form-group">
<label i18n for="category">Category</label> <label i18n for="category">Category</label>
<button i18n class="reset-button reset-button-small" (click)="resetField('categoryOneOf')" *ngIf="advancedSearch.categoryOneOf !== undefined"> <button i18n class="reset-button reset-button-small" (click)="resetField('categoryOneOf')" *ngIf="advancedSearch.categoryOneOf !== undefined">
@ -164,6 +164,22 @@
[maxItems]="5" [modelAsStrings]="true" [maxItems]="5" [modelAsStrings]="true"
></tag-input> ></tag-input>
</div> </div>
<div class="form-group" *ngIf="isSearchTargetEnabled()">
<div class="radio-label label-container">
<label i18n>Search target</label>
</div>
<div class="peertube-radio-container">
<input type="radio" name="searchTarget" id="searchTargetLocal" value="local" [(ngModel)]="advancedSearch.searchTarget">
<label i18n for="searchTargetLocal" class="radio">Instance</label>
</div>
<div class="peertube-radio-container">
<input type="radio" name="searchTarget" id="searchTargetSearchIndex" value="search-index" [(ngModel)]="advancedSearch.searchTarget">
<label i18n for="searchTargetSearchIndex" class="radio">Vidiverse</label>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -44,7 +44,7 @@ export class SearchFiltersComponent implements OnInit {
this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES
this.publishedDateRanges = [ this.publishedDateRanges = [
{ {
id: undefined, id: 'any_published_date',
label: this.i18n('Any') label: this.i18n('Any')
}, },
{ {
@ -67,7 +67,7 @@ export class SearchFiltersComponent implements OnInit {
this.durationRanges = [ this.durationRanges = [
{ {
id: undefined, id: 'any_duration',
label: this.i18n('Any') label: this.i18n('Any')
}, },
{ {
@ -147,6 +147,10 @@ export class SearchFiltersComponent implements OnInit {
this.originallyPublishedStartYear = this.originallyPublishedEndYear = undefined this.originallyPublishedStartYear = this.originallyPublishedEndYear = undefined
} }
isSearchTargetEnabled () {
return this.serverConfig.search.searchIndex.enabled && this.serverConfig.search.searchIndex.disableLocalSearch !== true
}
private loadOriginallyPublishedAtYears () { private loadOriginallyPublishedAtYears () {
this.originallyPublishedStartYear = this.advancedSearch.originallyPublishedStartDate this.originallyPublishedStartYear = this.advancedSearch.originallyPublishedStartDate
? new Date(this.advancedSearch.originallyPublishedStartDate).getFullYear().toString() ? new Date(this.advancedSearch.originallyPublishedStartDate).getFullYear().toString()

View File

@ -1,7 +1,9 @@
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router' import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { SearchComponent } from '@app/search/search.component' import { SearchComponent } from '@app/search/search.component'
import { MetaGuard } from '@ngx-meta/core'
import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
const searchRoutes: Routes = [ const searchRoutes: Routes = [
{ {
@ -13,6 +15,22 @@ const searchRoutes: Routes = [
title: 'Search' title: 'Search'
} }
} }
},
{
path: 'search/lazy-load-video',
component: SearchComponent,
canActivate: [ MetaGuard ],
resolve: {
data: VideoLazyLoadResolver
}
},
{
path: 'search/lazy-load-channel',
component: SearchComponent,
canActivate: [ MetaGuard ],
resolve: {
data: ChannelLazyLoadResolver
}
} }
] ]

View File

@ -2,7 +2,11 @@
<div class="results-header"> <div class="results-header">
<div class="first-line"> <div class="first-line">
<div class="results-counter" *ngIf="pagination.totalItems"> <div class="results-counter" *ngIf="pagination.totalItems">
<span i18n>{{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}}</span> <span i18n>{{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}} </span>
<span i18n *ngIf="advancedSearch.searchTarget === 'local'">on this instance</span>
<span i18n *ngIf="advancedSearch.searchTarget === 'search-index'">on the vidiverse</span>
<span *ngIf="currentSearch" i18n> <span *ngIf="currentSearch" i18n>
for <span class="search-value">{{ currentSearch }}</span> for <span class="search-value">{{ currentSearch }}</span>
</span> </span>
@ -31,12 +35,12 @@
<ng-container *ngFor="let result of results"> <ng-container *ngFor="let result of results">
<div *ngIf="isVideoChannel(result)" class="entry video-channel"> <div *ngIf="isVideoChannel(result)" class="entry video-channel">
<a [routerLink]="[ '/video-channels', result.nameWithHost ]"> <a [routerLink]="getChannelUrl(result)">
<img [src]="result.avatarUrl" alt="Avatar" /> <img [src]="result.avatarUrl" alt="Avatar" />
</a> </a>
<div class="video-channel-info"> <div class="video-channel-info">
<a [routerLink]="[ '/video-channels', result.nameWithHost ]" class="video-channel-names"> <a [routerLink]="getChannelUrl(result)" class="video-channel-names">
<div class="video-channel-display-name">{{ result.displayName }}</div> <div class="video-channel-display-name">{{ result.displayName }}</div>
<div class="video-channel-name">{{ result.nameWithHost }}</div> <div class="video-channel-name">{{ result.nameWithHost }}</div>
</a> </a>
@ -44,12 +48,13 @@
<div i18n class="video-channel-followers">{{ result.followersCount }} subscribers</div> <div i18n class="video-channel-followers">{{ result.followersCount }} subscribers</div>
</div> </div>
<my-subscribe-button [videoChannels]="[result]"></my-subscribe-button> <my-subscribe-button *ngIf="!hideActions()" [videoChannels]="[result]"></my-subscribe-button>
</div> </div>
<div *ngIf="isVideo(result)" class="entry video"> <div *ngIf="isVideo(result)" class="entry video">
<my-video-miniature <my-video-miniature
[video]="result" [user]="user" [displayAsRow]="true" [video]="result" [user]="user" [displayAsRow]="true" [displayVideoActions]="!hideActions()"
[useLazyLoadUrl]="advancedSearch.searchTarget === 'search-index'"
(videoBlacklisted)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)" (videoBlacklisted)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)"
></my-video-miniature> ></my-video-miniature>
</div> </div>

View File

@ -1,16 +1,18 @@
import { forkJoin, of, Subscription } from 'rxjs'
import { Component, OnDestroy, OnInit } from '@angular/core' import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, Notifier } from '@app/core' import { AuthService, Notifier, ServerService } from '@app/core'
import { forkJoin, of, Subscription } from 'rxjs'
import { SearchService } from '@app/search/search.service'
import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { MetaService } from '@ngx-meta/core'
import { AdvancedSearch } from '@app/search/advanced-search.model'
import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
import { immutableAssign } from '@app/shared/misc/utils'
import { Video } from '@app/shared/video/video.model'
import { HooksService } from '@app/core/plugins/hooks.service' import { HooksService } from '@app/core/plugins/hooks.service'
import { AdvancedSearch } from '@app/search/advanced-search.model'
import { SearchService } from '@app/search/search.service'
import { immutableAssign } from '@app/shared/misc/utils'
import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
import { Video } from '@app/shared/video/video.model'
import { MetaService } from '@ngx-meta/core'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { ServerConfig } from '@shared/models'
import { UserService } from '@app/shared'
@Component({ @Component({
selector: 'my-search', selector: 'my-search',
@ -29,6 +31,9 @@ export class SearchComponent implements OnInit, OnDestroy {
isSearchFilterCollapsed = true isSearchFilterCollapsed = true
currentSearch: string currentSearch: string
errorMessage: string
serverConfig: ServerConfig
private subActivatedRoute: Subscription private subActivatedRoute: Subscription
private isInitialLoad = false // set to false to show the search filters on first arrival private isInitialLoad = false // set to false to show the search filters on first arrival
private firstSearch = true private firstSearch = true
@ -43,7 +48,8 @@ export class SearchComponent implements OnInit, OnDestroy {
private notifier: Notifier, private notifier: Notifier,
private searchService: SearchService, private searchService: SearchService,
private authService: AuthService, private authService: AuthService,
private hooks: HooksService private hooks: HooksService,
private serverService: ServerService
) { } ) { }
get user () { get user () {
@ -51,8 +57,11 @@ export class SearchComponent implements OnInit, OnDestroy {
} }
ngOnInit () { ngOnInit () {
this.serverService.getConfig()
.subscribe(config => this.serverConfig = config)
this.subActivatedRoute = this.route.queryParams.subscribe( this.subActivatedRoute = this.route.queryParams.subscribe(
queryParams => { async queryParams => {
const querySearch = queryParams['search'] const querySearch = queryParams['search']
// Search updated, reset filters // Search updated, reset filters
@ -65,6 +74,9 @@ export class SearchComponent implements OnInit, OnDestroy {
} }
this.advancedSearch = new AdvancedSearch(queryParams) this.advancedSearch = new AdvancedSearch(queryParams)
if (!this.advancedSearch.searchTarget) {
this.advancedSearch.searchTarget = await this.serverService.getDefaultSearchTarget()
}
// Don't hide filters if we have some of them AND the user just came on the webpage // Don't hide filters if we have some of them AND the user just came on the webpage
this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues() this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues()
@ -99,12 +111,12 @@ export class SearchComponent implements OnInit, OnDestroy {
forkJoin([ forkJoin([
this.getVideosObs(), this.getVideosObs(),
this.getVideoChannelObs() this.getVideoChannelObs()
]) ]).subscribe(
.subscribe( ([videosResult, videoChannelsResult]) => {
([ videosResult, videoChannelsResult ]) => {
this.results = this.results this.results = this.results
.concat(videoChannelsResult.data) .concat(videoChannelsResult.data)
.concat(videosResult.data) .concat(videosResult.data)
this.pagination.totalItems = videosResult.total + videoChannelsResult.total this.pagination.totalItems = videosResult.total + videoChannelsResult.total
// Focus on channels if there are no enough videos // Focus on channels if there are no enough videos
@ -119,7 +131,16 @@ export class SearchComponent implements OnInit, OnDestroy {
this.firstSearch = false this.firstSearch = false
}, },
err => this.notifier.error(err.message) err => {
if (this.advancedSearch.searchTarget !== 'search-index') this.notifier.error(err.message)
this.notifier.error(
this.i18n('Search index is unavailable. Retrying with instance results instead.'),
this.i18n('Search error')
)
this.advancedSearch.searchTarget = 'local'
this.search()
}
) )
} }
@ -146,6 +167,24 @@ export class SearchComponent implements OnInit, OnDestroy {
this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id) this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
} }
getChannelUrl (channel: VideoChannel) {
if (this.advancedSearch.searchTarget === 'search-index' && channel.url) {
const remoteUriConfig = this.serverConfig.search.remoteUri
// Redirect on the external instance if not allowed to fetch remote data
const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
const fromPath = window.location.pathname + window.location.search
return [ '/search/lazy-load-channel', { url: channel.url, externalRedirect, fromPath } ]
}
return [ '/video-channels', channel.nameWithHost ]
}
hideActions () {
return this.advancedSearch.searchTarget === 'search-index'
}
private resetPagination () { private resetPagination () {
this.pagination.currentPage = 1 this.pagination.currentPage = 1
this.pagination.totalItems = null this.pagination.totalItems = null
@ -189,7 +228,8 @@ export class SearchComponent implements OnInit, OnDestroy {
const params = { const params = {
search: this.currentSearch, search: this.currentSearch,
componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }) componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }),
searchTarget: this.advancedSearch.searchTarget
} }
return this.hooks.wrapObsFun( return this.hooks.wrapObsFun(

View File

@ -1,10 +1,12 @@
import { NgModule } from '@angular/core'
import { TagInputModule } from 'ngx-chips' import { TagInputModule } from 'ngx-chips'
import { SharedModule } from '../shared' import { NgModule } from '@angular/core'
import { SearchFiltersComponent } from '@app/search/search-filters.component'
import { SearchRoutingModule } from '@app/search/search-routing.module'
import { SearchComponent } from '@app/search/search.component' import { SearchComponent } from '@app/search/search.component'
import { SearchService } from '@app/search/search.service' import { SearchService } from '@app/search/search.service'
import { SearchRoutingModule } from '@app/search/search-routing.module' import { SharedModule } from '../shared'
import { SearchFiltersComponent } from '@app/search/search-filters.component' import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
@NgModule({ @NgModule({
imports: [ imports: [
@ -25,7 +27,9 @@ import { SearchFiltersComponent } from '@app/search/search-filters.component'
], ],
providers: [ providers: [
SearchService SearchService,
VideoLazyLoadResolver,
ChannelLazyLoadResolver
] ]
}) })
export class SearchModule { } export class SearchModule { }

View File

@ -1,17 +1,18 @@
import { Observable } from 'rxjs'
import { catchError, map, switchMap } from 'rxjs/operators' import { catchError, map, switchMap } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http' import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
import { VideoService } from '@app/shared/video/video.service'
import { RestExtractor, RestService } from '@app/shared'
import { environment } from '../../environments/environment'
import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared'
import { Video } from '@app/shared/video/video.model'
import { AdvancedSearch } from '@app/search/advanced-search.model' import { AdvancedSearch } from '@app/search/advanced-search.model'
import { RestExtractor, RestPagination, RestService } from '@app/shared'
import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
import { VideoChannel } from '@app/shared/video-channel/video-channel.model' import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' import { Video } from '@app/shared/video/video.model'
import { VideoService } from '@app/shared/video/video.service'
import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared'
import { environment } from '../../environments/environment'
import { SearchTargetType } from '@shared/models/search/search-target-query.model'
@Injectable() @Injectable()
export class SearchService { export class SearchService {
@ -30,21 +31,27 @@ export class SearchService {
searchVideos (parameters: { searchVideos (parameters: {
search: string, search: string,
componentPagination: ComponentPaginationLight, componentPagination?: ComponentPaginationLight,
advancedSearch: AdvancedSearch advancedSearch?: AdvancedSearch
}): Observable<ResultList<Video>> { }): Observable<ResultList<Video>> {
const { search, componentPagination, advancedSearch } = parameters const { search, componentPagination, advancedSearch } = parameters
const url = SearchService.BASE_SEARCH_URL + 'videos' const url = SearchService.BASE_SEARCH_URL + 'videos'
const pagination = this.restService.componentPaginationToRestPagination(componentPagination) let pagination: RestPagination
if (componentPagination) {
pagination = this.restService.componentPaginationToRestPagination(componentPagination)
}
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination) params = this.restService.addRestGetParams(params, pagination)
if (search) params = params.append('search', search) if (search) params = params.append('search', search)
if (advancedSearch) {
const advancedSearchObject = advancedSearch.toAPIObject() const advancedSearchObject = advancedSearch.toAPIObject()
params = this.restService.addObjectParams(params, advancedSearchObject) params = this.restService.addObjectParams(params, advancedSearchObject)
}
return this.authHttp return this.authHttp
.get<ResultList<VideoServerModel>>(url, { params }) .get<ResultList<VideoServerModel>>(url, { params })
@ -56,17 +63,26 @@ export class SearchService {
searchVideoChannels (parameters: { searchVideoChannels (parameters: {
search: string, search: string,
componentPagination: ComponentPaginationLight searchTarget?: SearchTargetType,
componentPagination?: ComponentPaginationLight
}): Observable<ResultList<VideoChannel>> { }): Observable<ResultList<VideoChannel>> {
const { search, componentPagination } = parameters const { search, componentPagination, searchTarget } = parameters
const url = SearchService.BASE_SEARCH_URL + 'video-channels' const url = SearchService.BASE_SEARCH_URL + 'video-channels'
const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
let pagination: RestPagination
if (componentPagination) {
pagination = this.restService.componentPaginationToRestPagination(componentPagination)
}
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination) params = this.restService.addRestGetParams(params, pagination)
params = params.append('search', search) params = params.append('search', search)
if (searchTarget) {
params = params.append('searchTarget', searchTarget as string)
}
return this.authHttp return this.authHttp
.get<ResultList<VideoChannelServerModel>>(url, { params }) .get<ResultList<VideoChannelServerModel>>(url, { params })
.pipe( .pipe(

View File

@ -0,0 +1,43 @@
import { map } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
import { SearchService } from './search.service'
@Injectable()
export class VideoLazyLoadResolver implements Resolve<any> {
constructor (
private router: Router,
private searchService: SearchService
) { }
resolve (route: ActivatedRouteSnapshot) {
const url = route.params.url
const externalRedirect = route.params.externalRedirect
const fromPath = route.params.fromPath
if (!url) {
console.error('Could not find url param.', { params: route.params })
return this.router.navigateByUrl('/404')
}
if (externalRedirect === 'true') {
window.open(url)
this.router.navigateByUrl(fromPath)
return
}
return this.searchService.searchVideos({ search: url })
.pipe(
map(result => {
if (result.data.length !== 1) {
console.error('Cannot find result for this URL')
return this.router.navigateByUrl('/404')
}
const video = result.data[0]
return this.router.navigateByUrl('/videos/watch/' + video.uuid)
})
)
}
}

View File

@ -15,10 +15,14 @@ export abstract class Actor implements ActorServer {
avatarUrl: string avatarUrl: string
static GET_ACTOR_AVATAR_URL (actor: { avatar?: { path: string } }) { static GET_ACTOR_AVATAR_URL (actor: { avatar?: Avatar }) {
if (actor?.avatar?.url) return actor.avatar.url
if (actor && actor.avatar) {
const absoluteAPIUrl = getAbsoluteAPIUrl() const absoluteAPIUrl = getAbsoluteAPIUrl()
if (actor && actor.avatar) return absoluteAPIUrl + actor.avatar.path return absoluteAPIUrl + actor.avatar.path
}
return this.GET_DEFAULT_AVATAR_URL() return this.GET_DEFAULT_AVATAR_URL()
} }

View File

@ -11,9 +11,6 @@ export class HighlightPipe implements PipeTransform {
/* use this for global search */ /* use this for global search */
static MULTI_MATCH = 'Multi-Match' static MULTI_MATCH = 'Multi-Match'
// tslint:disable-next-line:no-empty
constructor () {}
transform ( transform (
contentString: string = null, contentString: string = null,
stringToHighlight: string = null, stringToHighlight: string = null,
@ -24,6 +21,7 @@ export class HighlightPipe implements PipeTransform {
if (stringToHighlight && contentString && option) { if (stringToHighlight && contentString && option) {
let regex: any = '' let regex: any = ''
const caseFlag: string = !caseSensitive ? 'i' : '' const caseFlag: string = !caseSensitive ? 'i' : ''
switch (option) { switch (option) {
case 'Single-Match': { case 'Single-Match': {
regex = new RegExp(stringToHighlight, caseFlag) regex = new RegExp(stringToHighlight, caseFlag)
@ -42,10 +40,12 @@ export class HighlightPipe implements PipeTransform {
regex = new RegExp(stringToHighlight, 'gi') regex = new RegExp(stringToHighlight, 'gi')
} }
} }
const replaced = contentString.replace( const replaced = contentString.replace(
regex, regex,
(match) => `<span class="${highlightStyleName}">${match}</span>` (match) => `<span class="${highlightStyleName}">${match}</span>`
) )
return replaced return replaced
} else { } else {
return contentString return contentString

View File

@ -14,6 +14,7 @@ export class CustomConfigValidatorsService {
readonly ADMIN_EMAIL: BuildFormValidator readonly ADMIN_EMAIL: BuildFormValidator
readonly TRANSCODING_THREADS: BuildFormValidator readonly TRANSCODING_THREADS: BuildFormValidator
readonly INDEX_URL: BuildFormValidator readonly INDEX_URL: BuildFormValidator
readonly SEARCH_INDEX_URL: BuildFormValidator
constructor (private i18n: I18n) { constructor (private i18n: I18n) {
this.INSTANCE_NAME = { this.INSTANCE_NAME = {
@ -86,5 +87,12 @@ export class CustomConfigValidatorsService {
'pattern': this.i18n('Index URL should be a URL') 'pattern': this.i18n('Index URL should be a URL')
} }
} }
this.SEARCH_INDEX_URL = {
VALIDATORS: [ Validators.pattern(/^https?:\/\//) ],
MESSAGES: {
'pattern': this.i18n('Search index URL should be a URL')
}
}
} }
} }

View File

@ -1,4 +1,4 @@
import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '../../../../../shared' import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, Avatar } from '../../../../../shared'
import { Actor } from '@app/shared/actor/actor.model' import { Actor } from '@app/shared/actor/actor.model'
export class UserNotification implements UserNotificationServer { export class UserNotification implements UserNotificationServer {
@ -178,7 +178,7 @@ export class UserNotification implements UserNotificationServer {
return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
} }
private setAvatarUrl (actor: { avatarUrl?: string, avatar?: { path: string } }) { private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) {
actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor) actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
} }
} }

View File

@ -1,6 +1,6 @@
<div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow, 'fit-width': fitWidth }" (mouseenter)="loadActions()"> <div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow, 'fit-width': fitWidth }" (mouseenter)="loadActions()">
<my-video-thumbnail <my-video-thumbnail
[video]="video" [nsfw]="isVideoBlur" [video]="video" [nsfw]="isVideoBlur" [routerLink]="videoLink"
[displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)" [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
> >
<ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container> <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
@ -12,7 +12,7 @@
<a <a
tabindex="-1" tabindex="-1"
class="video-miniature-name" class="video-miniature-name"
[routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" [routerLink]="videoLink" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
>{{ video.name }}</a> >{{ video.name }}</a>
<div class="d-inline-flex"> <div class="d-inline-flex">

View File

@ -1,3 +1,4 @@
import { switchMap } from 'rxjs/operators'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
@ -9,15 +10,14 @@ import {
OnInit, OnInit,
Output Output
} from '@angular/core' } from '@angular/core'
import { User } from '../users'
import { Video } from './video.model'
import { AuthService, ServerService } from '@app/core' import { AuthService, ServerService } from '@app/core'
import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component'
import { ScreenService } from '@app/shared/misc/screen.service' import { ScreenService } from '@app/shared/misc/screen.service'
import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
import { switchMap } from 'rxjs/operators' import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared'
import { User } from '../users'
import { Video } from './video.model'
export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto'
export type MiniatureDisplayOptions = { export type MiniatureDisplayOptions = {
@ -57,6 +57,8 @@ export class VideoMiniatureComponent implements OnInit {
@Input() displayVideoActions = true @Input() displayVideoActions = true
@Input() fitWidth = false @Input() fitWidth = false
@Input() useLazyLoadUrl = false
@Output() videoBlacklisted = new EventEmitter() @Output() videoBlacklisted = new EventEmitter()
@Output() videoUnblacklisted = new EventEmitter() @Output() videoUnblacklisted = new EventEmitter()
@Output() videoRemoved = new EventEmitter() @Output() videoRemoved = new EventEmitter()
@ -82,6 +84,8 @@ export class VideoMiniatureComponent implements OnInit {
playlistElementId?: number playlistElementId?: number
} }
videoLink: any[] = []
private ownerDisplayTypeChosen: 'account' | 'videoChannel' private ownerDisplayTypeChosen: 'account' | 'videoChannel'
constructor ( constructor (
@ -103,7 +107,10 @@ export class VideoMiniatureComponent implements OnInit {
ngOnInit () { ngOnInit () {
this.serverConfig = this.serverService.getTmpConfig() this.serverConfig = this.serverService.getTmpConfig()
this.serverService.getConfig() this.serverService.getConfig()
.subscribe(config => this.serverConfig = config) .subscribe(config => {
this.serverConfig = config
this.buildVideoLink()
})
this.setUpBy() this.setUpBy()
@ -113,6 +120,21 @@ export class VideoMiniatureComponent implements OnInit {
} }
} }
buildVideoLink () {
if (this.useLazyLoadUrl && this.video.url) {
const remoteUriConfig = this.serverConfig.search.remoteUri
// Redirect on the external instance if not allowed to fetch remote data
const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
const fromPath = window.location.pathname + window.location.search
this.videoLink = [ '/search/lazy-load-video', { url: this.video.url, externalRedirect, fromPath } ]
return
}
this.videoLink = [ '/videos/watch', this.video.uuid ]
}
displayOwnerAccount () { displayOwnerAccount () {
return this.ownerDisplayTypeChosen === 'account' return this.ownerDisplayTypeChosen === 'account'
} }
@ -203,7 +225,7 @@ export class VideoMiniatureComponent implements OnInit {
} }
isWatchLaterPlaylistDisplayed () { isWatchLaterPlaylistDisplayed () {
return this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined return this.displayVideoActions && this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
} }
private setUpBy () { private setUpBy () {

View File

@ -33,10 +33,15 @@ export class Video implements VideoServerModel {
serverHost: string serverHost: string
thumbnailPath: string thumbnailPath: string
thumbnailUrl: string thumbnailUrl: string
previewPath: string previewPath: string
previewUrl: string previewUrl: string
embedPath: string embedPath: string
embedUrl: string embedUrl: string
url?: string
views: number views: number
likes: number likes: number
dislikes: number dislikes: number
@ -100,13 +105,15 @@ export class Video implements VideoServerModel {
this.name = hash.name this.name = hash.name
this.thumbnailPath = hash.thumbnailPath this.thumbnailPath = hash.thumbnailPath
this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath this.thumbnailUrl = hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
this.previewPath = hash.previewPath this.previewPath = hash.previewPath
this.previewUrl = absoluteAPIUrl + hash.previewPath this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
this.embedPath = hash.embedPath this.embedPath = hash.embedPath
this.embedUrl = absoluteAPIUrl + hash.embedPath this.embedUrl = hash.embedUrl || (absoluteAPIUrl + hash.embedPath)
this.url = hash.url
this.views = hash.views this.views = hash.views
this.likes = hash.likes this.likes = hash.likes

View File

@ -94,14 +94,6 @@ log:
maxFiles: 20 maxFiles: 20
anonymizeIP: false anonymizeIP: false
search:
# Add ability to fetch remote videos/actors by their URI, that may not be federated with your instance
# If enabled, the associated group will be able to "escape" from the instance follows
# That means they will be able to follow channels, watch videos, list videos of non followed instances
remote_uri:
users: true
anonymous: false
trending: trending:
videos: videos:
interval_days: 7 # Compute trending videos for the last x days interval_days: 7 # Compute trending videos for the last x days
@ -382,3 +374,28 @@ broadcast_message:
message: '' # Support markdown message: '' # Support markdown
level: 'info' # 'info' | 'warning' | 'error' level: 'info' # 'info' | 'warning' | 'error'
dismissable: false dismissable: false
search:
# Add ability to fetch remote videos/actors by their URI, that may not be federated with your instance
# If enabled, the associated group will be able to "escape" from the instance follows
# That means they will be able to follow channels, watch videos, list videos of non followed instances
remote_uri:
users: true
anonymous: false
# Use a third party index instead of your local index, only for search results
# Useful to discover content outside of your instance
# If you enable search_index, you must enable remote_uri search for users
# If you do not enable remote_uri search for anonymous user, your instance will redirect the user on the origin instance
# instead of loading the video locally
search_index:
enabled: false
# URL of the search index, that should use the same search API and routes
# than PeerTube: https://docs.joinpeertube.org/api-rest-reference.html
# You should deploy your own with https://framagit.org/framasoft/peertube/search-index,
# and can use https://search.joinpeertube.org/ for tests, but keep in mind the latter is an unmoderated search index
url: ''
# You can disable local search, so users only use the search index
disable_local_search: false
# If you did not disable local search, you can decide to use the search index by default
is_default_search: false

View File

@ -95,14 +95,6 @@ log:
maxFiles: 20 maxFiles: 20
anonymizeIP: false anonymizeIP: false
search:
# Add ability to fetch remote videos/actors by their URI, that may not be federated with your instance
# If enabled, the associated group will be able to "escape" from the instance follows
# That means they will be able to follow channels, watch videos, list videos of non followed instances
remote_uri:
users: true
anonymous: false
trending: trending:
videos: videos:
interval_days: 7 # Compute trending videos for the last x days interval_days: 7 # Compute trending videos for the last x days
@ -396,3 +388,28 @@ broadcast_message:
message: '' # Support markdown message: '' # Support markdown
level: 'info' # 'info' | 'warning' | 'error' level: 'info' # 'info' | 'warning' | 'error'
dismissable: false dismissable: false
search:
# Add ability to fetch remote videos/actors by their URI, that may not be federated with your instance
# If enabled, the associated group will be able to "escape" from the instance follows
# That means they will be able to follow channels, watch videos, list videos of non followed instances
remote_uri:
users: true
anonymous: false
# Use a third party index instead of your local index, only for search results
# Useful to discover content outside of your instance
# If you enable search_index, you must enable remote_uri search for users
# If you do not enable remote_uri search for anonymous user, your instance will redirect the user on the origin instance
# instead of loading the video locally
search_index:
enabled: false
# URL of the search index, that should use the same search API and routes
# than PeerTube: https://docs.joinpeertube.org/api-rest-reference.html
# You should deploy your own with https://framagit.org/framasoft/peertube/search-index,
# and can use https://search.joinpeertube.org/ for tests, but keep in mind the latter is an unmoderated search index
url: ''
# You can disable local search, so users only use the search index
disable_local_search: false
# If you did not disable local search, you can decide to use the search index by default
is_default_search: false

View File

@ -98,3 +98,25 @@ instance:
plugins: plugins:
index: index:
check_latest_versions_interval: '10 minutes' check_latest_versions_interval: '10 minutes'
search:
# Add ability to fetch remote videos/actors by their URI, that may not be federated with your instance
# If enabled, the associated group will be able to "escape" from the instance follows
# That means they will be able to follow channels, watch videos, list videos of non followed instances
remote_uri:
users: true
anonymous: false
# Use a third party index instead of your local index, only for search results
# Useful to discover content outside of your instance
search_index:
enabled: true
# URL of the search index, that should use the same search API and routes
# than PeerTube: https://docs.joinpeertube.org/api-rest-reference.html
# You should deploy your own with https://framagit.org/framasoft/peertube/search-index,
# and can use https://search.joinpeertube.org/ for tests, but keep in mind the latter is an unmoderated search index
url: 'http://localhost:3234'
# You can disable local search, so users only use the search index
disable_local_search: false
# If you did not disable local search, you can decide to use the search index by default
is_default_search: true

View File

@ -76,6 +76,12 @@ async function getConfig (req: express.Request, res: express.Response) {
remoteUri: { remoteUri: {
users: CONFIG.SEARCH.REMOTE_URI.USERS, users: CONFIG.SEARCH.REMOTE_URI.USERS,
anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
},
searchIndex: {
enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
url: CONFIG.SEARCH.SEARCH_INDEX.URL,
disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
} }
}, },
plugin: { plugin: {
@ -445,7 +451,19 @@ function customConfig (): CustomConfig {
message: CONFIG.BROADCAST_MESSAGE.MESSAGE, message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
level: CONFIG.BROADCAST_MESSAGE.LEVEL, level: CONFIG.BROADCAST_MESSAGE.LEVEL,
dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
},
search: {
remoteUri: {
users: CONFIG.SEARCH.REMOTE_URI.USERS,
anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
},
searchIndex: {
enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
url: CONFIG.SEARCH.SEARCH_INDEX.URL,
disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
} }
},
} }
} }

View File

@ -1,7 +1,19 @@
import * as express from 'express' import * as express from 'express'
import { sanitizeUrl } from '@server/helpers/core-utils'
import { doRequest } from '@server/helpers/requests'
import { CONFIG } from '@server/initializers/config'
import { getOrCreateVideoAndAccountAndChannel } from '@server/lib/activitypub/videos'
import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
import { getServerActor } from '@server/models/application/application'
import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
import { ResultList, Video, VideoChannel } from '@shared/models'
import { SearchTargetQuery } from '@shared/models/search/search-target-query.model'
import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search'
import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
import { logger } from '../../helpers/logger'
import { getFormattedObjects } from '../../helpers/utils' import { getFormattedObjects } from '../../helpers/utils'
import { VideoModel } from '../../models/video/video' import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger'
import { getOrCreateActorAndServerAndModel } from '../../lib/activitypub/actor'
import { import {
asyncMiddleware, asyncMiddleware,
commonVideosFiltersValidator, commonVideosFiltersValidator,
@ -14,14 +26,9 @@ import {
videosSearchSortValidator, videosSearchSortValidator,
videosSearchValidator videosSearchValidator
} from '../../middlewares' } from '../../middlewares'
import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search' import { VideoModel } from '../../models/video/video'
import { getOrCreateActorAndServerAndModel } from '../../lib/activitypub/actor'
import { logger } from '../../helpers/logger'
import { VideoChannelModel } from '../../models/video/video-channel' import { VideoChannelModel } from '../../models/video/video-channel'
import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger'
import { MChannelAccountDefault, MVideoAccountLightBlacklistAllFiles } from '../../typings/models' import { MChannelAccountDefault, MVideoAccountLightBlacklistAllFiles } from '../../typings/models'
import { getServerActor } from '@server/models/application/application'
import { getOrCreateVideoAndAccountAndChannel } from '@server/lib/activitypub/videos'
const searchRouter = express.Router() const searchRouter = express.Router()
@ -68,9 +75,34 @@ function searchVideoChannels (req: express.Request, res: express.Response) {
// @username -> username to search in DB // @username -> username to search in DB
if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '') if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '')
if (isSearchIndexEnabled(query)) {
return searchVideoChannelsIndex(query, res)
}
return searchVideoChannelsDB(query, res) return searchVideoChannelsDB(query, res)
} }
async function searchVideoChannelsIndex (query: VideoChannelsSearchQuery, res: express.Response) {
logger.debug('Doing channels search on search index.')
const result = await buildMutedForSearchIndex(res)
const body = Object.assign(query, result)
const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels'
try {
const searchIndexResult = await doRequest<ResultList<VideoChannel>>({ uri: url, body, json: true })
return res.json(searchIndexResult.body)
} catch (err) {
logger.warn('Cannot use search index to make video channels search.', { err })
return res.sendStatus(500)
}
}
async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) { async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) {
const serverActor = await getServerActor() const serverActor = await getServerActor()
@ -120,13 +152,38 @@ async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean
function searchVideos (req: express.Request, res: express.Response) { function searchVideos (req: express.Request, res: express.Response) {
const query: VideosSearchQuery = req.query const query: VideosSearchQuery = req.query
const search = query.search const search = query.search
if (search && (search.startsWith('http://') || search.startsWith('https://'))) { if (search && (search.startsWith('http://') || search.startsWith('https://'))) {
return searchVideoURI(search, res) return searchVideoURI(search, res)
} }
if (isSearchIndexEnabled(query)) {
return searchVideosIndex(query, res)
}
return searchVideosDB(query, res) return searchVideosDB(query, res)
} }
async function searchVideosIndex (query: VideosSearchQuery, res: express.Response) {
logger.debug('Doing videos search on search index.')
const result = await buildMutedForSearchIndex(res)
const body = Object.assign(query, result)
const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos'
try {
const searchIndexResult = await doRequest<ResultList<Video>>({ uri: url, body, json: true })
return res.json(searchIndexResult.body)
} catch (err) {
logger.warn('Cannot use search index to make video search.', { err })
return res.sendStatus(500)
}
}
async function searchVideosDB (query: VideosSearchQuery, res: express.Response) { async function searchVideosDB (query: VideosSearchQuery, res: express.Response) {
const options = Object.assign(query, { const options = Object.assign(query, {
includeLocalVideos: true, includeLocalVideos: true,
@ -168,3 +225,35 @@ async function searchVideoURI (url: string, res: express.Response) {
data: video ? [ video.toFormattedJSON() ] : [] data: video ? [ video.toFormattedJSON() ] : []
}) })
} }
function isSearchIndexEnabled (query: SearchTargetQuery) {
if (query.searchTarget === 'search-index') return true
const searchIndexConfig = CONFIG.SEARCH.SEARCH_INDEX
if (searchIndexConfig.ENABLED !== true) return false
if (searchIndexConfig.DISABLE_LOCAL_SEARCH) return true
if (searchIndexConfig.IS_DEFAULT_SEARCH && !query.searchTarget) return true
return false
}
async function buildMutedForSearchIndex (res: express.Response) {
const serverActor = await getServerActor()
const accountIds = [ serverActor.Account.id ]
if (res.locals.oauth) {
accountIds.push(res.locals.oauth.token.User.Account.id)
}
const [ blockedHosts, blockedAccounts ] = await Promise.all([
ServerBlocklistModel.listHostsBlockedBy(accountIds),
AccountBlocklistModel.listHandlesBlockedBy(accountIds)
])
return {
blockedHosts,
blockedAccounts
}
}

View File

@ -128,6 +128,13 @@ function checkConfig () {
} }
} }
// Search index
if (CONFIG.SEARCH.SEARCH_INDEX.ENABLED === true) {
if (CONFIG.SEARCH.REMOTE_URI.USERS === false) {
return 'You cannot enable search index without enabling remote URI search for users.'
}
}
return null return null
} }

View File

@ -35,7 +35,9 @@ function checkMissedConfig () {
'rates_limit.login.window', 'rates_limit.login.max', 'rates_limit.ask_send_email.window', 'rates_limit.ask_send_email.max', 'rates_limit.login.window', 'rates_limit.login.max', 'rates_limit.ask_send_email.window', 'rates_limit.ask_send_email.max',
'theme.default', 'theme.default',
'remote_redundancy.videos.accept_from', 'remote_redundancy.videos.accept_from',
'federation.videos.federate_unlisted' 'federation.videos.federate_unlisted',
'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url',
'search.search_index.disable_local_search', 'search.search_index.is_default_search'
] ]
const requiredAlternatives = [ const requiredAlternatives = [
[ // set [ // set

View File

@ -104,12 +104,6 @@ const CONFIG = {
}, },
ANONYMIZE_IP: config.get<boolean>('log.anonymizeIP') ANONYMIZE_IP: config.get<boolean>('log.anonymizeIP')
}, },
SEARCH: {
REMOTE_URI: {
USERS: config.get<boolean>('search.remote_uri.users'),
ANONYMOUS: config.get<boolean>('search.remote_uri.anonymous')
}
},
TRENDING: { TRENDING: {
VIDEOS: { VIDEOS: {
INTERVAL_DAYS: config.get<number>('trending.videos.interval_days') INTERVAL_DAYS: config.get<number>('trending.videos.interval_days')
@ -297,6 +291,18 @@ const CONFIG = {
get MESSAGE () { return config.get<string>('broadcast_message.message') }, get MESSAGE () { return config.get<string>('broadcast_message.message') },
get LEVEL () { return config.get<BroadcastMessageLevel>('broadcast_message.level') }, get LEVEL () { return config.get<BroadcastMessageLevel>('broadcast_message.level') },
get DISMISSABLE () { return config.get<boolean>('broadcast_message.dismissable') } get DISMISSABLE () { return config.get<boolean>('broadcast_message.dismissable') }
},
SEARCH: {
REMOTE_URI: {
USERS: config.get<boolean>('search.remote_uri.users'),
ANONYMOUS: config.get<boolean>('search.remote_uri.anonymous')
},
SEARCH_INDEX: {
get ENABLED () { return config.get<boolean>('search.search_index.enabled') },
get URL () { return config.get<string>('search.search_index.url') },
get DISABLE_LOCAL_SEARCH () { return config.get<boolean>('search.search_index.disable_local_search') },
get IS_DEFAULT_SEARCH () { return config.get<boolean>('search.search_index.is_default_search') }
}
} }
} }

View File

@ -61,6 +61,7 @@ const SORTABLE_COLUMNS = {
VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending' ], VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending' ],
// Don't forget to update peertube-search-index with the same values
VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ], VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ], VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
@ -649,6 +650,15 @@ const DEFAULT_USER_THEME_NAME = 'instance-default'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const SEARCH_INDEX = {
ROUTES: {
VIDEOS: '/api/v1/search/videos',
VIDEO_CHANNELS: '/api/v1/search/video-channels'
}
}
// ---------------------------------------------------------------------------
// Special constants for a test instance // Special constants for a test instance
if (isTestInstance() === true) { if (isTestInstance() === true) {
PRIVATE_RSA_KEY_SIZE = 1024 PRIVATE_RSA_KEY_SIZE = 1024
@ -704,6 +714,7 @@ export {
API_VERSION, API_VERSION,
PEERTUBE_VERSION, PEERTUBE_VERSION,
LAZY_STATIC_PATHS, LAZY_STATIC_PATHS,
SEARCH_INDEX,
HLS_REDUNDANCY_DIRECTORY, HLS_REDUNDANCY_DIRECTORY,
P2P_MEDIA_LOADER_PEER_VERSION, P2P_MEDIA_LOADER_PEER_VERSION,
AVATARS_SIZE, AVATARS_SIZE,

View File

@ -272,11 +272,22 @@ async function getOrCreateVideoAndAccountAndChannel (
const actor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo) const actor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
const videoChannel = actor.VideoChannel const videoChannel = actor.VideoChannel
try {
const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(createVideo, fetchedVideo, videoChannel, syncParam.thumbnail) const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(createVideo, fetchedVideo, videoChannel, syncParam.thumbnail)
await syncVideoExternalAttributes(videoCreated, fetchedVideo, syncParam) await syncVideoExternalAttributes(videoCreated, fetchedVideo, syncParam)
return { video: videoCreated, created: true, autoBlacklisted } return { video: videoCreated, created: true, autoBlacklisted }
} catch (err) {
// Maybe a concurrent getOrCreateVideoAndAccountAndChannel call created this video
if (err.name === 'SequelizeUniqueConstraintError') {
const fallbackVideo = await fetchVideoByUrl(videoUrl, fetchType)
if (fallbackVideo) return { video: fallbackVideo, created: false }
}
throw err
}
} }
async function updateVideoFromAP (options: { async function updateVideoFromAP (options: {

View File

@ -11,6 +11,7 @@ import { PluginModel } from '../../models/server/plugin'
import { PluginManager } from './plugin-manager' import { PluginManager } from './plugin-manager'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { PEERTUBE_VERSION } from '../../initializers/constants' import { PEERTUBE_VERSION } from '../../initializers/constants'
import { sanitizeUrl } from '@server/helpers/core-utils'
async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) { async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) {
const { start = 0, count = 20, search, sort = 'npmName', pluginType } = options const { start = 0, count = 20, search, sort = 'npmName', pluginType } = options
@ -55,7 +56,7 @@ async function getLatestPluginsVersion (npmNames: string[]): Promise<PeertubePlu
currentPeerTubeEngine: PEERTUBE_VERSION currentPeerTubeEngine: PEERTUBE_VERSION
} }
const uri = CONFIG.PLUGINS.INDEX.URL + '/api/v1/plugins/latest-version' const uri = sanitizeUrl(CONFIG.PLUGINS.INDEX.URL) + '/api/v1/plugins/latest-version'
const { body } = await doRequest<any>({ uri, body: bodyRequest, json: true, method: 'POST' }) const { body } = await doRequest<any>({ uri, body: bodyRequest, json: true, method: 'POST' })

View File

@ -58,7 +58,14 @@ const customConfigUpdateValidator = [
body('broadcastMessage.enabled').isBoolean().withMessage('Should have a valid broadcast message enabled boolean'), body('broadcastMessage.enabled').isBoolean().withMessage('Should have a valid broadcast message enabled boolean'),
body('broadcastMessage.message').exists().withMessage('Should have a valid broadcast message'), body('broadcastMessage.message').exists().withMessage('Should have a valid broadcast message'),
body('broadcastMessage.level').exists().withMessage('Should have a valid broadcast level'), body('broadcastMessage.level').exists().withMessage('Should have a valid broadcast level'),
body('broadcastMessage.dismissable').exists().withMessage('Should have a valid broadcast dismissable boolean'), body('broadcastMessage.dismissable').isBoolean().withMessage('Should have a valid broadcast dismissable boolean'),
body('search.remoteUri.users').isBoolean().withMessage('Should have a remote URI search for users boolean'),
body('search.remoteUri.anonymous').isBoolean().withMessage('Should have a valid remote URI search for anonymous boolean'),
body('search.searchIndex.enabled').isBoolean().withMessage('Should have a valid search index enabled boolean'),
body('search.searchIndex.url').exists().withMessage('Should have a valid search index URL'),
body('search.searchIndex.disableLocalSearch').isBoolean().withMessage('Should have a valid search index disable local search boolean'),
body('search.searchIndex.isDefaultSearch').isBoolean().withMessage('Should have a valid search index default enabled boolean'),
(req: express.Request, res: express.Response, next: express.NextFunction) => { (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body }) logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })

View File

@ -5,6 +5,8 @@ import { AccountBlock } from '../../../shared/models/blocklist'
import { Op } from 'sequelize' import { Op } from 'sequelize'
import * as Bluebird from 'bluebird' import * as Bluebird from 'bluebird'
import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/typings/models' import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/typings/models'
import { ActorModel } from '../activitypub/actor'
import { ServerModel } from '../server/server'
enum ScopeNames { enum ScopeNames {
WITH_ACCOUNTS = 'WITH_ACCOUNTS' WITH_ACCOUNTS = 'WITH_ACCOUNTS'
@ -149,6 +151,42 @@ export class AccountBlocklistModel extends Model<AccountBlocklistModel> {
}) })
} }
static listHandlesBlockedBy (accountIds: number[]): Bluebird<string[]> {
const query = {
attributes: [],
where: {
accountId: {
[Op.in]: accountIds
}
},
include: [
{
attributes: [ 'id' ],
model: AccountModel.unscoped(),
required: true,
as: 'BlockedAccount',
include: [
{
attributes: [ 'preferredUsername' ],
model: ActorModel.unscoped(),
required: true,
include: [
{
attributes: [ 'host' ],
model: ServerModel.unscoped(),
required: true
}
]
}
]
}
]
}
return AccountBlocklistModel.findAll(query)
.then(entries => entries.map(e => `${e.BlockedAccount.Actor.preferredUsername}@${e.BlockedAccount.Actor.Server.host}`))
}
toFormattedJSON (this: MAccountBlocklistFormattable): AccountBlock { toFormattedJSON (this: MAccountBlocklistFormattable): AccountBlock {
return { return {
byAccount: this.ByAccount.toFormattedJSON(), byAccount: this.ByAccount.toFormattedJSON(),

View File

@ -120,6 +120,27 @@ export class ServerBlocklistModel extends Model<ServerBlocklistModel> {
return ServerBlocklistModel.findOne(query) return ServerBlocklistModel.findOne(query)
} }
static listHostsBlockedBy (accountIds: number[]): Bluebird<string[]> {
const query = {
attributes: [ ],
where: {
accountId: {
[Op.in]: accountIds
}
},
include: [
{
attributes: [ 'host' ],
model: ServerModel.unscoped(),
required: true
}
]
}
return ServerBlocklistModel.findAll(query)
.then(entries => entries.map(e => e.BlockedServer.host))
}
static listForApi (parameters: { static listForApi (parameters: {
start: number start: number
count: number count: number

View File

@ -139,6 +139,18 @@ describe('Test config API validators', function () {
dismissable: true, dismissable: true,
message: 'super message', message: 'super message',
level: 'warning' level: 'warning'
},
search: {
remoteUri: {
users: true,
anonymous: true
},
searchIndex: {
enabled: true,
url: 'https://search.joinpeertube.org',
disableLocalSearch: true,
isDefaultSearch: true
}
} }
} }

View File

@ -340,6 +340,18 @@ describe('Test config', function () {
level: 'error', level: 'error',
message: 'super bad message', message: 'super bad message',
dismissable: true dismissable: true
},
search: {
remoteUri: {
anonymous: true,
users: true
},
searchIndex: {
enabled: true,
url: 'https://search.joinpeertube.org',
disableLocalSearch: true,
isDefaultSearch: true
}
} }
} }
await updateCustomConfig(server.url, server.accessToken, newCustomConfig) await updateCustomConfig(server.url, server.accessToken, newCustomConfig)

View File

@ -165,6 +165,18 @@ function updateCustomSubConfig (url: string, token: string, newConfig: DeepParti
level: 'warning', level: 'warning',
message: 'hello', message: 'hello',
dismissable: true dismissable: true
},
search: {
remoteUri: {
users: true,
anonymous: true
},
searchIndex: {
enabled: true,
url: 'https://search.joinpeertube.org',
disableLocalSearch: true,
isDefaultSearch: true
}
} }
} }

View File

@ -1,5 +1,8 @@
export interface Avatar { export interface Avatar {
path: string path: string
url?: string
createdAt: Date | string createdAt: Date | string
updatedAt: Date | string updatedAt: Date | string
} }

View File

@ -0,0 +1,5 @@
export type SearchTargetType = 'local' | 'search-index'
export interface SearchTargetQuery {
searchTarget?: SearchTargetType
}

View File

@ -1,4 +1,6 @@
export interface VideoChannelsSearchQuery { import { SearchTargetQuery } from "./search-target-query.model"
export interface VideoChannelsSearchQuery extends SearchTargetQuery {
search: string search: string
start?: number start?: number

View File

@ -1,7 +1,10 @@
import { NSFWQuery } from './nsfw-query.model' import { NSFWQuery } from './nsfw-query.model'
import { VideoFilter } from '../videos' import { VideoFilter } from '../videos'
import { SearchTargetQuery } from './search-target-query.model'
export interface VideosSearchQuery extends SearchTargetQuery {
forceLocalSearch?: boolean
export interface VideosSearchQuery {
search?: string search?: string
start?: number start?: number

View File

@ -139,4 +139,18 @@ export interface CustomConfig {
level: BroadcastMessageLevel level: BroadcastMessageLevel
dismissable: boolean dismissable: boolean
} }
search: {
remoteUri: {
users: boolean
anonymous: boolean
}
searchIndex: {
enabled: boolean
url: string
disableLocalSearch: boolean
isDefaultSearch: boolean
}
}
} }

View File

@ -50,6 +50,13 @@ export interface ServerConfig {
users: boolean users: boolean
anonymous: boolean anonymous: boolean
} }
searchIndex: {
enabled: boolean
url: string
disableLocalSearch: boolean
isDefaultSearch: boolean
}
} }
plugin: { plugin: {

View File

@ -22,9 +22,19 @@ export interface Video {
duration: number duration: number
isLocal: boolean isLocal: boolean
name: string name: string
thumbnailPath: string thumbnailPath: string
thumbnailUrl?: string
previewPath: string previewPath: string
previewUrl?: string
embedPath: string embedPath: string
embedUrl?: string
// When using the search index
url?: string
views: number views: number
likes: number likes: number
dislikes: number dislikes: number