From 5fb2e2888ce032c638e4b75d07458642f0833e52 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 29 May 2020 16:16:24 +0200 Subject: [PATCH] First implem global search --- .../edit-custom-config.component.html | 84 ++++++- .../edit-custom-config.component.scss | 6 +- .../edit-custom-config.component.ts | 16 ++ client/src/app/app.module.ts | 3 +- client/src/app/core/server/server.service.ts | 45 +++- client/src/app/header/index.ts | 1 - .../header/search-typeahead.component.html | 41 ++-- .../header/search-typeahead.component.scss | 8 +- .../app/header/search-typeahead.component.ts | 206 +++++++++++------- .../src/app/header/suggestion.component.html | 21 +- client/src/app/header/suggestion.component.ts | 22 +- .../src/app/header/suggestions.component.html | 6 - .../src/app/header/suggestions.component.ts | 24 -- .../src/app/search/advanced-search.model.ts | 21 +- .../app/search/channel-lazy-load.resolver.ts | 45 ++++ .../app/search/search-filters.component.html | 64 ++++-- .../app/search/search-filters.component.ts | 8 +- .../src/app/search/search-routing.module.ts | 20 +- client/src/app/search/search.component.html | 15 +- client/src/app/search/search.component.ts | 102 ++++++--- client/src/app/search/search.module.ts | 14 +- client/src/app/search/search.service.ts | 48 ++-- .../app/search/video-lazy-load.resolver.ts | 43 ++++ client/src/app/shared/actor/actor.model.ts | 10 +- .../src/app/shared/angular/highlight.pipe.ts | 20 +- .../custom-config-validators.service.ts | 8 + .../shared/users/user-notification.model.ts | 4 +- .../video/video-miniature.component.html | 4 +- .../shared/video/video-miniature.component.ts | 38 +++- client/src/app/shared/video/video.model.ts | 13 +- config/default.yaml | 33 ++- config/production.yaml.example | 33 ++- config/test.yaml | 22 ++ server/controllers/api/config.ts | 20 +- server/controllers/api/search.ts | 103 ++++++++- server/initializers/checker-after-init.ts | 7 + server/initializers/checker-before-init.ts | 4 +- server/initializers/config.ts | 18 +- server/initializers/constants.ts | 11 + server/lib/activitypub/videos.ts | 17 +- server/lib/plugins/plugin-index.ts | 3 +- server/middlewares/validators/config.ts | 9 +- server/models/account/account-blocklist.ts | 38 ++++ server/models/server/server-blocklist.ts | 21 ++ server/tests/api/check-params/config.ts | 12 + server/tests/api/server/config.ts | 12 + shared/extra-utils/server/config.ts | 12 + shared/models/avatars/avatar.model.ts | 3 + .../search/search-target-query.model.ts | 5 + .../video-channels-search-query.model.ts | 4 +- .../search/videos-search-query.model.ts | 5 +- shared/models/server/custom-config.model.ts | 14 ++ shared/models/server/server-config.model.ts | 7 + shared/models/videos/video.model.ts | 10 + 54 files changed, 1052 insertions(+), 331 deletions(-) delete mode 100644 client/src/app/header/suggestions.component.html delete mode 100644 client/src/app/header/suggestions.component.ts create mode 100644 client/src/app/search/channel-lazy-load.resolver.ts create mode 100644 client/src/app/search/video-lazy-load.resolver.ts create mode 100644 shared/models/search/search-target-query.model.ts diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html index 4ee573696..b8682ffe0 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html @@ -396,9 +396,9 @@ -
+
-
NEW VIDEOS
+
VIDEOS
@@ -445,6 +445,86 @@
+
+
+
SEARCH
+
+ +
+ + + + +
+ + + Add ability for your users to fetch remote videos/actors by their URI, that may not be federated with your instance + + +
+ +
+ + + Add ability for anonymous to fetch remote videos/actors by their URI, that may not be federated with your instance + + +
+ +
+ + +
+ + + +
+ + +
{{ formErrors.search.searchIndex.url }}
+
+ +
+ +
+ +
+ + + The local search is used by default + + +
+ +
+
+
+ +
+ +
+ +
+
+
FEDERATION
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss index 2bfa92da4..9618100b5 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss @@ -64,8 +64,10 @@ textarea { } .disabled-checkbox-extra { - opacity: .5; - pointer-events: none; + &, ::ng-deep label { + opacity: .5; + pointer-events: none; + } } .form-group-right { diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index 6d59494c8..3a47ba25e 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts @@ -221,6 +221,18 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A 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 } diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index e61346dac..89332ec5f 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts @@ -8,7 +8,7 @@ import 'focus-visible' 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, diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts index fdfbe4c02..a804efd28 100644 --- a/client/src/app/core/server/server.service.ts +++ b/client/src/app/core/server/server.service.ts @@ -1,15 +1,16 @@ +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(ServerService.BASE_STATS_URL) } + getDefaultSearchTarget (): Promise { + 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 ( baseUrl: string, attributeName: 'categories' | 'licences' | 'languages' | 'privacies', diff --git a/client/src/app/header/index.ts b/client/src/app/header/index.ts index a882d4d1f..005e0c97d 100644 --- a/client/src/app/header/index.ts +++ b/client/src/app/header/index.ts @@ -1,4 +1,3 @@ export * from './header.component' export * from './search-typeahead.component' -export * from './suggestions.component' export * from './suggestion.component' diff --git a/client/src/app/header/search-typeahead.component.html b/client/src/app/header/search-typeahead.component.html index bbf3834c5..4355b67af 100644 --- a/client/src/app/header/search-typeahead.component.html +++ b/client/src/app/header/search-typeahead.component.html @@ -1,38 +1,43 @@
- - + +
    +
  • + +
  • +
-
- -
- -
- using {{ serverConfig.followings.instance.autoFollowIndex.indexUrl }} - -
+
+
+ +
+ using {{ serverConfig.search.searchIndex.url }} +
-
Results will be augmented with those of a third-party index. Only data necessary to make the query will be sent.
- +
+
Results will be augmented with those of a third-party index. Only data necessary to make the query will be sent.
-
+
- any instance - only followed instances - + any instance + only followed instances +
diff --git a/client/src/app/header/search-typeahead.component.scss b/client/src/app/header/search-typeahead.component.scss index 0a30ebd55..4b56fd93a 100644 --- a/client/src/app/header/search-typeahead.component.scss +++ b/client/src/app/header/search-typeahead.component.scss @@ -36,7 +36,7 @@ #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; } } diff --git a/client/src/app/header/search-typeahead.component.ts b/client/src/app/header/search-typeahead.component.ts index 2bf1072f4..6c8b8efee 100644 --- a/client/src/app/header/search-typeahead.component.ts +++ b/client/src/app/header/search-typeahead.component.ts @@ -1,23 +1,24 @@ -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 +export class SearchTypeaheadComponent implements OnInit, AfterViewInit, AfterViewChecked, OnDestroy { + @ViewChildren(SuggestionComponent) suggestionItems: QueryList 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 - 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, 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, 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' + } } diff --git a/client/src/app/header/suggestion.component.html b/client/src/app/header/suggestion.component.html index d7ae3450a..ab4b4b678 100644 --- a/client/src/app/header/suggestion.component.html +++ b/client/src/app/header/suggestion.component.html @@ -1,22 +1,17 @@
- - +
-
+
-
- In this channel +
In this instance - In the vidiverse - + In the vidiverse
- - -
\ No newline at end of file + diff --git a/client/src/app/header/suggestion.component.ts b/client/src/app/header/suggestion.component.ts index 69641b511..250a5411e 100644 --- a/client/src/app/header/suggestion.component.ts +++ b/client/src/app/header/suggestion.component.ts @@ -1,24 +1,24 @@ -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) - } } diff --git a/client/src/app/header/suggestions.component.html b/client/src/app/header/suggestions.component.html deleted file mode 100644 index 8d017d78d..000000000 --- a/client/src/app/header/suggestions.component.html +++ /dev/null @@ -1,6 +0,0 @@ -
    -
  • - -
  • -
\ No newline at end of file diff --git a/client/src/app/header/suggestions.component.ts b/client/src/app/header/suggestions.component.ts deleted file mode 100644 index ee3ef73c2..000000000 --- a/client/src/app/header/suggestions.component.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 - @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 }) - } -} diff --git a/client/src/app/search/advanced-search.model.ts b/client/src/app/search/advanced-search.model.ts index 50f00bc27..643cc9a29 100644 --- a/client/src/app/search/advanced-search.model.ts +++ b/client/src/app/search/advanced-search.model.ts @@ -1,3 +1,4 @@ +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++ } diff --git a/client/src/app/search/channel-lazy-load.resolver.ts b/client/src/app/search/channel-lazy-load.resolver.ts new file mode 100644 index 000000000..8be089cdd --- /dev/null +++ b/client/src/app/search/channel-lazy-load.resolver.ts @@ -0,0 +1,45 @@ +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 { + 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) + }) + ) + } +} diff --git a/client/src/app/search/search-filters.component.html b/client/src/app/search/search-filters.component.html index 54fc7338f..e20aef8fb 100644 --- a/client/src/app/search/search-filters.component.html +++ b/client/src/app/search/search-filters.component.html @@ -16,6 +16,25 @@
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
@@ -39,7 +58,7 @@
-
+
-
+
+
+ +
@@ -76,28 +98,6 @@
-
-
- - -
- -
- - -
- -
- - -
-
- -
- -
+ +
+
+ +
+ +
+ + +
+ +
+ + +
+
diff --git a/client/src/app/search/search-filters.component.ts b/client/src/app/search/search-filters.component.ts index 344a260df..af76260a7 100644 --- a/client/src/app/search/search-filters.component.ts +++ b/client/src/app/search/search-filters.component.ts @@ -44,7 +44,7 @@ export class SearchFiltersComponent implements OnInit { 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() diff --git a/client/src/app/search/search-routing.module.ts b/client/src/app/search/search-routing.module.ts index 0ac9e6b57..9da900e9a 100644 --- a/client/src/app/search/search-routing.module.ts +++ b/client/src/app/search/search-routing.module.ts @@ -1,7 +1,9 @@ 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 + } } ] diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html index a4a1d41b3..3cafc676d 100644 --- a/client/src/app/search/search.component.html +++ b/client/src/app/search/search.component.html @@ -2,7 +2,11 @@
- {{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}} + {{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}} + + on this instance + on the vidiverse + for {{ currentSearch }} @@ -31,12 +35,12 @@
- + Avatar
- +
{{ result.displayName }}
{{ result.nameWithHost }}
@@ -44,12 +48,13 @@
{{ result.followersCount }} subscribers
- +
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts index 075994dd3..d3c0761d7 100644 --- a/client/src/app/search/search.component.ts +++ b/client/src/app/search/search.component.ts @@ -1,16 +1,18 @@ +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( diff --git a/client/src/app/search/search.module.ts b/client/src/app/search/search.module.ts index 3b0fd6ee2..df5459802 100644 --- a/client/src/app/search/search.module.ts +++ b/client/src/app/search/search.module.ts @@ -1,10 +1,12 @@ -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 { } diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts index 3cad5aaa7..fdb12ea2c 100644 --- a/client/src/app/search/search.service.ts +++ b/client/src/app/search/search.service.ts @@ -1,17 +1,18 @@ +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> { 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>(url, { params }) @@ -56,17 +63,26 @@ export class SearchService { searchVideoChannels (parameters: { search: string, - componentPagination: ComponentPaginationLight + searchTarget?: SearchTargetType, + componentPagination?: ComponentPaginationLight }): Observable> { - 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>(url, { params }) .pipe( diff --git a/client/src/app/search/video-lazy-load.resolver.ts b/client/src/app/search/video-lazy-load.resolver.ts new file mode 100644 index 000000000..8d846d367 --- /dev/null +++ b/client/src/app/search/video-lazy-load.resolver.ts @@ -0,0 +1,43 @@ +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 { + 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) + }) + ) + } +} diff --git a/client/src/app/shared/actor/actor.model.ts b/client/src/app/shared/actor/actor.model.ts index 0e5060f67..a78303a2f 100644 --- a/client/src/app/shared/actor/actor.model.ts +++ b/client/src/app/shared/actor/actor.model.ts @@ -15,10 +15,14 @@ export abstract class Actor implements ActorServer { 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() } diff --git a/client/src/app/shared/angular/highlight.pipe.ts b/client/src/app/shared/angular/highlight.pipe.ts index fb6042280..50ee5c1bd 100644 --- a/client/src/app/shared/angular/highlight.pipe.ts +++ b/client/src/app/shared/angular/highlight.pipe.ts @@ -11,19 +11,17 @@ export class HighlightPipe implements PipeTransform { /* 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) => `${match}` + regex, + (match) => `${match}` ) + return replaced } else { return contentString diff --git a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts index abcbca817..fdb19e06a 100644 --- a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts +++ b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts @@ -14,6 +14,7 @@ export class CustomConfigValidatorsService { 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') + } + } } } diff --git a/client/src/app/shared/users/user-notification.model.ts b/client/src/app/shared/users/user-notification.model.ts index ba29cb462..7b8368d87 100644 --- a/client/src/app/shared/users/user-notification.model.ts +++ b/client/src/app/shared/users/user-notification.model.ts @@ -1,4 +1,4 @@ -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) } } diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html index d354a2930..3e23cf18c 100644 --- a/client/src/app/shared/video/video-miniature.component.html +++ b/client/src/app/shared/video/video-miniature.component.html @@ -1,6 +1,6 @@
Unlisted @@ -12,7 +12,7 @@ {{ video.name }}
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts index a1d4f0e81..aa1726ca7 100644 --- a/client/src/app/shared/video/video-miniature.component.ts +++ b/client/src/app/shared/video/video-miniature.component.ts @@ -1,3 +1,4 @@ +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 () { diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts index 546518cca..97759f9c1 100644 --- a/client/src/app/shared/video/video.model.ts +++ b/client/src/app/shared/video/video.model.ts @@ -33,10 +33,15 @@ export class Video implements VideoServerModel { 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 diff --git a/config/default.yaml b/config/default.yaml index f6e944298..050019670 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -94,14 +94,6 @@ log: 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 diff --git a/config/production.yaml.example b/config/production.yaml.example index e21528821..6f658e61a 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -95,14 +95,6 @@ log: 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 diff --git a/config/test.yaml b/config/test.yaml index 74979f3a7..da34ccd03 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -98,3 +98,25 @@ instance: 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 diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index 41e5027b9..1d48b4b26 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts @@ -76,6 +76,12 @@ async function getConfig (req: express.Request, res: express.Response) { 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 + } + }, } } diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts index 35d94d747..e08e1d79f 100644 --- a/server/controllers/api/search.ts +++ b/server/controllers/api/search.ts @@ -1,7 +1,19 @@ 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>({ 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>({ 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 + } +} diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts index b5b854137..b49ab6bca 100644 --- a/server/initializers/checker-after-init.ts +++ b/server/initializers/checker-after-init.ts @@ -128,6 +128,13 @@ function checkConfig () { } } + // 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 } diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts index bd8f02bc0..e0819c4aa 100644 --- a/server/initializers/checker-before-init.ts +++ b/server/initializers/checker-before-init.ts @@ -35,7 +35,9 @@ function checkMissedConfig () { '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 diff --git a/server/initializers/config.ts b/server/initializers/config.ts index 44fd9045b..5b402dd74 100644 --- a/server/initializers/config.ts +++ b/server/initializers/config.ts @@ -104,12 +104,6 @@ const CONFIG = { }, ANONYMIZE_IP: config.get('log.anonymizeIP') }, - SEARCH: { - REMOTE_URI: { - USERS: config.get('search.remote_uri.users'), - ANONYMOUS: config.get('search.remote_uri.anonymous') - } - }, TRENDING: { VIDEOS: { INTERVAL_DAYS: config.get('trending.videos.interval_days') @@ -297,6 +291,18 @@ const CONFIG = { get MESSAGE () { return config.get('broadcast_message.message') }, get LEVEL () { return config.get('broadcast_message.level') }, get DISMISSABLE () { return config.get('broadcast_message.dismissable') } + }, + SEARCH: { + REMOTE_URI: { + USERS: config.get('search.remote_uri.users'), + ANONYMOUS: config.get('search.remote_uri.anonymous') + }, + SEARCH_INDEX: { + get ENABLED () { return config.get('search.search_index.enabled') }, + get URL () { return config.get('search.search_index.url') }, + get DISABLE_LOCAL_SEARCH () { return config.get('search.search_index.disable_local_search') }, + get IS_DEFAULT_SEARCH () { return config.get('search.search_index.is_default_search') } + } } } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index d201df3d8..314f094b3 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -61,6 +61,7 @@ const SORTABLE_COLUMNS = { 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, diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index 7d16bd390..6d20e0e65 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts @@ -272,11 +272,22 @@ async function getOrCreateVideoAndAccountAndChannel ( 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: { diff --git a/server/lib/plugins/plugin-index.ts b/server/lib/plugins/plugin-index.ts index 170f0c7e2..7bcb6ed4c 100644 --- a/server/lib/plugins/plugin-index.ts +++ b/server/lib/plugins/plugin-index.ts @@ -11,6 +11,7 @@ import { PluginModel } from '../../models/server/plugin' 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({ uri, body: bodyRequest, json: true, method: 'POST' }) diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts index 6905ac762..d3669f6be 100644 --- a/server/middlewares/validators/config.ts +++ b/server/middlewares/validators/config.ts @@ -58,7 +58,14 @@ const customConfigUpdateValidator = [ 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 }) diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts index d8a7ce4b4..2c6b756d2 100644 --- a/server/models/account/account-blocklist.ts +++ b/server/models/account/account-blocklist.ts @@ -5,6 +5,8 @@ import { AccountBlock } from '../../../shared/models/blocklist' 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 { }) } + static listHandlesBlockedBy (accountIds: number[]): Bluebird { + 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(), diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts index 892024c04..ad8e3d1e8 100644 --- a/server/models/server/server-blocklist.ts +++ b/server/models/server/server-blocklist.ts @@ -120,6 +120,27 @@ export class ServerBlocklistModel extends Model { return ServerBlocklistModel.findOne(query) } + static listHostsBlockedBy (accountIds: number[]): Bluebird { + 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 diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts index 7c96fa762..3f2708f94 100644 --- a/server/tests/api/check-params/config.ts +++ b/server/tests/api/check-params/config.ts @@ -139,6 +139,18 @@ describe('Test config API validators', function () { 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 + } } } diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts index d18a93082..597233588 100644 --- a/server/tests/api/server/config.ts +++ b/server/tests/api/server/config.ts @@ -340,6 +340,18 @@ describe('Test config', function () { 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) diff --git a/shared/extra-utils/server/config.ts b/shared/extra-utils/server/config.ts index 98cd435f6..eb06a1516 100644 --- a/shared/extra-utils/server/config.ts +++ b/shared/extra-utils/server/config.ts @@ -165,6 +165,18 @@ function updateCustomSubConfig (url: string, token: string, newConfig: DeepParti level: 'warning', message: 'hello', dismissable: true + }, + search: { + remoteUri: { + users: true, + anonymous: true + }, + searchIndex: { + enabled: true, + url: 'https://search.joinpeertube.org', + disableLocalSearch: true, + isDefaultSearch: true + } } } diff --git a/shared/models/avatars/avatar.model.ts b/shared/models/avatars/avatar.model.ts index 301d00929..f7fa16f49 100644 --- a/shared/models/avatars/avatar.model.ts +++ b/shared/models/avatars/avatar.model.ts @@ -1,5 +1,8 @@ export interface Avatar { path: string + + url?: string + createdAt: Date | string updatedAt: Date | string } diff --git a/shared/models/search/search-target-query.model.ts b/shared/models/search/search-target-query.model.ts new file mode 100644 index 000000000..3bb2e0d31 --- /dev/null +++ b/shared/models/search/search-target-query.model.ts @@ -0,0 +1,5 @@ +export type SearchTargetType = 'local' | 'search-index' + +export interface SearchTargetQuery { + searchTarget?: SearchTargetType +} diff --git a/shared/models/search/video-channels-search-query.model.ts b/shared/models/search/video-channels-search-query.model.ts index de2741e14..c96aa8c1d 100644 --- a/shared/models/search/video-channels-search-query.model.ts +++ b/shared/models/search/video-channels-search-query.model.ts @@ -1,4 +1,6 @@ -export interface VideoChannelsSearchQuery { +import { SearchTargetQuery } from "./search-target-query.model" + +export interface VideoChannelsSearchQuery extends SearchTargetQuery { search: string start?: number diff --git a/shared/models/search/videos-search-query.model.ts b/shared/models/search/videos-search-query.model.ts index 838063095..bd6bb5bc1 100644 --- a/shared/models/search/videos-search-query.model.ts +++ b/shared/models/search/videos-search-query.model.ts @@ -1,7 +1,10 @@ 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 diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts index 851bf1854..338a59341 100644 --- a/shared/models/server/custom-config.model.ts +++ b/shared/models/server/custom-config.model.ts @@ -139,4 +139,18 @@ export interface CustomConfig { level: BroadcastMessageLevel dismissable: boolean } + + search: { + remoteUri: { + users: boolean + anonymous: boolean + } + + searchIndex: { + enabled: boolean + url: string + disableLocalSearch: boolean + isDefaultSearch: boolean + } + } } diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts index 9c903b7ee..a8e5dfbff 100644 --- a/shared/models/server/server-config.model.ts +++ b/shared/models/server/server-config.model.ts @@ -50,6 +50,13 @@ export interface ServerConfig { users: boolean anonymous: boolean } + + searchIndex: { + enabled: boolean + url: string + disableLocalSearch: boolean + isDefaultSearch: boolean + } } plugin: { diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts index a69152759..0f8822125 100644 --- a/shared/models/videos/video.model.ts +++ b/shared/models/videos/video.model.ts @@ -22,9 +22,19 @@ export interface Video { 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