</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">
</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>
}
.disabled-checkbox-extra {
- opacity: .5;
- pointer-events: none;
+ &, ::ng-deep label {
+ opacity: .5;
+ pointer-events: none;
+ }
}
.form-group-right {
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
+ }
}
}
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
}
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'
AvatarNotificationComponent,
HeaderComponent,
SearchTypeaheadComponent,
- SuggestionsComponent,
SuggestionComponent,
CustomModalComponent,
+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 {
css: ''
}
},
- search: {
- remoteUri: {
- users: true,
- anonymous: false
- }
- },
plugin: {
registered: [],
registeredExternalAuths: [],
message: '',
level: 'info',
dismissable: false
+ },
+ search: {
+ remoteUri: {
+ users: true,
+ anonymous: false
+ },
+ searchIndex: {
+ enabled: false,
+ url: '',
+ disableLocalSearch: false,
+ isDefaultSearch: false
+ }
}
}
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',
export * from './header.component'
export * from './search-typeahead.component'
-export * from './suggestions.component'
export * from './suggestion.component'
<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>
#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;
}
& > 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;
}
@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;
}
}
-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
inThisChannelText: string
keyboardEventsManager: ListKeyManager<SuggestionComponent>
- results: Result[] = []
+ results: SuggestionPayload[] = []
+
+ activeSearch: SuggestionPayloadType
+
+ private scheduleKeyboardEventsInit = false
constructor (
private authService: AuthService,
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()
+ })
+ })
}
- ngOnDestroy () {
- if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
+ ngAfterViewChecked () {
+ if (this.scheduleKeyboardEventsInit && !this.keyboardEventsManager) {
+ // Avoid ExpressionChangedAfterItHasBeenCheckedError errors
+ setTimeout(() => this.initKeyboardEventsManager(), 0)
+ }
}
- get activeResult () {
- return this.keyboardEventsManager?.activeItem?.result
+ ngOnDestroy () {
+ if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
}
- 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()
- }
-
- computeResults () {
- this.newSearch = true
- let results: Result[] = []
-
- 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.computeTypeahead()
+ }
+
+ initKeyboardEventsManager () {
+ if (this.keyboardEventsManager) return
+
+ 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
- }
+ this.updateItemsState(activeIndex)
+
+ this.keyboardEventsManager.change.subscribe(
+ _ => this.updateItemsState()
)
}
- setEventItems (event: { items: QueryList<SuggestionComponent>, index?: number }) {
- event.items.forEach(e => {
- if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === e) {
- this.keyboardEventsManager.activeItem.active = true
+ computeTypeahead () {
+ const searchIndexConfig = this.serverConfig.search.searchIndex
+
+ if (!this.activeSearch) {
+ if (searchIndexConfig.enabled && searchIndexConfig.isDefaultSearch) {
+ this.activeSearch = 'search-instance'
} 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'
+ })
+ }
+
+ if (searchIndexConfig.enabled) {
+ this.results.push({
+ text: this.search,
+ type: 'search-index',
+ default: this.activeSearch === 'search-index'
+ })
+ }
+
+ this.scheduleKeyboardEventsInit = true
}
- initKeyboardEventsManager (event: { items: QueryList<SuggestionComponent>, index?: number }) {
- if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
+ updateItemsState (index?: number) {
+ if (index !== undefined) {
+ this.keyboardEventsManager.setActiveItem(index)
+ }
- this.keyboardEventsManager = new ListKeyManager(event.items)
+ for (const item of this.suggestionItems) {
+ if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === item) {
+ item.active = true
+ this.activeSearch = item.result.type
+ continue
+ }
- if (event.index !== undefined) {
- this.keyboardEventsManager.setActiveItem(event.index)
- } else {
- this.keyboardEventsManager.setFirstItemActive()
+ item.active = false
}
+ }
- this.keyboardEventsManager.change.subscribe(
- _ => this.setEventItems(event)
- )
+ 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
}
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)
tap(() => Object.assign(queryParams, { languageOneOf: this.authService.getUser().videoLanguages }))
)
}
+
+ private buildSearchTarget (result: SuggestionPayload): SearchTargetType {
+ if (result.type === 'search-index') {
+ return 'search-index'
+ }
+
+ return 'local'
+ }
}
<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>
\ No newline at end of file
+</a>
-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
ngOnInit () {
if (this.result.default) this.active = true
}
-
- selectItem () {
- this.selected.emit(this.result)
- }
}
+++ /dev/null
-<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>
\ No newline at end of file
+++ /dev/null
-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 })
- }
-}
+import { SearchTargetType } from '@shared/models/search/search-target-query.model'
import { NSFWQuery } from '../../../../shared/models/search'
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
durationMin?: string
durationMax?: string
sort?: string
+ searchTarget?: SearchTargetType
}) {
if (!options) return
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
}
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
}
tagsAllOf: this.tagsAllOf,
durationMin: this.durationMin,
durationMax: this.durationMax,
- sort: this.sort
+ sort: this.sort,
+ searchTarget: this.searchTarget
}
}
tagsAllOf: this.intoArray(this.tagsAllOf),
durationMin: this.durationMin,
durationMax: this.durationMax,
- sort: this.sort
+ sort: this.sort,
+ searchTarget: this.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] !== '') acc++
}
--- /dev/null
+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)
+ })
+ )
+ }
+}
</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>
</div>
<div class="row">
- <div class="col-sm-6">
+ <div class="pl-0 col-sm-6">
<input
(change)="inputUpdated()"
(keydown.enter)="$event.preventDefault()"
class="form-control"
>
</div>
- <div class="col-sm-6">
+ <div class="pr-0 col-sm-6">
<input
(change)="inputUpdated()"
(keydown.enter)="$event.preventDefault()"
</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>
</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">
[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>
this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES
this.publishedDateRanges = [
{
- id: undefined,
+ id: 'any_published_date',
label: this.i18n('Any')
},
{
this.durationRanges = [
{
- id: undefined,
+ id: 'any_duration',
label: this.i18n('Any')
},
{
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()
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 = [
{
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
+ }
}
]
<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>
<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>
<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>
+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 { 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 { 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 { 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',
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
private notifier: Notifier,
private searchService: SearchService,
private authService: AuthService,
- private hooks: HooksService
+ private hooks: HooksService,
+ private serverService: ServerService
) { }
get user () {
}
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
}
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()
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
-
- // 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()
- }
+ ]).subscribe(
+ ([videosResult, videoChannelsResult]) => {
+ this.results = this.results
+ .concat(videoChannelsResult.data)
+ .concat(videosResult.data)
+
+ 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 () {
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
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(
-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: [
],
providers: [
- SearchService
+ SearchService,
+ VideoLazyLoadResolver,
+ ChannelLazyLoadResolver
]
})
export class SearchModule { }
+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 {
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 })
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(
--- /dev/null
+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)
+ })
+ )
+ }
+}
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) {
+ const absoluteAPIUrl = getAbsoluteAPIUrl()
- if (actor && actor.avatar) return absoluteAPIUrl + actor.avatar.path
+ return absoluteAPIUrl + actor.avatar.path
+ }
return this.GET_DEFAULT_AVATAR_URL()
}
/* 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)
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
readonly ADMIN_EMAIL: BuildFormValidator
readonly TRANSCODING_THREADS: BuildFormValidator
readonly INDEX_URL: BuildFormValidator
+ readonly SEARCH_INDEX_URL: BuildFormValidator
constructor (private i18n: I18n) {
this.INSTANCE_NAME = {
'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')
+ }
+ }
}
}
-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 {
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)
}
}
<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>
<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">
+import { switchMap } from 'rxjs/operators'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
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 = {
@Input() displayVideoActions = true
@Input() fitWidth = false
+ @Input() useLazyLoadUrl = false
+
@Output() videoBlacklisted = new EventEmitter()
@Output() videoUnblacklisted = new EventEmitter()
@Output() videoRemoved = new EventEmitter()
playlistElementId?: number
}
+ videoLink: any[] = []
+
private ownerDisplayTypeChosen: 'account' | 'videoChannel'
constructor (
ngOnInit () {
this.serverConfig = this.serverService.getTmpConfig()
this.serverService.getConfig()
- .subscribe(config => this.serverConfig = config)
+ .subscribe(config => {
+ this.serverConfig = config
+ this.buildVideoLink()
+ })
this.setUpBy()
}
}
+ 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'
}
}
isWatchLaterPlaylistDisplayed () {
- return this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
+ return this.displayVideoActions && this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
}
private setUpBy () {
serverHost: string
thumbnailPath: string
thumbnailUrl: string
+
previewPath: string
previewUrl: string
+
embedPath: string
embedUrl: string
+
+ url?: string
+
views: number
likes: number
dislikes: number
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
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
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
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
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
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
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: {
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
+ }
+ },
}
}
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,
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()
// @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()
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,
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
+ }
+}
}
}
+ // 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
}
'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
},
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')
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') }
+ }
}
}
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' ],
// ---------------------------------------------------------------------------
+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
API_VERSION,
PEERTUBE_VERSION,
LAZY_STATIC_PATHS,
+ SEARCH_INDEX,
HLS_REDUNDANCY_DIRECTORY,
P2P_MEDIA_LOADER_PEER_VERSION,
AVATARS_SIZE,
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)
+
+ 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: {
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
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' })
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 })
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'
})
}
+ 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(),
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
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
+ }
}
}
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)
level: 'warning',
message: 'hello',
dismissable: true
+ },
+ search: {
+ remoteUri: {
+ users: true,
+ anonymous: true
+ },
+ searchIndex: {
+ enabled: true,
+ url: 'https://search.joinpeertube.org',
+ disableLocalSearch: true,
+ isDefaultSearch: true
+ }
}
}
export interface Avatar {
path: string
+
+ url?: string
+
createdAt: Date | string
updatedAt: Date | string
}
--- /dev/null
+export type SearchTargetType = 'local' | 'search-index'
+
+export interface SearchTargetQuery {
+ searchTarget?: SearchTargetType
+}
-export interface VideoChannelsSearchQuery {
+import { SearchTargetQuery } from "./search-target-query.model"
+
+export interface VideoChannelsSearchQuery extends SearchTargetQuery {
search: string
start?: number
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
level: BroadcastMessageLevel
dismissable: boolean
}
+
+ search: {
+ remoteUri: {
+ users: boolean
+ anonymous: boolean
+ }
+
+ searchIndex: {
+ enabled: boolean
+ url: string
+ disableLocalSearch: boolean
+ isDefaultSearch: boolean
+ }
+ }
}
users: boolean
anonymous: boolean
}
+
+ searchIndex: {
+ enabled: boolean
+ url: string
+ disableLocalSearch: boolean
+ isDefaultSearch: boolean
+ }
}
plugin: {
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