First implem global search
This commit is contained in:
parent
62e7be634b
commit
5fb2e2888c
|
@ -396,9 +396,9 @@
|
|||
</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 i18n class="inner-form-title">NEW VIDEOS</div>
|
||||
<div i18n class="inner-form-title">VIDEOS</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
|
||||
|
@ -445,6 +445,86 @@
|
|||
</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-group col-12 col-lg-4 col-xl-3">
|
||||
<div i18n class="inner-form-title">FEDERATION</div>
|
||||
|
|
|
@ -64,8 +64,10 @@ textarea {
|
|||
}
|
||||
|
||||
.disabled-checkbox-extra {
|
||||
opacity: .5;
|
||||
pointer-events: none;
|
||||
&, ::ng-deep label {
|
||||
opacity: .5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group-right {
|
||||
|
|
|
@ -221,6 +221,18 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
|
|||
level: null,
|
||||
dismissable: 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
|
||||
}
|
||||
|
||||
isSearchIndexEnabled () {
|
||||
return this.form.value['search']['searchIndex']['enabled'] === true
|
||||
}
|
||||
|
||||
isAutoFollowIndexEnabled () {
|
||||
return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import 'focus-visible'
|
|||
import { AppRoutingModule } from './app-routing.module'
|
||||
import { AppComponent } from './app.component'
|
||||
import { CoreModule } from './core'
|
||||
import { HeaderComponent, SearchTypeaheadComponent, SuggestionsComponent, SuggestionComponent } from './header'
|
||||
import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header'
|
||||
import { LoginModule } from './login'
|
||||
import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
|
||||
import { SharedModule } from './shared'
|
||||
|
@ -35,7 +35,6 @@ registerLocaleData(localeOc, 'oc')
|
|||
AvatarNotificationComponent,
|
||||
HeaderComponent,
|
||||
SearchTypeaheadComponent,
|
||||
SuggestionsComponent,
|
||||
SuggestionComponent,
|
||||
|
||||
CustomModalComponent,
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
import { Observable, of, Subject } from 'rxjs'
|
||||
import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators'
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
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 { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
|
||||
import { sortBy } from '@app/shared/misc/utils'
|
||||
import { SearchTargetType } from '@shared/models/search/search-target-query.model'
|
||||
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()
|
||||
export class ServerService {
|
||||
|
@ -47,12 +48,6 @@ export class ServerService {
|
|||
css: ''
|
||||
}
|
||||
},
|
||||
search: {
|
||||
remoteUri: {
|
||||
users: true,
|
||||
anonymous: false
|
||||
}
|
||||
},
|
||||
plugin: {
|
||||
registered: [],
|
||||
registeredExternalAuths: [],
|
||||
|
@ -145,6 +140,18 @@ export class ServerService {
|
|||
message: '',
|
||||
level: 'info',
|
||||
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)
|
||||
}
|
||||
|
||||
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> (
|
||||
baseUrl: string,
|
||||
attributeName: 'categories' | 'licences' | 'languages' | 'privacies',
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
export * from './header.component'
|
||||
export * from './search-typeahead.component'
|
||||
export * from './suggestions.component'
|
||||
export * from './suggestion.component'
|
||||
|
|
|
@ -1,38 +1,43 @@
|
|||
<div class="d-inline-flex position-relative" id="typeahead-container">
|
||||
<input
|
||||
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()"
|
||||
aria-label="Search"
|
||||
[(ngModel)]="search" (ngModelChange)="onSearchChange()" (keydown)="handleKey($event)" (keydown.enter)="doSearch()"
|
||||
aria-label="Search" autocomplete="off"
|
||||
>
|
||||
<span class="icon icon-search" (click)="doSearch()"></span>
|
||||
|
||||
<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 -->
|
||||
<div *ngIf="showHelp" id="typeahead-help" class="overflow-hidden">
|
||||
<ng-container *ngIf="activeResult.type === 'search-global'">
|
||||
<div class="d-flex justify-content-between">
|
||||
<label class="small-title" i18n>GLOBAL SEARCH</label>
|
||||
<div class="advanced-search-status text-muted">
|
||||
<span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.followings.instance.autoFollowIndex.indexUrl }}</span>
|
||||
<i class="glyphicon glyphicon-globe"></i>
|
||||
</div>
|
||||
<div *ngIf="showSearchGlobalHelp()" id="typeahead-help" class="overflow-hidden">
|
||||
<div class="d-flex justify-content-between">
|
||||
<label class="small-title" i18n>GLOBAL SEARCH</label>
|
||||
<div class="advanced-search-status text-muted">
|
||||
<span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.search.searchIndex.url }}</span>
|
||||
<i class="glyphicon glyphicon-globe"></i>
|
||||
</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 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>
|
||||
|
||||
<!-- 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">
|
||||
<label class="small-title" i18n>ADVANCED SEARCH</label>
|
||||
<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 *ngIf="canSearchAnyURI" class="mr-1" i18n>any instance</span>
|
||||
<span *ngIf="!canSearchAnyURI" class="mr-1" i18n>only followed instances</span>
|
||||
<i [ngClass]="canSearchAnyURI ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i>
|
||||
<span *ngIf="canSearchAnyURI()" class="mr-1" i18n>any instance</span>
|
||||
<span *ngIf="!canSearchAnyURI()" class="mr-1" i18n>only followed instances</span>
|
||||
<i [ngClass]="canSearchAnyURI() ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
#typeahead-help,
|
||||
#typeahead-instructions,
|
||||
my-suggestions ::ng-deep ul {
|
||||
li.suggestion {
|
||||
border: 1px solid pvar(--mainBackgroundColor);
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
|
@ -90,7 +90,7 @@ my-suggestions ::ng-deep ul {
|
|||
}
|
||||
|
||||
& > div:last-child {
|
||||
// we have to switch the display and not the opacity,
|
||||
// we have to switch the display and not the opacity,
|
||||
// to avoid clashing with the rest of the interface.
|
||||
display: none;
|
||||
}
|
||||
|
@ -101,10 +101,10 @@ my-suggestions ::ng-deep ul {
|
|||
@media screen and (min-width: $mobile-view) {
|
||||
display: initial !important;
|
||||
}
|
||||
|
||||
|
||||
#typeahead-help,
|
||||
#typeahead-instructions,
|
||||
my-suggestions ::ng-deep ul {
|
||||
li.suggestion {
|
||||
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 { 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 { SearchTargetType } from '@shared/models/search/search-target-query.model'
|
||||
import { SuggestionComponent, SuggestionPayload, SuggestionPayloadType } from './suggestion.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-search-typeahead',
|
||||
templateUrl: './search-typeahead.component.html',
|
||||
styleUrls: [ './search-typeahead.component.scss' ]
|
||||
})
|
||||
export class SearchTypeaheadComponent implements OnInit, OnDestroy {
|
||||
@ViewChild('searchVideo', { static: true }) searchInput: ElementRef<HTMLInputElement>
|
||||
export class SearchTypeaheadComponent implements OnInit, AfterViewInit, AfterViewChecked, OnDestroy {
|
||||
@ViewChildren(SuggestionComponent) suggestionItems: QueryList<SuggestionComponent>
|
||||
|
||||
hasChannel = false
|
||||
inChannel = false
|
||||
newSearch = true
|
||||
areSuggestionsOpened = true
|
||||
|
||||
search = ''
|
||||
serverConfig: ServerConfig
|
||||
|
@ -25,7 +26,11 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
|
|||
inThisChannelText: string
|
||||
|
||||
keyboardEventsManager: ListKeyManager<SuggestionComponent>
|
||||
results: Result[] = []
|
||||
results: SuggestionPayload[] = []
|
||||
|
||||
activeSearch: SuggestionPayloadType
|
||||
|
||||
private scheduleKeyboardEventsInit = false
|
||||
|
||||
constructor (
|
||||
private authService: AuthService,
|
||||
|
@ -38,109 +43,138 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
|
|||
this.route.queryParams
|
||||
.pipe(first(params => this.isOnSearch() && params.search !== undefined && params.search !== null))
|
||||
.subscribe(params => this.search = params.search)
|
||||
}
|
||||
|
||||
ngAfterViewInit () {
|
||||
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 () {
|
||||
if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
|
||||
}
|
||||
|
||||
get activeResult () {
|
||||
return this.keyboardEventsManager?.activeItem?.result
|
||||
}
|
||||
|
||||
get areInstructionsDisplayed () {
|
||||
areInstructionsDisplayed () {
|
||||
return !this.search
|
||||
}
|
||||
|
||||
get showHelp () {
|
||||
return this.search && this.newSearch && this.activeResult?.type === 'search-global'
|
||||
showSearchGlobalHelp () {
|
||||
return this.search && this.areSuggestionsOpened && this.keyboardEventsManager?.activeItem?.result?.type === 'search-index'
|
||||
}
|
||||
|
||||
get canSearchAnyURI () {
|
||||
canSearchAnyURI () {
|
||||
if (!this.serverConfig) return false
|
||||
|
||||
return this.authService.isLoggedIn()
|
||||
? this.serverConfig.search.remoteUri.users
|
||||
: this.serverConfig.search.remoteUri.anonymous
|
||||
}
|
||||
|
||||
onSearchChange () {
|
||||
this.computeResults()
|
||||
this.computeTypeahead()
|
||||
}
|
||||
|
||||
computeResults () {
|
||||
this.newSearch = true
|
||||
let results: Result[] = []
|
||||
initKeyboardEventsManager () {
|
||||
if (this.keyboardEventsManager) return
|
||||
|
||||
if (this.search) {
|
||||
results = [
|
||||
/* Channel search is still unimplemented. Uncomment when it is.
|
||||
{
|
||||
text: this.search,
|
||||
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.keyboardEventsManager = new ListKeyManager(this.suggestionItems)
|
||||
|
||||
const activeIndex = this.suggestionItems.toArray().findIndex(i => i.result.default === true)
|
||||
if (activeIndex === -1) {
|
||||
console.error('Cannot find active index.', { suggestionItems: this.suggestionItems })
|
||||
}
|
||||
|
||||
this.results = results.filter(
|
||||
(result: Result) => {
|
||||
// if we're not in a channel or one of its videos/playlits, show all channel-related results
|
||||
if (!(this.hasChannel || this.inChannel)) return !result.type.includes('channel')
|
||||
// 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 }) {
|
||||
event.items.forEach(e => {
|
||||
if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === e) {
|
||||
this.keyboardEventsManager.activeItem.active = true
|
||||
} else {
|
||||
e.active = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
initKeyboardEventsManager (event: { items: QueryList<SuggestionComponent>, index?: number }) {
|
||||
if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
|
||||
|
||||
this.keyboardEventsManager = new ListKeyManager(event.items)
|
||||
|
||||
if (event.index !== undefined) {
|
||||
this.keyboardEventsManager.setActiveItem(event.index)
|
||||
} else {
|
||||
this.keyboardEventsManager.setFirstItemActive()
|
||||
}
|
||||
this.updateItemsState(activeIndex)
|
||||
|
||||
this.keyboardEventsManager.change.subscribe(
|
||||
_ => this.setEventItems(event)
|
||||
_ => this.updateItemsState()
|
||||
)
|
||||
}
|
||||
|
||||
computeTypeahead () {
|
||||
const searchIndexConfig = this.serverConfig.search.searchIndex
|
||||
|
||||
if (!this.activeSearch) {
|
||||
if (searchIndexConfig.enabled && searchIndexConfig.isDefaultSearch) {
|
||||
this.activeSearch = 'search-instance'
|
||||
} else {
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
if (searchIndexConfig.enabled) {
|
||||
this.results.push({
|
||||
text: this.search,
|
||||
type: 'search-index',
|
||||
default: this.activeSearch === 'search-index'
|
||||
})
|
||||
}
|
||||
|
||||
this.scheduleKeyboardEventsInit = true
|
||||
}
|
||||
|
||||
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) {
|
||||
event.stopImmediatePropagation()
|
||||
if (!this.keyboardEventsManager) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
case 'ArrowUp':
|
||||
event.stopPropagation()
|
||||
|
||||
this.keyboardEventsManager.onKeydown(event)
|
||||
break
|
||||
}
|
||||
|
@ -150,15 +184,19 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
|
|||
return window.location.pathname === '/search'
|
||||
}
|
||||
|
||||
doSearch () {
|
||||
this.newSearch = false
|
||||
doSearch (searchTarget?: SearchTargetType) {
|
||||
this.areSuggestionsOpened = false
|
||||
const queryParams: Params = {}
|
||||
|
||||
if (this.isOnSearch() && 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()
|
||||
? this.loadUserLanguagesIfNeeded(queryParams)
|
||||
|
@ -176,4 +214,12 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
|
|||
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">
|
||||
<div class="flex-shrink-0 mr-2 text-center">
|
||||
<my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon>
|
||||
<my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon>
|
||||
<my-global-icon iconName="search"></my-global-icon>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<span *ngIf="result.type === 'search-channel'" i18n>In this channel</span>
|
||||
<div class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6">
|
||||
<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-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span>
|
||||
<span *ngIf="result.type === 'search-index'" i18n>In the vidiverse</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>
|
||||
</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 { ListKeyManagerOption } from '@angular/cdk/a11y'
|
||||
|
||||
export type Result = {
|
||||
export type SuggestionPayload = {
|
||||
text: string
|
||||
type: 'channel' | 'suggestion' | 'search-channel' | 'search-instance' | 'search-global' | 'search-any'
|
||||
routerLink?: RouterLink,
|
||||
default?: boolean
|
||||
type: SuggestionPayloadType
|
||||
routerLink?: RouterLink
|
||||
default: boolean
|
||||
}
|
||||
|
||||
export type SuggestionPayloadType = 'search-instance' | 'search-index'
|
||||
|
||||
@Component({
|
||||
selector: 'my-suggestion',
|
||||
templateUrl: './suggestion.component.html',
|
||||
styleUrls: [ './suggestion.component.scss' ],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
styleUrls: [ './suggestion.component.scss' ]
|
||||
})
|
||||
export class SuggestionComponent implements OnInit, ListKeyManagerOption {
|
||||
@Input() result: Result
|
||||
@Input() result: SuggestionPayload
|
||||
@Input() highlight: string
|
||||
@Output() selected = new EventEmitter()
|
||||
|
||||
disabled = false
|
||||
active = false
|
||||
|
@ -30,8 +30,4 @@ export class SuggestionComponent implements OnInit, ListKeyManagerOption {
|
|||
ngOnInit () {
|
||||
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'
|
||||
|
||||
export class AdvancedSearch {
|
||||
|
@ -23,6 +24,11 @@ export class AdvancedSearch {
|
|||
|
||||
sort: string
|
||||
|
||||
searchTarget: SearchTargetType
|
||||
|
||||
// Filters we don't want to count, because they are mandatory
|
||||
private silentFilters = new Set([ 'sort', 'searchTarget' ])
|
||||
|
||||
constructor (options?: {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
|
@ -37,6 +43,7 @@ export class AdvancedSearch {
|
|||
durationMin?: string
|
||||
durationMax?: string
|
||||
sort?: string
|
||||
searchTarget?: SearchTargetType
|
||||
}) {
|
||||
if (!options) return
|
||||
|
||||
|
@ -54,6 +61,8 @@ export class AdvancedSearch {
|
|||
this.durationMin = parseInt(options.durationMin, 10)
|
||||
this.durationMax = parseInt(options.durationMax, 10)
|
||||
|
||||
this.searchTarget = options.searchTarget || undefined
|
||||
|
||||
if (isNaN(this.durationMin)) this.durationMin = undefined
|
||||
if (isNaN(this.durationMax)) this.durationMax = undefined
|
||||
|
||||
|
@ -61,9 +70,11 @@ export class AdvancedSearch {
|
|||
}
|
||||
|
||||
containsValues () {
|
||||
const exceptions = new Set([ 'sort', 'searchTarget' ])
|
||||
|
||||
const obj = this.toUrlObject()
|
||||
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
|
||||
}
|
||||
|
@ -102,7 +113,8 @@ export class AdvancedSearch {
|
|||
tagsAllOf: this.tagsAllOf,
|
||||
durationMin: this.durationMin,
|
||||
durationMax: this.durationMax,
|
||||
sort: this.sort
|
||||
sort: this.sort,
|
||||
searchTarget: this.searchTarget
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -120,7 +132,8 @@ export class AdvancedSearch {
|
|||
tagsAllOf: this.intoArray(this.tagsAllOf),
|
||||
durationMin: this.durationMin,
|
||||
durationMax: this.durationMax,
|
||||
sort: this.sort
|
||||
sort: this.sort,
|
||||
searchTarget: this.searchTarget
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -129,7 +142,7 @@ export class AdvancedSearch {
|
|||
|
||||
const obj = this.toUrlObject()
|
||||
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++
|
||||
}
|
||||
|
|
|
@ -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 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="radio-label label-container">
|
||||
<label i18n>Published date</label>
|
||||
|
@ -39,7 +58,7 @@
|
|||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<div class="pl-0 col-sm-6">
|
||||
<input
|
||||
(change)="inputUpdated()"
|
||||
(keydown.enter)="$event.preventDefault()"
|
||||
|
@ -49,7 +68,7 @@
|
|||
class="form-control"
|
||||
>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="pr-0 col-sm-6">
|
||||
<input
|
||||
(change)="inputUpdated()"
|
||||
(keydown.enter)="$event.preventDefault()"
|
||||
|
@ -62,6 +81,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4 col-md-6 col-xs-12">
|
||||
<div class="form-group">
|
||||
<div class="radio-label label-container">
|
||||
<label i18n>Duration</label>
|
||||
|
@ -76,28 +98,6 @@
|
|||
</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">
|
||||
<label i18n for="category">Category</label>
|
||||
<button i18n class="reset-button reset-button-small" (click)="resetField('categoryOneOf')" *ngIf="advancedSearch.categoryOneOf !== undefined">
|
||||
|
@ -164,6 +164,22 @@
|
|||
[maxItems]="5" [modelAsStrings]="true"
|
||||
></tag-input>
|
||||
</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>
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@ export class SearchFiltersComponent implements OnInit {
|
|||
this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES
|
||||
this.publishedDateRanges = [
|
||||
{
|
||||
id: undefined,
|
||||
id: 'any_published_date',
|
||||
label: this.i18n('Any')
|
||||
},
|
||||
{
|
||||
|
@ -67,7 +67,7 @@ export class SearchFiltersComponent implements OnInit {
|
|||
|
||||
this.durationRanges = [
|
||||
{
|
||||
id: undefined,
|
||||
id: 'any_duration',
|
||||
label: this.i18n('Any')
|
||||
},
|
||||
{
|
||||
|
@ -147,6 +147,10 @@ export class SearchFiltersComponent implements OnInit {
|
|||
this.originallyPublishedStartYear = this.originallyPublishedEndYear = undefined
|
||||
}
|
||||
|
||||
isSearchTargetEnabled () {
|
||||
return this.serverConfig.search.searchIndex.enabled && this.serverConfig.search.searchIndex.disableLocalSearch !== true
|
||||
}
|
||||
|
||||
private loadOriginallyPublishedAtYears () {
|
||||
this.originallyPublishedStartYear = this.advancedSearch.originallyPublishedStartDate
|
||||
? new Date(this.advancedSearch.originallyPublishedStartDate).getFullYear().toString()
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { MetaGuard } from '@ngx-meta/core'
|
||||
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 = [
|
||||
{
|
||||
|
@ -13,6 +15,22 @@ const searchRoutes: Routes = [
|
|||
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="first-line">
|
||||
<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>
|
||||
for <span class="search-value">{{ currentSearch }}</span>
|
||||
</span>
|
||||
|
@ -31,12 +35,12 @@
|
|||
|
||||
<ng-container *ngFor="let result of results">
|
||||
<div *ngIf="isVideoChannel(result)" class="entry video-channel">
|
||||
<a [routerLink]="[ '/video-channels', result.nameWithHost ]">
|
||||
<a [routerLink]="getChannelUrl(result)">
|
||||
<img [src]="result.avatarUrl" alt="Avatar" />
|
||||
</a>
|
||||
|
||||
<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-name">{{ result.nameWithHost }}</div>
|
||||
</a>
|
||||
|
@ -44,12 +48,13 @@
|
|||
<div i18n class="video-channel-followers">{{ result.followersCount }} subscribers</div>
|
||||
</div>
|
||||
|
||||
<my-subscribe-button [videoChannels]="[result]"></my-subscribe-button>
|
||||
<my-subscribe-button *ngIf="!hideActions()" [videoChannels]="[result]"></my-subscribe-button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isVideo(result)" class="entry video">
|
||||
<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)"
|
||||
></my-video-miniature>
|
||||
</div>
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
import { forkJoin, of, Subscription } from 'rxjs'
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { AuthService, Notifier } 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 { AuthService, Notifier, ServerService } from '@app/core'
|
||||
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({
|
||||
selector: 'my-search',
|
||||
|
@ -29,6 +31,9 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||
isSearchFilterCollapsed = true
|
||||
currentSearch: string
|
||||
|
||||
errorMessage: string
|
||||
serverConfig: ServerConfig
|
||||
|
||||
private subActivatedRoute: Subscription
|
||||
private isInitialLoad = false // set to false to show the search filters on first arrival
|
||||
private firstSearch = true
|
||||
|
@ -43,7 +48,8 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||
private notifier: Notifier,
|
||||
private searchService: SearchService,
|
||||
private authService: AuthService,
|
||||
private hooks: HooksService
|
||||
private hooks: HooksService,
|
||||
private serverService: ServerService
|
||||
) { }
|
||||
|
||||
get user () {
|
||||
|
@ -51,8 +57,11 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.serverService.getConfig()
|
||||
.subscribe(config => this.serverConfig = config)
|
||||
|
||||
this.subActivatedRoute = this.route.queryParams.subscribe(
|
||||
queryParams => {
|
||||
async queryParams => {
|
||||
const querySearch = queryParams['search']
|
||||
|
||||
// Search updated, reset filters
|
||||
|
@ -65,6 +74,9 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
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
|
||||
this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues()
|
||||
|
@ -99,28 +111,37 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||
forkJoin([
|
||||
this.getVideosObs(),
|
||||
this.getVideoChannelObs()
|
||||
])
|
||||
.subscribe(
|
||||
([ videosResult, videoChannelsResult ]) => {
|
||||
this.results = this.results
|
||||
.concat(videoChannelsResult.data)
|
||||
.concat(videosResult.data)
|
||||
this.pagination.totalItems = videosResult.total + videoChannelsResult.total
|
||||
]).subscribe(
|
||||
([videosResult, videoChannelsResult]) => {
|
||||
this.results = this.results
|
||||
.concat(videoChannelsResult.data)
|
||||
.concat(videosResult.data)
|
||||
|
||||
// Focus on channels if there are no enough videos
|
||||
if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
|
||||
this.resetPagination()
|
||||
this.firstSearch = false
|
||||
|
||||
this.channelsPerPage = 10
|
||||
this.search()
|
||||
}
|
||||
this.pagination.totalItems = videosResult.total + videoChannelsResult.total
|
||||
|
||||
// Focus on channels if there are no enough videos
|
||||
if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
|
||||
this.resetPagination()
|
||||
this.firstSearch = false
|
||||
},
|
||||
|
||||
err => this.notifier.error(err.message)
|
||||
)
|
||||
this.channelsPerPage = 10
|
||||
this.search()
|
||||
}
|
||||
|
||||
this.firstSearch = false
|
||||
},
|
||||
|
||||
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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onNearOfBottom () {
|
||||
|
@ -146,6 +167,24 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||
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 () {
|
||||
this.pagination.currentPage = 1
|
||||
this.pagination.totalItems = null
|
||||
|
@ -189,7 +228,8 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||
|
||||
const params = {
|
||||
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(
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { NgModule } from '@angular/core'
|
||||
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 { SearchService } from '@app/search/search.service'
|
||||
import { SearchRoutingModule } from '@app/search/search-routing.module'
|
||||
import { SearchFiltersComponent } from '@app/search/search-filters.component'
|
||||
import { SharedModule } from '../shared'
|
||||
import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
|
||||
import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
@ -25,7 +27,9 @@ import { SearchFiltersComponent } from '@app/search/search-filters.component'
|
|||
],
|
||||
|
||||
providers: [
|
||||
SearchService
|
||||
SearchService,
|
||||
VideoLazyLoadResolver,
|
||||
ChannelLazyLoadResolver
|
||||
]
|
||||
})
|
||||
export class SearchModule { }
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
import { Observable } from 'rxjs'
|
||||
import { catchError, map, switchMap } from 'rxjs/operators'
|
||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||
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 { 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 { 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()
|
||||
export class SearchService {
|
||||
|
@ -30,21 +31,27 @@ export class SearchService {
|
|||
|
||||
searchVideos (parameters: {
|
||||
search: string,
|
||||
componentPagination: ComponentPaginationLight,
|
||||
advancedSearch: AdvancedSearch
|
||||
componentPagination?: ComponentPaginationLight,
|
||||
advancedSearch?: AdvancedSearch
|
||||
}): Observable<ResultList<Video>> {
|
||||
const { search, componentPagination, advancedSearch } = parameters
|
||||
|
||||
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()
|
||||
params = this.restService.addRestGetParams(params, pagination)
|
||||
|
||||
if (search) params = params.append('search', search)
|
||||
|
||||
const advancedSearchObject = advancedSearch.toAPIObject()
|
||||
params = this.restService.addObjectParams(params, advancedSearchObject)
|
||||
if (advancedSearch) {
|
||||
const advancedSearchObject = advancedSearch.toAPIObject()
|
||||
params = this.restService.addObjectParams(params, advancedSearchObject)
|
||||
}
|
||||
|
||||
return this.authHttp
|
||||
.get<ResultList<VideoServerModel>>(url, { params })
|
||||
|
@ -56,17 +63,26 @@ export class SearchService {
|
|||
|
||||
searchVideoChannels (parameters: {
|
||||
search: string,
|
||||
componentPagination: ComponentPaginationLight
|
||||
searchTarget?: SearchTargetType,
|
||||
componentPagination?: ComponentPaginationLight
|
||||
}): Observable<ResultList<VideoChannel>> {
|
||||
const { search, componentPagination } = parameters
|
||||
const { search, componentPagination, searchTarget } = parameters
|
||||
|
||||
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()
|
||||
params = this.restService.addRestGetParams(params, pagination)
|
||||
params = params.append('search', search)
|
||||
|
||||
if (searchTarget) {
|
||||
params = params.append('searchTarget', searchTarget as string)
|
||||
}
|
||||
|
||||
return this.authHttp
|
||||
.get<ResultList<VideoChannelServerModel>>(url, { params })
|
||||
.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
|
||||
|
||||
static GET_ACTOR_AVATAR_URL (actor: { avatar?: { path: string } }) {
|
||||
const absoluteAPIUrl = getAbsoluteAPIUrl()
|
||||
static GET_ACTOR_AVATAR_URL (actor: { avatar?: Avatar }) {
|
||||
if (actor?.avatar?.url) return actor.avatar.url
|
||||
|
||||
if (actor && actor.avatar) return absoluteAPIUrl + actor.avatar.path
|
||||
if (actor && actor.avatar) {
|
||||
const absoluteAPIUrl = getAbsoluteAPIUrl()
|
||||
|
||||
return absoluteAPIUrl + actor.avatar.path
|
||||
}
|
||||
|
||||
return this.GET_DEFAULT_AVATAR_URL()
|
||||
}
|
||||
|
|
|
@ -11,19 +11,17 @@ export class HighlightPipe implements PipeTransform {
|
|||
/* use this for global search */
|
||||
static MULTI_MATCH = 'Multi-Match'
|
||||
|
||||
// tslint:disable-next-line:no-empty
|
||||
constructor () {}
|
||||
|
||||
transform (
|
||||
contentString: string = null,
|
||||
stringToHighlight: string = null,
|
||||
option = 'Single-And-StartsWith-Match',
|
||||
caseSensitive = false,
|
||||
highlightStyleName = 'search-highlight'
|
||||
contentString: string = null,
|
||||
stringToHighlight: string = null,
|
||||
option = 'Single-And-StartsWith-Match',
|
||||
caseSensitive = false,
|
||||
highlightStyleName = 'search-highlight'
|
||||
): SafeHtml {
|
||||
if (stringToHighlight && contentString && option) {
|
||||
let regex: any = ''
|
||||
const caseFlag: string = !caseSensitive ? 'i' : ''
|
||||
|
||||
switch (option) {
|
||||
case 'Single-Match': {
|
||||
regex = new RegExp(stringToHighlight, caseFlag)
|
||||
|
@ -42,10 +40,12 @@ export class HighlightPipe implements PipeTransform {
|
|||
regex = new RegExp(stringToHighlight, 'gi')
|
||||
}
|
||||
}
|
||||
|
||||
const replaced = contentString.replace(
|
||||
regex,
|
||||
(match) => `<span class="${highlightStyleName}">${match}</span>`
|
||||
regex,
|
||||
(match) => `<span class="${highlightStyleName}">${match}</span>`
|
||||
)
|
||||
|
||||
return replaced
|
||||
} else {
|
||||
return contentString
|
||||
|
|
|
@ -14,6 +14,7 @@ export class CustomConfigValidatorsService {
|
|||
readonly ADMIN_EMAIL: BuildFormValidator
|
||||
readonly TRANSCODING_THREADS: BuildFormValidator
|
||||
readonly INDEX_URL: BuildFormValidator
|
||||
readonly SEARCH_INDEX_URL: BuildFormValidator
|
||||
|
||||
constructor (private i18n: I18n) {
|
||||
this.INSTANCE_NAME = {
|
||||
|
@ -86,5 +87,12 @@ export class CustomConfigValidatorsService {
|
|||
'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'
|
||||
|
||||
export class UserNotification implements UserNotificationServer {
|
||||
|
@ -178,7 +178,7 @@ export class UserNotification implements UserNotificationServer {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow, 'fit-width': fitWidth }" (mouseenter)="loadActions()">
|
||||
<my-video-thumbnail
|
||||
[video]="video" [nsfw]="isVideoBlur"
|
||||
[video]="video" [nsfw]="isVideoBlur" [routerLink]="videoLink"
|
||||
[displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
|
||||
>
|
||||
<ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
|
||||
|
@ -12,7 +12,7 @@
|
|||
<a
|
||||
tabindex="-1"
|
||||
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>
|
||||
|
||||
<div class="d-inline-flex">
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { switchMap } from 'rxjs/operators'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
|
@ -9,15 +10,14 @@ import {
|
|||
OnInit,
|
||||
Output
|
||||
} from '@angular/core'
|
||||
import { User } from '../users'
|
||||
import { Video } from './video.model'
|
||||
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 { 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 MiniatureDisplayOptions = {
|
||||
|
@ -57,6 +57,8 @@ export class VideoMiniatureComponent implements OnInit {
|
|||
@Input() displayVideoActions = true
|
||||
@Input() fitWidth = false
|
||||
|
||||
@Input() useLazyLoadUrl = false
|
||||
|
||||
@Output() videoBlacklisted = new EventEmitter()
|
||||
@Output() videoUnblacklisted = new EventEmitter()
|
||||
@Output() videoRemoved = new EventEmitter()
|
||||
|
@ -82,6 +84,8 @@ export class VideoMiniatureComponent implements OnInit {
|
|||
playlistElementId?: number
|
||||
}
|
||||
|
||||
videoLink: any[] = []
|
||||
|
||||
private ownerDisplayTypeChosen: 'account' | 'videoChannel'
|
||||
|
||||
constructor (
|
||||
|
@ -103,7 +107,10 @@ export class VideoMiniatureComponent implements OnInit {
|
|||
ngOnInit () {
|
||||
this.serverConfig = this.serverService.getTmpConfig()
|
||||
this.serverService.getConfig()
|
||||
.subscribe(config => this.serverConfig = config)
|
||||
.subscribe(config => {
|
||||
this.serverConfig = config
|
||||
this.buildVideoLink()
|
||||
})
|
||||
|
||||
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 () {
|
||||
return this.ownerDisplayTypeChosen === 'account'
|
||||
}
|
||||
|
@ -203,7 +225,7 @@ export class VideoMiniatureComponent implements OnInit {
|
|||
}
|
||||
|
||||
isWatchLaterPlaylistDisplayed () {
|
||||
return this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
|
||||
return this.displayVideoActions && this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
|
||||
}
|
||||
|
||||
private setUpBy () {
|
||||
|
|
|
@ -33,10 +33,15 @@ export class Video implements VideoServerModel {
|
|||
serverHost: string
|
||||
thumbnailPath: string
|
||||
thumbnailUrl: string
|
||||
|
||||
previewPath: string
|
||||
previewUrl: string
|
||||
|
||||
embedPath: string
|
||||
embedUrl: string
|
||||
|
||||
url?: string
|
||||
|
||||
views: number
|
||||
likes: number
|
||||
dislikes: number
|
||||
|
@ -100,13 +105,15 @@ export class Video implements VideoServerModel {
|
|||
this.name = hash.name
|
||||
|
||||
this.thumbnailPath = hash.thumbnailPath
|
||||
this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
|
||||
this.thumbnailUrl = hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
|
||||
|
||||
this.previewPath = hash.previewPath
|
||||
this.previewUrl = absoluteAPIUrl + hash.previewPath
|
||||
this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
|
||||
|
||||
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.likes = hash.likes
|
||||
|
|
|
@ -94,14 +94,6 @@ log:
|
|||
maxFiles: 20
|
||||
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:
|
||||
videos:
|
||||
interval_days: 7 # Compute trending videos for the last x days
|
||||
|
@ -382,3 +374,28 @@ broadcast_message:
|
|||
message: '' # Support markdown
|
||||
level: 'info' # 'info' | 'warning' | 'error'
|
||||
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
|
||||
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:
|
||||
videos:
|
||||
interval_days: 7 # Compute trending videos for the last x days
|
||||
|
@ -396,3 +388,28 @@ broadcast_message:
|
|||
message: '' # Support markdown
|
||||
level: 'info' # 'info' | 'warning' | 'error'
|
||||
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:
|
||||
index:
|
||||
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: {
|
||||
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
|
||||
}
|
||||
},
|
||||
plugin: {
|
||||
|
@ -445,7 +451,19 @@ function customConfig (): CustomConfig {
|
|||
message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
|
||||
level: CONFIG.BROADCAST_MESSAGE.LEVEL,
|
||||
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 { 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 { logger } from '../../helpers/logger'
|
||||
import { getFormattedObjects } from '../../helpers/utils'
|
||||
import { VideoModel } from '../../models/video/video'
|
||||
import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger'
|
||||
import { getOrCreateActorAndServerAndModel } from '../../lib/activitypub/actor'
|
||||
import {
|
||||
asyncMiddleware,
|
||||
commonVideosFiltersValidator,
|
||||
|
@ -14,14 +26,9 @@ import {
|
|||
videosSearchSortValidator,
|
||||
videosSearchValidator
|
||||
} from '../../middlewares'
|
||||
import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search'
|
||||
import { getOrCreateActorAndServerAndModel } from '../../lib/activitypub/actor'
|
||||
import { logger } from '../../helpers/logger'
|
||||
import { VideoModel } from '../../models/video/video'
|
||||
import { VideoChannelModel } from '../../models/video/video-channel'
|
||||
import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger'
|
||||
import { MChannelAccountDefault, MVideoAccountLightBlacklistAllFiles } from '../../typings/models'
|
||||
import { getServerActor } from '@server/models/application/application'
|
||||
import { getOrCreateVideoAndAccountAndChannel } from '@server/lib/activitypub/videos'
|
||||
|
||||
const searchRouter = express.Router()
|
||||
|
||||
|
@ -68,9 +75,34 @@ function searchVideoChannels (req: express.Request, res: express.Response) {
|
|||
|
||||
// @username -> username to search in DB
|
||||
if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '')
|
||||
|
||||
if (isSearchIndexEnabled(query)) {
|
||||
return searchVideoChannelsIndex(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) {
|
||||
const serverActor = await getServerActor()
|
||||
|
||||
|
@ -120,13 +152,38 @@ async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean
|
|||
function searchVideos (req: express.Request, res: express.Response) {
|
||||
const query: VideosSearchQuery = req.query
|
||||
const search = query.search
|
||||
|
||||
if (search && (search.startsWith('http://') || search.startsWith('https://'))) {
|
||||
return searchVideoURI(search, res)
|
||||
}
|
||||
|
||||
if (isSearchIndexEnabled(query)) {
|
||||
return searchVideosIndex(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) {
|
||||
const options = Object.assign(query, {
|
||||
includeLocalVideos: true,
|
||||
|
@ -168,3 +225,35 @@ async function searchVideoURI (url: string, res: express.Response) {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
'theme.default',
|
||||
'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 = [
|
||||
[ // set
|
||||
|
|
|
@ -104,12 +104,6 @@ const CONFIG = {
|
|||
},
|
||||
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: {
|
||||
VIDEOS: {
|
||||
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 LEVEL () { return config.get<BroadcastMessageLevel>('broadcast_message.level') },
|
||||
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' ],
|
||||
|
||||
// Don't forget to update peertube-search-index with the same values
|
||||
VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
|
||||
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
|
||||
if (isTestInstance() === true) {
|
||||
PRIVATE_RSA_KEY_SIZE = 1024
|
||||
|
@ -704,6 +714,7 @@ export {
|
|||
API_VERSION,
|
||||
PEERTUBE_VERSION,
|
||||
LAZY_STATIC_PATHS,
|
||||
SEARCH_INDEX,
|
||||
HLS_REDUNDANCY_DIRECTORY,
|
||||
P2P_MEDIA_LOADER_PEER_VERSION,
|
||||
AVATARS_SIZE,
|
||||
|
|
|
@ -272,11 +272,22 @@ async function getOrCreateVideoAndAccountAndChannel (
|
|||
|
||||
const actor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
|
||||
const videoChannel = actor.VideoChannel
|
||||
const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(createVideo, fetchedVideo, videoChannel, syncParam.thumbnail)
|
||||
|
||||
await syncVideoExternalAttributes(videoCreated, fetchedVideo, syncParam)
|
||||
try {
|
||||
const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(createVideo, fetchedVideo, videoChannel, syncParam.thumbnail)
|
||||
|
||||
return { video: videoCreated, created: true, autoBlacklisted }
|
||||
await syncVideoExternalAttributes(videoCreated, fetchedVideo, syncParam)
|
||||
|
||||
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: {
|
||||
|
|
|
@ -11,6 +11,7 @@ import { PluginModel } from '../../models/server/plugin'
|
|||
import { PluginManager } from './plugin-manager'
|
||||
import { logger } from '../../helpers/logger'
|
||||
import { PEERTUBE_VERSION } from '../../initializers/constants'
|
||||
import { sanitizeUrl } from '@server/helpers/core-utils'
|
||||
|
||||
async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) {
|
||||
const { start = 0, count = 20, search, sort = 'npmName', pluginType } = options
|
||||
|
@ -55,7 +56,7 @@ async function getLatestPluginsVersion (npmNames: string[]): Promise<PeertubePlu
|
|||
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' })
|
||||
|
||||
|
|
|
@ -58,7 +58,14 @@ const customConfigUpdateValidator = [
|
|||
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.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) => {
|
||||
logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })
|
||||
|
|
|
@ -5,6 +5,8 @@ import { AccountBlock } from '../../../shared/models/blocklist'
|
|||
import { Op } from 'sequelize'
|
||||
import * as Bluebird from 'bluebird'
|
||||
import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/typings/models'
|
||||
import { ActorModel } from '../activitypub/actor'
|
||||
import { ServerModel } from '../server/server'
|
||||
|
||||
enum ScopeNames {
|
||||
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 {
|
||||
return {
|
||||
byAccount: this.ByAccount.toFormattedJSON(),
|
||||
|
|
|
@ -120,6 +120,27 @@ export class ServerBlocklistModel extends Model<ServerBlocklistModel> {
|
|||
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: {
|
||||
start: number
|
||||
count: number
|
||||
|
|
|
@ -139,6 +139,18 @@ describe('Test config API validators', function () {
|
|||
dismissable: true,
|
||||
message: 'super message',
|
||||
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',
|
||||
message: 'super bad message',
|
||||
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)
|
||||
|
|
|
@ -165,6 +165,18 @@ function updateCustomSubConfig (url: string, token: string, newConfig: DeepParti
|
|||
level: 'warning',
|
||||
message: 'hello',
|
||||
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 {
|
||||
path: string
|
||||
|
||||
url?: string
|
||||
|
||||
createdAt: 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
|
||||
|
||||
start?: number
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import { NSFWQuery } from './nsfw-query.model'
|
||||
import { VideoFilter } from '../videos'
|
||||
import { SearchTargetQuery } from './search-target-query.model'
|
||||
|
||||
export interface VideosSearchQuery extends SearchTargetQuery {
|
||||
forceLocalSearch?: boolean
|
||||
|
||||
export interface VideosSearchQuery {
|
||||
search?: string
|
||||
|
||||
start?: number
|
||||
|
|
|
@ -139,4 +139,18 @@ export interface CustomConfig {
|
|||
level: BroadcastMessageLevel
|
||||
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
|
||||
anonymous: boolean
|
||||
}
|
||||
|
||||
searchIndex: {
|
||||
enabled: boolean
|
||||
url: string
|
||||
disableLocalSearch: boolean
|
||||
isDefaultSearch: boolean
|
||||
}
|
||||
}
|
||||
|
||||
plugin: {
|
||||
|
|
|
@ -22,9 +22,19 @@ export interface Video {
|
|||
duration: number
|
||||
isLocal: boolean
|
||||
name: string
|
||||
|
||||
thumbnailPath: string
|
||||
thumbnailUrl?: string
|
||||
|
||||
previewPath: string
|
||||
previewUrl?: string
|
||||
|
||||
embedPath: string
|
||||
embedUrl?: string
|
||||
|
||||
// When using the search index
|
||||
url?: string
|
||||
|
||||
views: number
|
||||
likes: number
|
||||
dislikes: number
|
||||
|
|
Loading…
Reference in New Issue