First implem global search
authorChocobozzz <me@florianbigard.com>
Fri, 29 May 2020 14:16:24 +0000 (16:16 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Wed, 10 Jun 2020 12:02:41 +0000 (14:02 +0200)
54 files changed:
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/app.module.ts
client/src/app/core/server/server.service.ts
client/src/app/header/index.ts
client/src/app/header/search-typeahead.component.html
client/src/app/header/search-typeahead.component.scss
client/src/app/header/search-typeahead.component.ts
client/src/app/header/suggestion.component.html
client/src/app/header/suggestion.component.ts
client/src/app/header/suggestions.component.html [deleted file]
client/src/app/header/suggestions.component.ts [deleted file]
client/src/app/search/advanced-search.model.ts
client/src/app/search/channel-lazy-load.resolver.ts [new file with mode: 0644]
client/src/app/search/search-filters.component.html
client/src/app/search/search-filters.component.ts
client/src/app/search/search-routing.module.ts
client/src/app/search/search.component.html
client/src/app/search/search.component.ts
client/src/app/search/search.module.ts
client/src/app/search/search.service.ts
client/src/app/search/video-lazy-load.resolver.ts [new file with mode: 0644]
client/src/app/shared/actor/actor.model.ts
client/src/app/shared/angular/highlight.pipe.ts
client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
client/src/app/shared/users/user-notification.model.ts
client/src/app/shared/video/video-miniature.component.html
client/src/app/shared/video/video-miniature.component.ts
client/src/app/shared/video/video.model.ts
config/default.yaml
config/production.yaml.example
config/test.yaml
server/controllers/api/config.ts
server/controllers/api/search.ts
server/initializers/checker-after-init.ts
server/initializers/checker-before-init.ts
server/initializers/config.ts
server/initializers/constants.ts
server/lib/activitypub/videos.ts
server/lib/plugins/plugin-index.ts
server/middlewares/validators/config.ts
server/models/account/account-blocklist.ts
server/models/server/server-blocklist.ts
server/tests/api/check-params/config.ts
server/tests/api/server/config.ts
shared/extra-utils/server/config.ts
shared/models/avatars/avatar.model.ts
shared/models/search/search-target-query.model.ts [new file with mode: 0644]
shared/models/search/video-channels-search-query.model.ts
shared/models/search/videos-search-query.model.ts
shared/models/server/custom-config.model.ts
shared/models/server/server-config.model.ts
shared/models/videos/video.model.ts

index 4ee573696705a605a116c768055dda2cb0d31c19..b8682ffe0ae264d02d20012a4b77bc3baa6a423a 100644 (file)
           </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>
index 2bfa92da4e0cef0c05da0f501c8e99059a33f657..9618100b51a81466962e3b67f6c07d55199ea186 100644 (file)
@@ -64,8 +64,10 @@ textarea {
 }
 
 .disabled-checkbox-extra {
-  opacity: .5;
-  pointer-events: none;
+  &, ::ng-deep label {
+    opacity: .5;
+    pointer-events: none;
+  }
 }
 
 .form-group-right {
index 6d59494c88a7192ec8690a5747b24ffeb2a69726..3a47ba25e3f2ecf4286df5b46d21e69f3275da8d 100644 (file)
@@ -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
   }
index e61346dac9addbbccb0d45f2b662ef1aa99ecdcf..89332ec5f4720057f163396a03b37ef370d60ad3 100644 (file)
@@ -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,
index fdfbe4c0260b68d17ca6da0aa9869a4416c9c0e6..a804efd2888ad27e9bcb87b578c3947474142b74 100644 (file)
@@ -1,15 +1,16 @@
+import { Observable, of, Subject } from 'rxjs'
 import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators'
 import { HttpClient } from '@angular/common/http'
 import { Inject, Injectable, LOCALE_ID } from '@angular/core'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { Observable, of, Subject } from 'rxjs'
-import { getCompleteLocale, ServerConfig } from '../../../../../shared'
-import { environment } from '../../../environments/environment'
-import { VideoConstant } from '../../../../../shared/models/videos'
-import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
 import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
+import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
 import { sortBy } from '@app/shared/misc/utils'
+import { SearchTargetType } from '@shared/models/search/search-target-query.model'
 import { ServerStats } from '@shared/models/server'
+import { getCompleteLocale, ServerConfig } from '../../../../../shared'
+import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
+import { VideoConstant } from '../../../../../shared/models/videos'
+import { environment } from '../../../environments/environment'
 
 @Injectable()
 export class ServerService {
@@ -47,12 +48,6 @@ export class ServerService {
         css: ''
       }
     },
-    search: {
-      remoteUri: {
-        users: true,
-        anonymous: false
-      }
-    },
     plugin: {
       registered: [],
       registeredExternalAuths: [],
@@ -145,6 +140,18 @@ export class ServerService {
       message: '',
       level: 'info',
       dismissable: false
+    },
+    search: {
+      remoteUri: {
+        users: true,
+        anonymous: false
+      },
+      searchIndex: {
+        enabled: false,
+        url: '',
+        disableLocalSearch: false,
+        isDefaultSearch: false
+      }
     }
   }
 
@@ -264,6 +271,20 @@ export class ServerService {
     return this.http.get<ServerStats>(ServerService.BASE_STATS_URL)
   }
 
+  getDefaultSearchTarget (): Promise<SearchTargetType> {
+    return this.getConfig().pipe(
+      map(config => {
+        const searchIndexConfig = config.search.searchIndex
+
+        if (searchIndexConfig.enabled && (searchIndexConfig.isDefaultSearch || searchIndexConfig.disableLocalSearch)) {
+          return 'search-index'
+        }
+
+        return 'local'
+      })
+    ).toPromise()
+  }
+
   private loadAttributeEnum <T extends string | number> (
     baseUrl: string,
     attributeName: 'categories' | 'licences' | 'languages' | 'privacies',
index a882d4d1faaf82877afdaa1facc0ff1cf17f7d2e..005e0c97da3cc74a1cc41888056b68047ee6bc61 100644 (file)
@@ -1,4 +1,3 @@
 export * from './header.component'
 export * from './search-typeahead.component'
-export * from './suggestions.component'
 export * from './suggestion.component'
index bbf3834c51cf9f36093c2be577d06cf0a9a19822..4355b67af568d63bebb454a8738d6425fd7a90e3 100644 (file)
@@ -1,38 +1,43 @@
 <div class="d-inline-flex position-relative" id="typeahead-container">
   <input
     type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…"
-    [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keyup)="handleKey($event)" (keydown.enter)="doSearch()"
-    aria-label="Search"
+    [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keydown)="handleKey($event)" (keydown.enter)="doSearch()"
+    aria-label="Search" autocomplete="off"
   >
   <span class="icon icon-search" (click)="doSearch()"></span>
 
   <div class="position-absolute jump-to-suggestions">
-    <!-- suggestions -->
-    <my-suggestions *ngIf="search && newSearch" [results]="results" [highlight]="search" (init)="initKeyboardEventsManager($event)"></my-suggestions>
+
+    <ul [hidden]="!search || !areSuggestionsOpened" role="listbox" class="p-0 m-0">
+      <li
+        *ngFor="let result of results; let i = index" class="suggestion d-flex flex-justify-start flex-items-center p-0 f5"
+        role="option" aria-selected="true" (mouseenter)="onSuggestionHover(i)" (click)="onSuggestionlicked(result)"
+      >
+        <my-suggestion [result]="result" [highlight]="search"></my-suggestion>
+      </li>
+    </ul>
 
     <!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion -->
-    <div *ngIf="showHelp" id="typeahead-help" class="overflow-hidden">
-      <ng-container *ngIf="activeResult.type === 'search-global'">
-        <div class="d-flex justify-content-between">
-          <label class="small-title" i18n>GLOBAL SEARCH</label>
-          <div class="advanced-search-status text-muted">
-            <span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.followings.instance.autoFollowIndex.indexUrl }}</span>
-            <i class="glyphicon glyphicon-globe"></i>
-          </div>
+    <div *ngIf="showSearchGlobalHelp()" id="typeahead-help" class="overflow-hidden">
+      <div class="d-flex justify-content-between">
+        <label class="small-title" i18n>GLOBAL SEARCH</label>
+        <div class="advanced-search-status text-muted">
+          <span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.search.searchIndex.url }}</span>
+          <i class="glyphicon glyphicon-globe"></i>
         </div>
-        <div class="text-muted" i18n>Results will be augmented with those of a third-party index. Only data necessary to make the query will be sent.</div>
-      </ng-container>
+      </div>
+      <div class="text-muted" i18n>Results will be augmented with those of a third-party index. Only data necessary to make the query will be sent.</div>
     </div>
 
     <!-- search instructions, when search input is empty -->
-    <div *ngIf="areInstructionsDisplayed" id="typeahead-instructions" class="overflow-hidden">
+    <div *ngIf="areInstructionsDisplayed()" id="typeahead-instructions" class="overflow-hidden">
       <div class="d-flex justify-content-between">
         <label class="small-title" i18n>ADVANCED SEARCH</label>
         <div class="advanced-search-status c-help">
           <span [ngClass]="canSearchAnyURI ? 'text-success' : 'text-muted'" i18n-title title="Determines whether you can resolve any distant content, or if this instance only allows doing so for instances it follows.">
-            <span *ngIf="canSearchAnyURI" class="mr-1" i18n>any instance</span>
-            <span *ngIf="!canSearchAnyURI" class="mr-1" i18n>only followed instances</span>
-            <i [ngClass]="canSearchAnyURI ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i>
+            <span *ngIf="canSearchAnyURI()" class="mr-1" i18n>any instance</span>
+            <span *ngIf="!canSearchAnyURI()" class="mr-1" i18n>only followed instances</span>
+            <i [ngClass]="canSearchAnyURI() ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i>
           </span>
         </div>
       </div>
index 0a30ebd552311561d18f38901615e46ba733e625..4b56fd93a75145a90abade6e6a3b904ee9bc1093 100644 (file)
@@ -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;
       }
     }
index 2bf1072f443b1ce9752e80b0e5292e818a5fd56e..6c8b8efee30f549898792919574178f988662d9f 100644 (file)
@@ -1,23 +1,24 @@
-import { Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild } from '@angular/core'
+import { of } from 'rxjs'
+import { first, tap, delay } from 'rxjs/operators'
+import { ListKeyManager } from '@angular/cdk/a11y'
+import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren, AfterViewChecked } from '@angular/core'
 import { ActivatedRoute, Params, Router } from '@angular/router'
 import { AuthService, ServerService } from '@app/core'
-import { first, tap } from 'rxjs/operators'
-import { ListKeyManager } from '@angular/cdk/a11y'
-import { Result, SuggestionComponent } from './suggestion.component'
-import { of } from 'rxjs'
 import { ServerConfig } from '@shared/models'
+import { SearchTargetType } from '@shared/models/search/search-target-query.model'
+import { SuggestionComponent, SuggestionPayload, SuggestionPayloadType } from './suggestion.component'
 
 @Component({
   selector: 'my-search-typeahead',
   templateUrl: './search-typeahead.component.html',
   styleUrls: [ './search-typeahead.component.scss' ]
 })
-export class SearchTypeaheadComponent implements OnInit, OnDestroy {
-  @ViewChild('searchVideo', { static: true }) searchInput: ElementRef<HTMLInputElement>
+export class SearchTypeaheadComponent implements OnInit, AfterViewInit, AfterViewChecked, OnDestroy {
+  @ViewChildren(SuggestionComponent) suggestionItems: QueryList<SuggestionComponent>
 
   hasChannel = false
   inChannel = false
-  newSearch = true
+  areSuggestionsOpened = true
 
   search = ''
   serverConfig: ServerConfig
@@ -25,7 +26,11 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
   inThisChannelText: string
 
   keyboardEventsManager: ListKeyManager<SuggestionComponent>
-  results: Result[] = []
+  results: SuggestionPayload[] = []
+
+  activeSearch: SuggestionPayloadType
+
+  private scheduleKeyboardEventsInit = false
 
   constructor (
     private authService: AuthService,
@@ -38,109 +43,138 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
     this.route.queryParams
       .pipe(first(params => this.isOnSearch() && params.search !== undefined && params.search !== null))
       .subscribe(params => this.search = params.search)
+  }
+
+  ngAfterViewInit () {
     this.serverService.getConfig()
-      .subscribe(config => this.serverConfig = config)
+      .subscribe(config => {
+        this.serverConfig = config
+
+        this.computeTypeahead()
+
+        this.serverService.configReloaded
+          .subscribe(config => {
+            this.serverConfig = config
+            this.computeTypeahead()
+          })
+      })
   }
 
-  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
     }
@@ -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'
+  }
 }
index d7ae3450a9ece36c06b78c9880381eed27af122c..ab4b4b678e957f217cf0a40bb52af37f3469e4c9 100644 (file)
@@ -1,22 +1,17 @@
 <a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active">
   <div class="flex-shrink-0 mr-2 text-center">
-    <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon>
-    <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon>
+    <my-global-icon iconName="search"></my-global-icon>
   </div>
 
   <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28">
 
-  <div class="flex-auto overflow-hidden text-left no-wrap css-truncate css-truncate-target" [attr.aria-label]="result.text" [innerHTML]="result.text | highlight : highlight"></div>
+  <div
+    class="flex-auto overflow-hidden text-left no-wrap css-truncate css-truncate-target"
+    [attr.aria-label]="result.text" [innerHTML]="result.text | highlight : highlight"
+  ></div>
 
-  <div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6">
-    <span *ngIf="result.type === 'search-channel'" i18n>In this channel</span>
+  <div class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6">
     <span *ngIf="result.type === 'search-instance'" i18n>In this instance</span>
-    <span *ngIf="result.type === 'search-global'" i18n>In the vidiverse</span>
-    <span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span>
+    <span *ngIf="result.type === 'search-index'" i18n>In the vidiverse</span>
   </div>
-
-  <div *ngIf="result.type === 'channel'" aria-hidden="true" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6 d-on-nav-focus" i18n>
-    Jump to channel
-    <span class="d-inline-block ml-1 v-align-middle">↵</span>
-  </div>
-</a>
\ No newline at end of file
+</a>
index 69641b511b9612f55c40fe3155bb980f301b2808..250a5411e49f9b785d4399ea4afe32278d27a6da 100644 (file)
@@ -1,24 +1,24 @@
-import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy } from '@angular/core'
+import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy, OnChanges } from '@angular/core'
 import { RouterLink } from '@angular/router'
 import { 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 (file)
index 8d017d7..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<ul role="listbox" class="p-0 m-0">
-  <li *ngFor="let result of results; let i = index" class="d-flex flex-justify-start flex-items-center p-0 f5"
-      role="option" aria-selected="true" (mouseenter)="hoverItem(i)">
-    <my-suggestion [result]="result" [highlight]="highlight"></my-suggestion>
-  </li>
-</ul>
\ 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 (file)
index ee3ef73..0000000
+++ /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<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 })
-  }
-}
index 50f00bc2713183449443d497abe421982751217b..643cc9a2912a06b5369570538d1a18e608600545 100644 (file)
@@ -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 (file)
index 0000000..8be089c
--- /dev/null
@@ -0,0 +1,45 @@
+import { map } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
+import { SearchService } from './search.service'
+import { RedirectService } from '@app/core'
+
+@Injectable()
+export class ChannelLazyLoadResolver implements Resolve<any> {
+  constructor (
+    private router: Router,
+    private searchService: SearchService,
+    private redirectService: RedirectService
+  ) { }
+
+  resolve (route: ActivatedRouteSnapshot) {
+    const url = route.params.url
+    const externalRedirect = route.params.externalRedirect
+    const fromPath = route.params.fromPath
+
+    if (!url) {
+      console.error('Could not find url param.', { params: route.params })
+      return this.router.navigateByUrl('/404')
+    }
+
+    if (externalRedirect === 'true') {
+      window.open(url)
+      this.router.navigateByUrl(fromPath)
+      return
+    }
+
+    return this.searchService.searchVideoChannels({ search: url })
+      .pipe(
+        map(result => {
+          if (result.data.length !== 1) {
+            console.error('Cannot find result for this URL')
+            return this.router.navigateByUrl('/404')
+          }
+
+          const channel = result.data[0]
+
+          return this.router.navigateByUrl('/video-channels/' + channel.nameWithHost)
+        })
+      )
+  }
+}
index 54fc7338fd37ac72e757729f1dfd1e8c8d6feb1c..e20aef8fbad8f942306a60dbdc85ba26165cb677 100644 (file)
         </div>
       </div>
 
+      <div class="form-group">
+        <div class="radio-label label-container">
+          <label i18n>Display sensitive content</label>
+          <button i18n class="reset-button reset-button-small" (click)="resetField('nsfw')" *ngIf="advancedSearch.nsfw !== undefined">
+            Reset
+          </button>
+        </div>
+
+        <div class="peertube-radio-container">
+          <input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch.nsfw">
+          <label i18n for="sensitiveContentYes" class="radio">Yes</label>
+        </div>
+
+        <div class="peertube-radio-container">
+          <input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch.nsfw">
+          <label i18n for="sensitiveContentNo" class="radio">No</label>
+        </div>
+      </div>
+
       <div class="form-group">
         <div class="radio-label label-container">
           <label i18n>Published date</label>
@@ -39,7 +58,7 @@
         </div>
 
         <div class="row">
-          <div class="col-sm-6">
+          <div class="pl-0 col-sm-6">
             <input
               (change)="inputUpdated()"
               (keydown.enter)="$event.preventDefault()"
@@ -49,7 +68,7 @@
               class="form-control"
             >
           </div>
-          <div class="col-sm-6">
+          <div class="pr-0 col-sm-6">
             <input
               (change)="inputUpdated()"
               (keydown.enter)="$event.preventDefault()"
@@ -62,6 +81,9 @@
         </div>
       </div>
 
+    </div>
+
+    <div class="col-lg-4 col-md-6 col-xs-12">
       <div class="form-group">
         <div class="radio-label label-container">
           <label i18n>Duration</label>
         </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>
 
index 344a260dfdd1bae8ecc193f2cd1c0689e1eea134..af76260a7b22b31799f117f94bfbbcc7ec07a7be 100644 (file)
@@ -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()
index 0ac9e6b57c68c25703195cfe9fd6e69b878e7017..9da900e9a7936a16767ee9201feb0be810a8f8e0 100644 (file)
@@ -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
+    }
   }
 ]
 
index a4a1d41b3d89e429231e708c99f83e2206009ac1..3cafc676d9577bc3d0bf683f9a135e7a555d6ac9 100644 (file)
@@ -2,7 +2,11 @@
   <div class="results-header">
     <div class="first-line">
       <div class="results-counter" *ngIf="pagination.totalItems">
-        <span i18n>{{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}}</span>
+        <span i18n>{{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}} </span>
+
+        <span i18n *ngIf="advancedSearch.searchTarget === 'local'">on this instance</span>
+        <span i18n *ngIf="advancedSearch.searchTarget === 'search-index'">on the vidiverse</span>
+
         <span *ngIf="currentSearch" i18n>
           for <span class="search-value">{{ currentSearch }}</span>
         </span>
 
   <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>
index 075994dd3e5d5239f76c70544b84c6b209b87783..d3c0761d723a6eb79f33e70f716eaabd1f20c60a 100644 (file)
@@ -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 { 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',
@@ -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
-
-          // 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 () {
@@ -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(
index 3b0fd6ee2a603a1662ebbbee5e43d83e2331c448..df5459802433163d763a52a73a131b66e60c645a 100644 (file)
@@ -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 { }
index 3cad5aaa795f538139e1c7f897785d85bd8c227c..fdb12ea2c51fcda6a1859041f6d527f4995446bf 100644 (file)
@@ -1,17 +1,18 @@
+import { Observable } from 'rxjs'
 import { catchError, map, switchMap } from 'rxjs/operators'
 import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
-import { Observable } from 'rxjs'
-import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
-import { VideoService } from '@app/shared/video/video.service'
-import { RestExtractor, RestService } from '@app/shared'
-import { environment } from '../../environments/environment'
-import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared'
-import { Video } from '@app/shared/video/video.model'
 import { AdvancedSearch } from '@app/search/advanced-search.model'
+import { RestExtractor, RestPagination, RestService } from '@app/shared'
+import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
+import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
 import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
 import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
+import { Video } from '@app/shared/video/video.model'
+import { VideoService } from '@app/shared/video/video.service'
+import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared'
+import { environment } from '../../environments/environment'
+import { SearchTargetType } from '@shared/models/search/search-target-query.model'
 
 @Injectable()
 export class SearchService {
@@ -30,21 +31,27 @@ export class SearchService {
 
   searchVideos (parameters: {
     search: string,
-    componentPagination: ComponentPaginationLight,
-    advancedSearch: AdvancedSearch
+    componentPagination?: ComponentPaginationLight,
+    advancedSearch?: AdvancedSearch
   }): Observable<ResultList<Video>> {
     const { search, componentPagination, advancedSearch } = parameters
 
     const url = SearchService.BASE_SEARCH_URL + 'videos'
-    const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+    let pagination: RestPagination
+
+    if (componentPagination) {
+      pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+    }
 
     let params = new HttpParams()
     params = this.restService.addRestGetParams(params, pagination)
 
     if (search) params = params.append('search', search)
 
-    const advancedSearchObject = advancedSearch.toAPIObject()
-    params = this.restService.addObjectParams(params, advancedSearchObject)
+    if (advancedSearch) {
+      const advancedSearchObject = advancedSearch.toAPIObject()
+      params = this.restService.addObjectParams(params, advancedSearchObject)
+    }
 
     return this.authHttp
                .get<ResultList<VideoServerModel>>(url, { params })
@@ -56,17 +63,26 @@ export class SearchService {
 
   searchVideoChannels (parameters: {
     search: string,
-    componentPagination: ComponentPaginationLight
+    searchTarget?: SearchTargetType,
+    componentPagination?: ComponentPaginationLight
   }): Observable<ResultList<VideoChannel>> {
-    const { search, componentPagination } = parameters
+    const { search, componentPagination, searchTarget } = parameters
 
     const url = SearchService.BASE_SEARCH_URL + 'video-channels'
-    const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+
+    let pagination: RestPagination
+    if (componentPagination) {
+      pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+    }
 
     let params = new HttpParams()
     params = this.restService.addRestGetParams(params, pagination)
     params = params.append('search', search)
 
+    if (searchTarget) {
+      params = params.append('searchTarget', searchTarget as string)
+    }
+
     return this.authHttp
                .get<ResultList<VideoChannelServerModel>>(url, { params })
                .pipe(
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 (file)
index 0000000..8d846d3
--- /dev/null
@@ -0,0 +1,43 @@
+import { map } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
+import { SearchService } from './search.service'
+
+@Injectable()
+export class VideoLazyLoadResolver implements Resolve<any> {
+  constructor (
+    private router: Router,
+    private searchService: SearchService
+  ) { }
+
+  resolve (route: ActivatedRouteSnapshot) {
+    const url = route.params.url
+    const externalRedirect = route.params.externalRedirect
+    const fromPath = route.params.fromPath
+
+    if (!url) {
+      console.error('Could not find url param.', { params: route.params })
+      return this.router.navigateByUrl('/404')
+    }
+
+    if (externalRedirect === 'true') {
+      window.open(url)
+      this.router.navigateByUrl(fromPath)
+      return
+    }
+
+    return this.searchService.searchVideos({ search: url })
+      .pipe(
+        map(result => {
+          if (result.data.length !== 1) {
+            console.error('Cannot find result for this URL')
+            return this.router.navigateByUrl('/404')
+          }
+
+          const video = result.data[0]
+
+          return this.router.navigateByUrl('/videos/watch/' + video.uuid)
+        })
+      )
+  }
+}
index 0e5060f67838b64d7ad8c7af24d1a901913f881e..a78303a2f9f1aacdddb20a72bba24644831e6e60 100644 (file)
@@ -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) {
+      const absoluteAPIUrl = getAbsoluteAPIUrl()
 
-    if (actor && actor.avatar) return absoluteAPIUrl + actor.avatar.path
+      return absoluteAPIUrl + actor.avatar.path
+    }
 
     return this.GET_DEFAULT_AVATAR_URL()
   }
index fb604228024ef9eb2e8b813cf36f5f5d1dc46d21..50ee5c1bd372d9894baae059900ab334ec609f2d 100644 (file)
@@ -11,19 +11,17 @@ export class HighlightPipe implements PipeTransform {
   /* use this for global search */
   static MULTI_MATCH = 'Multi-Match'
 
-  // tslint:disable-next-line:no-empty
-  constructor () {}
-
   transform (
-      contentString: string = null,
-      stringToHighlight: string = null,
-      option = 'Single-And-StartsWith-Match',
-      caseSensitive = false,
-      highlightStyleName = 'search-highlight'
+    contentString: string = null,
+    stringToHighlight: string = null,
+    option = 'Single-And-StartsWith-Match',
+    caseSensitive = false,
+    highlightStyleName = 'search-highlight'
   ): SafeHtml {
     if (stringToHighlight && contentString && option) {
       let regex: any = ''
       const caseFlag: string = !caseSensitive ? 'i' : ''
+
       switch (option) {
         case 'Single-Match': {
           regex = new RegExp(stringToHighlight, caseFlag)
@@ -42,10 +40,12 @@ export class HighlightPipe implements PipeTransform {
           regex = new RegExp(stringToHighlight, 'gi')
         }
       }
+
       const replaced = contentString.replace(
-          regex,
-          (match) => `<span class="${highlightStyleName}">${match}</span>`
+        regex,
+        (match) => `<span class="${highlightStyleName}">${match}</span>`
       )
+
       return replaced
     } else {
       return contentString
index abcbca817ac52522baaddb0e63649826810c644c..fdb19e06a902bdc9867a63f162b059dcd3ed4c36 100644 (file)
@@ -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')
+      }
+    }
   }
 }
index ba29cb46271dfeb4c31d5f5c02fa08215e1595bc..7b8368d872da81ad5c594f68a124471444b1e9b1 100644 (file)
@@ -1,4 +1,4 @@
-import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '../../../../../shared'
+import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, Avatar } from '../../../../../shared'
 import { Actor } from '@app/shared/actor/actor.model'
 
 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)
   }
 }
index d354a2930c38326c40ea3707df5a801be82d79b6..3e23cf18c0ffc774cefdab2907533af8a0d6c235 100644 (file)
@@ -1,6 +1,6 @@
 <div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow, 'fit-width': fitWidth }" (mouseenter)="loadActions()">
   <my-video-thumbnail
-    [video]="video" [nsfw]="isVideoBlur"
+    [video]="video" [nsfw]="isVideoBlur" [routerLink]="videoLink"
     [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
   >
     <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
@@ -12,7 +12,7 @@
       <a
         tabindex="-1"
         class="video-miniature-name"
-        [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
+        [routerLink]="videoLink" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
       >{{ video.name }}</a>
 
       <div class="d-inline-flex">
index a1d4f0e81b1485e368a0812ff587c2dafea4778c..aa1726ca7b6097a13cdc1ef777be12f6b4bf9449 100644 (file)
@@ -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 () {
index 546518ccac77b7b8fef0cf6d4aacd7f1436a40a6..97759f9c1b3f7679d8bafb14c92fa723670bebb3 100644 (file)
@@ -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
index f6e944298a5eb4a2b810b78727fe39ad9463844b..050019670b4918a5b12f0f6fcfcb90964bd20dea 100644 (file)
@@ -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
index e215288214ac16e7ebdcfa4e19c6a44283ad0bd9..6f658e61a8d2c22d09def68d626e016152b0119a 100644 (file)
@@ -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
index 74979f3a76b5e96239441b91b622d29e1258f209..da34ccd03e8693a51cdb787827184c22ba4e9f59 100644 (file)
@@ -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
index 41e5027b9b8c4bdc69d0cff3b4dc864d19251c03..1d48b4b26711623b01af26ac8cdc5457dd68e9b9 100644 (file)
@@ -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
+      }
+    },
   }
 }
 
index 35d94d747e4a2c3164cb2e558b50f0d2077fe6e7..e08e1d79fa15baea1ec64193c49e30e9e6d31cdc 100644 (file)
@@ -1,7 +1,19 @@
 import * as express from 'express'
+import { sanitizeUrl } from '@server/helpers/core-utils'
+import { doRequest } from '@server/helpers/requests'
+import { CONFIG } from '@server/initializers/config'
+import { getOrCreateVideoAndAccountAndChannel } from '@server/lib/activitypub/videos'
+import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
+import { getServerActor } from '@server/models/application/application'
+import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
+import { ResultList, Video, VideoChannel } from '@shared/models'
+import { SearchTargetQuery } from '@shared/models/search/search-target-query.model'
+import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search'
 import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
+import { logger } from '../../helpers/logger'
 import { getFormattedObjects } from '../../helpers/utils'
-import { VideoModel } from '../../models/video/video'
+import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger'
+import { getOrCreateActorAndServerAndModel } from '../../lib/activitypub/actor'
 import {
   asyncMiddleware,
   commonVideosFiltersValidator,
@@ -14,14 +26,9 @@ import {
   videosSearchSortValidator,
   videosSearchValidator
 } from '../../middlewares'
-import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search'
-import { getOrCreateActorAndServerAndModel } from '../../lib/activitypub/actor'
-import { logger } from '../../helpers/logger'
+import { VideoModel } from '../../models/video/video'
 import { VideoChannelModel } from '../../models/video/video-channel'
-import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger'
 import { MChannelAccountDefault, MVideoAccountLightBlacklistAllFiles } from '../../typings/models'
-import { getServerActor } from '@server/models/application/application'
-import { getOrCreateVideoAndAccountAndChannel } from '@server/lib/activitypub/videos'
 
 const searchRouter = express.Router()
 
@@ -68,9 +75,34 @@ function searchVideoChannels (req: express.Request, res: express.Response) {
 
   // @username -> username to search in DB
   if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '')
+
+  if (isSearchIndexEnabled(query)) {
+    return searchVideoChannelsIndex(query, res)
+  }
+
   return searchVideoChannelsDB(query, res)
 }
 
+async function searchVideoChannelsIndex (query: VideoChannelsSearchQuery, res: express.Response) {
+  logger.debug('Doing channels search on search index.')
+
+  const result = await buildMutedForSearchIndex(res)
+
+  const body = Object.assign(query, result)
+
+  const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels'
+
+  try {
+    const searchIndexResult = await doRequest<ResultList<VideoChannel>>({ uri: url, body, json: true })
+
+    return res.json(searchIndexResult.body)
+  } catch (err) {
+    logger.warn('Cannot use search index to make video channels search.', { err })
+
+    return res.sendStatus(500)
+  }
+}
+
 async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) {
   const serverActor = await getServerActor()
 
@@ -120,13 +152,38 @@ async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean
 function searchVideos (req: express.Request, res: express.Response) {
   const query: VideosSearchQuery = req.query
   const search = query.search
+
   if (search && (search.startsWith('http://') || search.startsWith('https://'))) {
     return searchVideoURI(search, res)
   }
 
+  if (isSearchIndexEnabled(query)) {
+    return searchVideosIndex(query, res)
+  }
+
   return searchVideosDB(query, res)
 }
 
+async function searchVideosIndex (query: VideosSearchQuery, res: express.Response) {
+  logger.debug('Doing videos search on search index.')
+
+  const result = await buildMutedForSearchIndex(res)
+
+  const body = Object.assign(query, result)
+
+  const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos'
+
+  try {
+    const searchIndexResult = await doRequest<ResultList<Video>>({ uri: url, body, json: true })
+
+    return res.json(searchIndexResult.body)
+  } catch (err) {
+    logger.warn('Cannot use search index to make video search.', { err })
+
+    return res.sendStatus(500)
+  }
+}
+
 async function searchVideosDB (query: VideosSearchQuery, res: express.Response) {
   const options = Object.assign(query, {
     includeLocalVideos: true,
@@ -168,3 +225,35 @@ async function searchVideoURI (url: string, res: express.Response) {
     data: video ? [ video.toFormattedJSON() ] : []
   })
 }
+
+function isSearchIndexEnabled (query: SearchTargetQuery) {
+  if (query.searchTarget === 'search-index') return true
+
+  const searchIndexConfig = CONFIG.SEARCH.SEARCH_INDEX
+
+  if (searchIndexConfig.ENABLED !== true) return false
+
+  if (searchIndexConfig.DISABLE_LOCAL_SEARCH) return true
+  if (searchIndexConfig.IS_DEFAULT_SEARCH && !query.searchTarget) return true
+
+  return false
+}
+
+async function buildMutedForSearchIndex (res: express.Response) {
+  const serverActor = await getServerActor()
+  const accountIds = [ serverActor.Account.id ]
+
+  if (res.locals.oauth) {
+    accountIds.push(res.locals.oauth.token.User.Account.id)
+  }
+
+  const [ blockedHosts, blockedAccounts ] = await Promise.all([
+    ServerBlocklistModel.listHostsBlockedBy(accountIds),
+    AccountBlocklistModel.listHandlesBlockedBy(accountIds)
+  ])
+
+  return {
+    blockedHosts,
+    blockedAccounts
+  }
+}
index b5b8541379d4abcad1ed4c783801fb2dc3341f25..b49ab6bca790d1bbab034c95ee2a898838b9d14e 100644 (file)
@@ -128,6 +128,13 @@ function checkConfig () {
     }
   }
 
+  // Search index
+  if (CONFIG.SEARCH.SEARCH_INDEX.ENABLED === true) {
+    if (CONFIG.SEARCH.REMOTE_URI.USERS === false) {
+      return 'You cannot enable search index without enabling remote URI search for users.'
+    }
+  }
+
   return null
 }
 
index bd8f02bc02eb4f9b906a04fa33d576c45f580ff1..e0819c4aa1c9c44de65ab6db180633de13db723e 100644 (file)
@@ -35,7 +35,9 @@ function checkMissedConfig () {
     'rates_limit.login.window', 'rates_limit.login.max', 'rates_limit.ask_send_email.window', 'rates_limit.ask_send_email.max',
     '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
index 44fd9045b48a82117307ab4673f1a2b7855c3114..5b402dd7432651216aedd92737325c10e834f641 100644 (file)
@@ -104,12 +104,6 @@ const CONFIG = {
     },
     ANONYMIZE_IP: config.get<boolean>('log.anonymizeIP')
   },
-  SEARCH: {
-    REMOTE_URI: {
-      USERS: config.get<boolean>('search.remote_uri.users'),
-      ANONYMOUS: config.get<boolean>('search.remote_uri.anonymous')
-    }
-  },
   TRENDING: {
     VIDEOS: {
       INTERVAL_DAYS: config.get<number>('trending.videos.interval_days')
@@ -297,6 +291,18 @@ const CONFIG = {
     get MESSAGE () { return config.get<string>('broadcast_message.message') },
     get LEVEL () { return config.get<BroadcastMessageLevel>('broadcast_message.level') },
     get DISMISSABLE () { return config.get<boolean>('broadcast_message.dismissable') }
+  },
+  SEARCH: {
+    REMOTE_URI: {
+      USERS: config.get<boolean>('search.remote_uri.users'),
+      ANONYMOUS: config.get<boolean>('search.remote_uri.anonymous')
+    },
+    SEARCH_INDEX: {
+      get ENABLED () { return config.get<boolean>('search.search_index.enabled') },
+      get URL () { return config.get<string>('search.search_index.url') },
+      get DISABLE_LOCAL_SEARCH () { return config.get<boolean>('search.search_index.disable_local_search') },
+      get IS_DEFAULT_SEARCH () { return config.get<boolean>('search.search_index.is_default_search') }
+    }
   }
 }
 
index d201df3d892199af634f523ede41a8537cc04fa0..314f094b3acf322028e611bf5e7b2cc28e5ea41c 100644 (file)
@@ -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,
index 7d16bd3908fd42f1cc78567f486154e8aefaa8d0..6d20e0e654437c5ffa7f186978ea332b0fdcee89 100644 (file)
@@ -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)
+
+    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: {
index 170f0c7e208729a64c69dba8ea100b0af3e954f1..7bcb6ed4c9a291c278b4555ed85be66d6971e16a 100644 (file)
@@ -11,6 +11,7 @@ import { PluginModel } from '../../models/server/plugin'
 import { PluginManager } from './plugin-manager'
 import { logger } from '../../helpers/logger'
 import { PEERTUBE_VERSION } from '../../initializers/constants'
+import { sanitizeUrl } from '@server/helpers/core-utils'
 
 async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) {
   const { start = 0, count = 20, search, sort = 'npmName', pluginType } = options
@@ -55,7 +56,7 @@ async function getLatestPluginsVersion (npmNames: string[]): Promise<PeertubePlu
     currentPeerTubeEngine: PEERTUBE_VERSION
   }
 
-  const uri = CONFIG.PLUGINS.INDEX.URL + '/api/v1/plugins/latest-version'
+  const uri = sanitizeUrl(CONFIG.PLUGINS.INDEX.URL) + '/api/v1/plugins/latest-version'
 
   const { body } = await doRequest<any>({ uri, body: bodyRequest, json: true, method: 'POST' })
 
index 6905ac762defeb34348aa595e548463a159d88b7..d3669f6bef91938a7f3696f34b638dab31c48834 100644 (file)
@@ -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 })
index d8a7ce4b4a5a6f26e62fbd91322e2a6d512f4b2c..2c6b756d20d3c613a1707ec7e529a06ff1861f1a 100644 (file)
@@ -5,6 +5,8 @@ import { AccountBlock } from '../../../shared/models/blocklist'
 import { Op } from 'sequelize'
 import * as Bluebird from 'bluebird'
 import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/typings/models'
+import { ActorModel } from '../activitypub/actor'
+import { ServerModel } from '../server/server'
 
 enum ScopeNames {
   WITH_ACCOUNTS = 'WITH_ACCOUNTS'
@@ -149,6 +151,42 @@ export class AccountBlocklistModel extends Model<AccountBlocklistModel> {
       })
   }
 
+  static listHandlesBlockedBy (accountIds: number[]): Bluebird<string[]> {
+    const query = {
+      attributes: [],
+      where: {
+        accountId: {
+          [Op.in]: accountIds
+        }
+      },
+      include: [
+        {
+          attributes: [ 'id' ],
+          model: AccountModel.unscoped(),
+          required: true,
+          as: 'BlockedAccount',
+          include: [
+            {
+              attributes: [ 'preferredUsername' ],
+              model: ActorModel.unscoped(),
+              required: true,
+              include: [
+                {
+                  attributes: [ 'host' ],
+                  model: ServerModel.unscoped(),
+                  required: true
+                }
+              ]
+            }
+          ]
+        }
+      ]
+    }
+
+    return AccountBlocklistModel.findAll(query)
+      .then(entries => entries.map(e => `${e.BlockedAccount.Actor.preferredUsername}@${e.BlockedAccount.Actor.Server.host}`))
+  }
+
   toFormattedJSON (this: MAccountBlocklistFormattable): AccountBlock {
     return {
       byAccount: this.ByAccount.toFormattedJSON(),
index 892024c04a4e074d25739bd6adf8ee80d6c9b7ed..ad8e3d1e88e30694cac7185fba19fc525a20cd2c 100644 (file)
@@ -120,6 +120,27 @@ export class ServerBlocklistModel extends Model<ServerBlocklistModel> {
     return ServerBlocklistModel.findOne(query)
   }
 
+  static listHostsBlockedBy (accountIds: number[]): Bluebird<string[]> {
+    const query = {
+      attributes: [ ],
+      where: {
+        accountId: {
+          [Op.in]: accountIds
+        }
+      },
+      include: [
+        {
+          attributes: [ 'host' ],
+          model: ServerModel.unscoped(),
+          required: true
+        }
+      ]
+    }
+
+    return ServerBlocklistModel.findAll(query)
+      .then(entries => entries.map(e => e.BlockedServer.host))
+  }
+
   static listForApi (parameters: {
     start: number
     count: number
index 7c96fa762b7c784535a840a8ec15cf46b8ef4bc8..3f2708f94a1a2fa4460e58b6a829dda953120ac2 100644 (file)
@@ -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
+      }
     }
   }
 
index d18a930823e85daf8ad0909b857c5780a8a71dbb..59723358865182a10ea3cbc528ee3289f50d26ce 100644 (file)
@@ -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)
index 98cd435f692481838f3b6378349e078176547d7c..eb06a151606fcfd048e9384abae0b9629a2c5373 100644 (file)
@@ -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
+      }
     }
   }
 
index 301d00929ef4fb4d609126ca6ab197c3e45f2ab4..f7fa16f49ef089d6f840e87b392fa3aaa2f09aa0 100644 (file)
@@ -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 (file)
index 0000000..3bb2e0d
--- /dev/null
@@ -0,0 +1,5 @@
+export type SearchTargetType = 'local' | 'search-index'
+
+export interface SearchTargetQuery {
+  searchTarget?: SearchTargetType
+}
index de2741e14de1b553f5fc3ddf2dd83872dc7a1a2d..c96aa8c1d55416e759607e8a1ee1d399247bc8cb 100644 (file)
@@ -1,4 +1,6 @@
-export interface VideoChannelsSearchQuery {
+import { SearchTargetQuery } from "./search-target-query.model"
+
+export interface VideoChannelsSearchQuery extends SearchTargetQuery {
   search: string
 
   start?: number
index 838063095dfabf7b55134fcff4ec12ca3fa99fce..bd6bb5bc125f67f158db30e2ad6bef35aaee693f 100644 (file)
@@ -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
index 851bf1854d31a33748dc9acd68609b7fcd2807cd..338a5934108f60fce270e2b15b43a0b8e7d01d63 100644 (file)
@@ -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
+    }
+  }
 }
index 9c903b7ee324b518b92dda315b93083e616eb300..a8e5dfbff64bfd66384b6eb19983835b44d76a2e 100644 (file)
@@ -50,6 +50,13 @@ export interface ServerConfig {
       users: boolean
       anonymous: boolean
     }
+
+    searchIndex: {
+      enabled: boolean
+      url: string
+      disableLocalSearch: boolean
+      isDefaultSearch: boolean
+    }
   }
 
   plugin: {
index a69152759aa0e77d43de607bf0a81ed02b2f1d48..0f8822125ede8a4487261325004b694d8fe20fe4 100644 (file)
@@ -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