First implem global search
This commit is contained in:
parent
62e7be634b
commit
5fb2e2888c
|
@ -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>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
|
@ -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 })
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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++
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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 { }
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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') }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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' })
|
||||||
|
|
||||||
|
|
|
@ -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 })
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
export type SearchTargetType = 'local' | 'search-index'
|
||||||
|
|
||||||
|
export interface SearchTargetQuery {
|
||||||
|
searchTarget?: SearchTargetType
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue