Begin advanced search
authorChocobozzz <me@florianbigard.com>
Thu, 19 Jul 2018 14:17:54 +0000 (16:17 +0200)
committerChocobozzz <me@florianbigard.com>
Tue, 24 Jul 2018 12:04:05 +0000 (14:04 +0200)
38 files changed:
.travis.yml
client/src/app/app.module.ts
client/src/app/header/header.component.ts
client/src/app/search/index.ts [new file with mode: 0644]
client/src/app/search/search-routing.module.ts [new file with mode: 0644]
client/src/app/search/search.component.html [new file with mode: 0644]
client/src/app/search/search.component.scss [new file with mode: 0644]
client/src/app/search/search.component.ts [new file with mode: 0644]
client/src/app/search/search.module.ts [new file with mode: 0644]
client/src/app/search/search.service.ts [new file with mode: 0644]
client/src/app/shared/shared.module.ts
client/src/app/shared/video/video-miniature.component.html
client/src/app/shared/video/video-thumbnail.component.html
client/src/app/shared/video/video.service.ts
client/src/app/videos/video-list/index.ts
client/src/app/videos/video-list/video-search.component.ts [deleted file]
client/src/app/videos/videos-routing.module.ts
client/src/app/videos/videos.module.ts
scripts/clean/server/test.sh
server.ts
server/controllers/api/index.ts
server/controllers/api/search.ts [new file with mode: 0644]
server/controllers/api/videos/index.ts
server/controllers/client.ts
server/helpers/database-utils.ts
server/initializers/constants.ts
server/initializers/database.ts
server/middlewares/sort.ts
server/middlewares/validators/index.ts
server/middlewares/validators/search.ts [new file with mode: 0644]
server/middlewares/validators/sort.ts
server/middlewares/validators/videos.ts
server/models/activitypub/actor.ts
server/models/utils.ts
server/models/video/video.ts
server/tests/utils/videos/videos.ts
support/doc/docker.md
support/doc/production.md

index 51892e5040bb504aee7131e8dd8dbfdc644f3555..04271952999dcef54dc8ee961f938a01c0df5f9d 100644 (file)
@@ -29,12 +29,6 @@ before_script:
   - cp ffmpeg-*-64bit-static/{ffmpeg,ffprobe,ffserver} $HOME/bin
   - export PATH=$HOME/bin:$PATH
   - export NODE_TEST_IMAGE=true
-  - psql -c 'create database peertube_test1;' -U postgres
-  - psql -c 'create database peertube_test2;' -U postgres
-  - psql -c 'create database peertube_test3;' -U postgres
-  - psql -c 'create database peertube_test4;' -U postgres
-  - psql -c 'create database peertube_test5;' -U postgres
-  - psql -c 'create database peertube_test6;' -U postgres
   - psql -c "create user peertube with password 'peertube';" -U postgres
 
 matrix:
index 48886fd4e865a3f0620417f898c0242ffbd62757..9d655c5237155ff2a5e3169ea4ac17d00ec56262 100644 (file)
@@ -18,6 +18,7 @@ import { VideosModule } from './videos'
 import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n'
 import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
 import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
+import { SearchModule } from '@app/search'
 
 export function metaFactory (serverService: ServerService): MetaLoader {
   return new MetaStaticLoader({
@@ -52,6 +53,7 @@ export function metaFactory (serverService: ServerService): MetaLoader {
     LoginModule,
     ResetPasswordModule,
     SignupModule,
+    SearchModule,
     SharedModule,
     VideosModule,
 
index 0e999fbb1e738a455455f2dad301b7045b402637..f73d40947187c630606f28d13ab6185a39471c53 100644 (file)
@@ -24,7 +24,7 @@ export class HeaderComponent implements OnInit {
   }
 
   doSearch () {
-    this.router.navigate([ '/videos', 'search' ], {
+    this.router.navigate([ '/search' ], {
       queryParams: { search: this.searchValue }
     })
   }
diff --git a/client/src/app/search/index.ts b/client/src/app/search/index.ts
new file mode 100644 (file)
index 0000000..40f4e02
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './search-routing.module'
+export * from './search.component'
+export * from './search.module'
diff --git a/client/src/app/search/search-routing.module.ts b/client/src/app/search/search-routing.module.ts
new file mode 100644 (file)
index 0000000..0ac9e6b
--- /dev/null
@@ -0,0 +1,23 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { MetaGuard } from '@ngx-meta/core'
+import { SearchComponent } from '@app/search/search.component'
+
+const searchRoutes: Routes = [
+  {
+    path: 'search',
+    component: SearchComponent,
+    canActivate: [ MetaGuard ],
+    data: {
+      meta: {
+        title: 'Search'
+      }
+    }
+  }
+]
+
+@NgModule({
+  imports: [ RouterModule.forChild(searchRoutes) ],
+  exports: [ RouterModule ]
+})
+export class SearchRoutingModule {}
diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html
new file mode 100644 (file)
index 0000000..b8c4d7d
--- /dev/null
@@ -0,0 +1,19 @@
+<div i18n *ngIf="pagination.totalItems === 0" class="no-result">
+  No results found
+</div>
+
+<div myInfiniteScroller [autoLoading]="true" (nearOfBottom)="onNearOfBottom()" class="search-result">
+  <div i18n *ngIf="pagination.totalItems" class="results-counter">
+    {{ pagination.totalItems | myNumberFormatter }} results for <span class="search-value">{{ currentSearch }}</span>
+  </div>
+
+  <div *ngFor="let video of videos" class="entry video">
+    <my-video-thumbnail [video]="video"></my-video-thumbnail>
+
+    <div class="video-info">
+      <a class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a>
+      <span i18n class="video-info-date-views">{{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span>
+      <a class="video-info-account" [routerLink]="[ '/accounts', video.by ]">{{ video.by }}</a>
+    </div>
+  </div>
+</div>
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss
new file mode 100644 (file)
index 0000000..06e3c95
--- /dev/null
@@ -0,0 +1,93 @@
+@import '_variables';
+@import '_mixins';
+
+.no-result {
+  height: 70vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 16px;
+  font-weight: $font-semibold;
+}
+
+.search-result {
+  margin-left: 40px;
+  margin-top: 40px;
+
+  .results-counter {
+    font-size: 15px;
+    padding-bottom: 20px;
+    margin-bottom: 30px;
+    border-bottom: 1px solid #DADADA;
+
+    .search-value {
+      font-weight: $font-semibold;
+    }
+  }
+
+  .entry {
+    display: flex;
+    min-height: 130px;
+    padding-bottom: 20px;
+    margin-bottom: 20px;
+
+    &.video {
+
+      my-video-thumbnail {
+        margin-right: 10px;
+      }
+
+      .video-info {
+        flex-grow: 1;
+
+        .video-info-name {
+          @include disable-default-a-behaviour;
+
+          color: #000;
+          display: block;
+          width: fit-content;
+          font-size: 18px;
+          font-weight: $font-semibold;
+        }
+
+        .video-info-date-views {
+          font-size: 14px;
+        }
+
+        .video-info-account {
+          @include disable-default-a-behaviour;
+
+          display: block;
+          width: fit-content;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+          font-size: 14px;
+          color: #585858;
+
+          &:hover {
+            color: #303030;
+          }
+        }
+      }
+    }
+  }
+}
+
+@media screen and (max-width: 800px) {
+  .entry {
+    flex-direction: column;
+    height: auto;
+    text-align: center;
+
+    &.video {
+      .video-info-name {
+        margin: auto;
+      }
+
+      my-video-thumbnail {
+        margin-right: 0;
+      }
+    }
+  }
+}
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts
new file mode 100644 (file)
index 0000000..be1cb36
--- /dev/null
@@ -0,0 +1,93 @@
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
+import { RedirectService } from '@app/core'
+import { NotificationsService } from 'angular2-notifications'
+import { Subscription } from 'rxjs'
+import { SearchService } from '@app/search/search.service'
+import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Video } from '../../../../shared'
+import { MetaService } from '@ngx-meta/core'
+
+@Component({
+  selector: 'my-search',
+  styleUrls: [ './search.component.scss' ],
+  templateUrl: './search.component.html'
+})
+export class SearchComponent implements OnInit, OnDestroy {
+  videos: Video[] = []
+  pagination: ComponentPagination = {
+    currentPage: 1,
+    itemsPerPage: 10, // It's per object type (so 10 videos, 10 video channels etc)
+    totalItems: null
+  }
+
+  private subActivatedRoute: Subscription
+  private currentSearch: string
+
+  constructor (
+    private i18n: I18n,
+    private route: ActivatedRoute,
+    private metaService: MetaService,
+    private redirectService: RedirectService,
+    private notificationsService: NotificationsService,
+    private searchService: SearchService
+  ) { }
+
+  ngOnInit () {
+    this.subActivatedRoute = this.route.queryParams.subscribe(
+      queryParams => {
+        const querySearch = queryParams['search']
+
+        if (!querySearch) return this.redirectService.redirectToHomepage()
+        if (querySearch === this.currentSearch) return
+
+        this.currentSearch = querySearch
+        this.updateTitle()
+
+        this.reload()
+      },
+
+      err => this.notificationsService.error('Error', err.text)
+    )
+  }
+
+  ngOnDestroy () {
+    if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
+  }
+
+  search () {
+    return this.searchService.searchVideos(this.currentSearch, this.pagination)
+      .subscribe(
+        ({ videos, totalVideos }) => {
+          this.videos = this.videos.concat(videos)
+          this.pagination.totalItems = totalVideos
+        },
+
+        error => {
+          this.notificationsService.error(this.i18n('Error'), error.message)
+        }
+      )
+  }
+
+  onNearOfBottom () {
+    // Last page
+    if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
+
+    this.pagination.currentPage += 1
+    this.search()
+  }
+
+  private reload () {
+    this.pagination.currentPage = 1
+    this.pagination.totalItems = null
+
+    this.videos = []
+
+    this.search()
+  }
+
+  private updateTitle () {
+    this.metaService.setTitle(this.i18n('Search') + ' ' + this.currentSearch)
+  }
+}
diff --git a/client/src/app/search/search.module.ts b/client/src/app/search/search.module.ts
new file mode 100644 (file)
index 0000000..c6ec74d
--- /dev/null
@@ -0,0 +1,25 @@
+import { NgModule } from '@angular/core'
+import { SharedModule } from '../shared'
+import { SearchComponent } from '@app/search/search.component'
+import { SearchService } from '@app/search/search.service'
+import { SearchRoutingModule } from '@app/search/search-routing.module'
+
+@NgModule({
+  imports: [
+    SearchRoutingModule,
+    SharedModule
+  ],
+
+  declarations: [
+    SearchComponent
+  ],
+
+  exports: [
+    SearchComponent
+  ],
+
+  providers: [
+    SearchService
+  ]
+})
+export class SearchModule { }
diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts
new file mode 100644 (file)
index 0000000..02d5f59
--- /dev/null
@@ -0,0 +1,46 @@
+import { catchError, switchMap } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { Observable } from 'rxjs'
+import { ComponentPagination } 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 } from '../../../../shared'
+import { Video as VideoServerModel } from '@app/shared/video/video.model'
+
+export type SearchResult = {
+  videosResult: { totalVideos: number, videos: Video[] }
+}
+
+@Injectable()
+export class SearchService {
+  static BASE_SEARCH_URL = environment.apiUrl + '/api/v1/search/'
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor,
+    private restService: RestService,
+    private videoService: VideoService
+  ) {}
+
+  searchVideos (
+    search: string,
+    componentPagination: ComponentPagination
+  ): Observable<{ videos: Video[], totalVideos: number }> {
+    const url = SearchService.BASE_SEARCH_URL + 'videos'
+
+    const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination)
+    params = params.append('search', search)
+
+    return this.authHttp
+               .get<ResultList<VideoServerModel>>(url, { params })
+               .pipe(
+                 switchMap(res => this.videoService.extractVideos(res)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+}
index fdfb90600b2f63fb13b48efc620ab25073524c73..99df61cdb4dbadf3c96897cae998781c44feb73f 100644 (file)
@@ -37,9 +37,14 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
 import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
 import {
   CustomConfigValidatorsService,
-  LoginValidatorsService, ReactiveFileComponent,
+  LoginValidatorsService,
+  ReactiveFileComponent,
   ResetPasswordValidatorsService,
-  UserValidatorsService, VideoAbuseValidatorsService, VideoChannelValidatorsService, VideoCommentValidatorsService, VideoValidatorsService
+  UserValidatorsService,
+  VideoAbuseValidatorsService,
+  VideoChannelValidatorsService,
+  VideoCommentValidatorsService,
+  VideoValidatorsService
 } from '@app/shared/forms'
 import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
 import { ScreenService } from '@app/shared/misc/screen.service'
index 20020e2a82d4626b362f78524a5cdd86e22820c5..3010e5ccc2864047c1122d56e3c1f2bd4788ca44 100644 (file)
@@ -3,7 +3,7 @@
 
   <div class="video-miniature-information">
     <a
-      class="video-miniature-name" alt=""
+      class="video-miniature-name"
       [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur() }"
     >
       {{ video.name }}
index 971f352ba3eee20ce27197f3f507002b2e85206a..4909cf3f16499e7bbf12bdaafc2346ab258d9ec0 100644 (file)
@@ -2,7 +2,7 @@
   [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name"
   class="video-thumbnail"
 >
-<img [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
+<img alt="" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
 
 <div class="video-thumbnail-overlay">
   {{ video.durationLabel }}
index b4c1f10f93dd96834e54f41ed4ae9f7878f982b4..f316d31ea4f82fc6d37575b085758db2e4d81853 100644 (file)
@@ -231,27 +231,6 @@ export class VideoService {
     return this.buildBaseFeedUrls(params)
   }
 
-  searchVideos (
-    search: string,
-    videoPagination: ComponentPagination,
-    sort: VideoSortField
-  ): Observable<{ videos: Video[], totalVideos: number }> {
-    const url = VideoService.BASE_VIDEO_URL + 'search'
-
-    const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
-
-    let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination, sort)
-    params = params.append('search', search)
-
-    return this.authHttp
-               .get<ResultList<VideoServerModel>>(url, { params })
-               .pipe(
-                 switchMap(res => this.extractVideos(res)),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
   removeVideo (id: number) {
     return this.authHttp
                .delete(VideoService.BASE_VIDEO_URL + id)
@@ -289,21 +268,7 @@ export class VideoService {
                .pipe(catchError(err => this.restExtractor.handleError(err)))
   }
 
-  private setVideoRate (id: number, rateType: VideoRateType) {
-    const url = VideoService.BASE_VIDEO_URL + id + '/rate'
-    const body: UserVideoRateUpdate = {
-      rating: rateType
-    }
-
-    return this.authHttp
-               .put(url, body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  private extractVideos (result: ResultList<VideoServerModel>) {
+  extractVideos (result: ResultList<VideoServerModel>) {
     return this.serverService.localeObservable
                .pipe(
                  map(translations => {
@@ -319,4 +284,18 @@ export class VideoService {
                  })
                )
   }
+
+  private setVideoRate (id: number, rateType: VideoRateType) {
+    const url = VideoService.BASE_VIDEO_URL + id + '/rate'
+    const body: UserVideoRateUpdate = {
+      rating: rateType
+    }
+
+    return this.authHttp
+               .put(url, body)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
 }
index 5e7c7886ceb619989bac4dd8fb785d106b6e64f9..5f7c8bd48e334da2b1e6b8c1fecb7a534bf7c36f 100644 (file)
@@ -1,3 +1,3 @@
+export * from './video-local.component'
 export * from './video-recently-added.component'
 export * from './video-trending.component'
-export * from './video-search.component'
diff --git a/client/src/app/videos/video-list/video-search.component.ts b/client/src/app/videos/video-list/video-search.component.ts
deleted file mode 100644 (file)
index 33ed3f0..0000000
+++ /dev/null
@@ -1,77 +0,0 @@
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { Location } from '@angular/common'
-import { RedirectService } from '@app/core'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { NotificationsService } from 'angular2-notifications'
-import { Subscription } from 'rxjs'
-import { AuthService } from '../../core/auth'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoService } from '../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
-
-@Component({
-  selector: 'my-videos-search',
-  styleUrls: [ '../../shared/video/abstract-video-list.scss' ],
-  templateUrl: '../../shared/video/abstract-video-list.html'
-})
-export class VideoSearchComponent extends AbstractVideoList implements OnInit, OnDestroy {
-  titlePage: string
-  currentRoute = '/videos/search'
-  loadOnInit = false
-
-  protected otherRouteParams = {
-    search: ''
-  }
-  private subActivatedRoute: Subscription
-
-  constructor (
-    protected router: Router,
-    protected route: ActivatedRoute,
-    protected notificationsService: NotificationsService,
-    protected authService: AuthService,
-    protected location: Location,
-    protected i18n: I18n,
-    protected screenService: ScreenService,
-    private videoService: VideoService,
-    private redirectService: RedirectService
-  ) {
-    super()
-
-    this.titlePage = i18n('Search')
-  }
-
-  ngOnInit () {
-    super.ngOnInit()
-
-    this.subActivatedRoute = this.route.queryParams.subscribe(
-      queryParams => {
-        const querySearch = queryParams['search']
-
-        if (!querySearch) return this.redirectService.redirectToHomepage()
-        if (this.otherRouteParams.search === querySearch) return
-
-        this.otherRouteParams.search = querySearch
-        this.reloadVideos()
-      },
-
-      err => this.notificationsService.error('Error', err.text)
-    )
-  }
-
-  ngOnDestroy () {
-    super.ngOnDestroy()
-
-    if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
-  }
-
-  getVideosObservable (page: number) {
-    const newPagination = immutableAssign(this.pagination, { currentPage: page })
-    return this.videoService.searchVideos(this.otherRouteParams.search, newPagination, this.sort)
-  }
-
-  generateSyndicationList () {
-    throw new Error('Search does not support syndication.')
-  }
-}
index da786c0f98a1c2fe6ff211c7b6717691c816ee97..538a43c6dc3c486548dd5dd4947d8fa8efaa58ae 100644 (file)
@@ -1,8 +1,7 @@
 import { NgModule } from '@angular/core'
-import { RouterModule, Routes, UrlSegment } from '@angular/router'
+import { RouterModule, Routes } from '@angular/router'
 import { VideoLocalComponent } from '@app/videos/video-list/video-local.component'
 import { MetaGuard } from '@ngx-meta/core'
-import { VideoSearchComponent } from './video-list'
 import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
 import { VideoTrendingComponent } from './video-list/video-trending.component'
 import { VideosComponent } from './videos.component'
@@ -45,15 +44,6 @@ const videosRoutes: Routes = [
           }
         }
       },
-      {
-        path: 'search',
-        component: VideoSearchComponent,
-        data: {
-          meta: {
-            title: 'Search videos'
-          }
-        }
-      },
       {
         path: 'upload',
         loadChildren: 'app/videos/+video-edit/video-add.module#VideoAddModule',
index 7c3d457b3aa9d440a870a41efe08e225f623c866..c38257e0889d4d4e570cb7a1bb8322ae14dd2d34 100644 (file)
@@ -1,7 +1,6 @@
 import { NgModule } from '@angular/core'
 import { VideoLocalComponent } from '@app/videos/video-list/video-local.component'
 import { SharedModule } from '../shared'
-import { VideoSearchComponent } from './video-list'
 import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
 import { VideoTrendingComponent } from './video-list/video-trending.component'
 import { VideosRoutingModule } from './videos-routing.module'
@@ -18,8 +17,7 @@ import { VideosComponent } from './videos.component'
 
     VideoTrendingComponent,
     VideoRecentlyAddedComponent,
-    VideoLocalComponent,
-    VideoSearchComponent
+    VideoLocalComponent
   ],
 
   exports: [
index 42651d3a8291da65069e3b582db6e6e4436e918a..3b8fe39edee1222897581fb1537c533eeab4fd68 100755 (executable)
@@ -3,10 +3,14 @@
 set -eu
 
 for i in $(seq 1 6); do
-  dropdb --if-exists "peertube_test$i"
+  dbname="peertube_test$i"
+
+  dropdb --if-exists "$dbname"
   rm -rf "./test$i"
   rm -f "./config/local-test.json"
   rm -f "./config/local-test-$i.json"
-  createdb -O peertube "peertube_test$i"
+  createdb -O peertube "$dbname"
+  psql -c "CREATE EXTENSION pg_trgm;" "$dbname"
+  psql -c "CREATE EXTENSION unaccent;" "$dbname"
   redis-cli KEYS "bull-localhost:900$i*" | grep -v empty | xargs --no-run-if-empty redis-cli DEL
 done
index 104de21533ad3368ab0df84898099c50dc67e337..1bfec724bf116d7f66ead98f583fd3b8a08f6998 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -49,7 +49,7 @@ if (errorMessage !== null) {
 // Trust our proxy (IP forwarding...)
 app.set('trust proxy', CONFIG.TRUST_PROXY)
 
-// Security middlewares
+// Security middleware
 app.use(helmet({
   frameguard: {
     action: 'deny' // we only allow it for /videos/embed, see server/controllers/client.ts
index c386a6710acc981cd7b5fced089a243e0401095b..e928a747846bab5b6fc1de2a6869c010149c2809 100644 (file)
@@ -9,6 +9,7 @@ import { videosRouter } from './videos'
 import { badRequest } from '../../helpers/express-utils'
 import { videoChannelRouter } from './video-channel'
 import * as cors from 'cors'
+import { searchRouter } from './search'
 
 const apiRouter = express.Router()
 
@@ -26,6 +27,7 @@ apiRouter.use('/accounts', accountsRouter)
 apiRouter.use('/video-channels', videoChannelRouter)
 apiRouter.use('/videos', videosRouter)
 apiRouter.use('/jobs', jobsRouter)
+apiRouter.use('/search', searchRouter)
 apiRouter.use('/ping', pong)
 apiRouter.use('/*', badRequest)
 
diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts
new file mode 100644 (file)
index 0000000..2ff340b
--- /dev/null
@@ -0,0 +1,43 @@
+import * as express from 'express'
+import { isNSFWHidden } from '../../helpers/express-utils'
+import { getFormattedObjects } from '../../helpers/utils'
+import { VideoModel } from '../../models/video/video'
+import {
+  asyncMiddleware,
+  optionalAuthenticate,
+  paginationValidator,
+  searchValidator,
+  setDefaultPagination,
+  setDefaultSearchSort,
+  videosSearchSortValidator
+} from '../../middlewares'
+
+const searchRouter = express.Router()
+
+searchRouter.get('/videos',
+  paginationValidator,
+  setDefaultPagination,
+  videosSearchSortValidator,
+  setDefaultSearchSort,
+  optionalAuthenticate,
+  searchValidator,
+  asyncMiddleware(searchVideos)
+)
+
+// ---------------------------------------------------------------------------
+
+export { searchRouter }
+
+// ---------------------------------------------------------------------------
+
+async function searchVideos (req: express.Request, res: express.Response) {
+  const resultList = await VideoModel.searchAndPopulateAccountAndServer(
+    req.query.search as string,
+    req.query.start as number,
+    req.query.count as number,
+    req.query.sort as string,
+    isNSFWHidden(res)
+  )
+
+  return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
index bbb5b8b4cc223c560eaf716a46aeb5aa36e96844..547522123d19744119683e7dc1d8b1d40efc2648 100644 (file)
@@ -38,7 +38,6 @@ import {
   videosAddValidator,
   videosGetValidator,
   videosRemoveValidator,
-  videosSearchValidator,
   videosSortValidator,
   videosUpdateValidator
 } from '../../../middlewares'
@@ -50,7 +49,6 @@ import { blacklistRouter } from './blacklist'
 import { videoCommentRouter } from './comment'
 import { rateVideoRouter } from './rate'
 import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
-import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
 import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils'
 import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
 import { videoCaptionsRouter } from './captions'
@@ -94,15 +92,6 @@ videosRouter.get('/',
   optionalAuthenticate,
   asyncMiddleware(listVideos)
 )
-videosRouter.get('/search',
-  videosSearchValidator,
-  paginationValidator,
-  videosSortValidator,
-  setDefaultSort,
-  setDefaultPagination,
-  optionalAuthenticate,
-  asyncMiddleware(searchVideos)
-)
 videosRouter.put('/:id',
   authenticate,
   reqVideoFileUpdate,
@@ -432,15 +421,3 @@ async function removeVideo (req: express.Request, res: express.Response) {
 
   return res.type('json').status(204).end()
 }
-
-async function searchVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
-  const resultList = await VideoModel.searchAndPopulateAccountAndServer(
-    req.query.search as string,
-    req.query.start as number,
-    req.query.count as number,
-    req.query.sort as VideoSortField,
-    isNSFWHidden(res)
-  )
-
-  return res.json(getFormattedObjects(resultList.data, resultList.total))
-}
index 352d45fbf9341c1044618c50cb59b1e73dbdef0d..bbb518c1bf9ff3e87c8c0fe93100423d7ab964e0 100644 (file)
@@ -5,6 +5,7 @@ import { ACCEPT_HEADERS, STATIC_MAX_AGE } from '../initializers'
 import { asyncMiddleware } from '../middlewares'
 import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '../../shared/models/i18n/i18n'
 import { ClientHtml } from '../lib/client-html'
+import { logger } from '../helpers/logger'
 
 const clientsRouter = express.Router()
 
@@ -66,9 +67,14 @@ clientsRouter.use('/client/*', (req: express.Request, res: express.Response, nex
 
 // Always serve index client page (the client is a single page application, let it handle routing)
 // Try to provide the right language index.html
-clientsRouter.use('/(:language)?', function (req, res) {
+clientsRouter.use('/(:language)?', async function (req, res) {
   if (req.accepts(ACCEPT_HEADERS) === 'html') {
-    return generateHTMLPage(req, res, req.params.language)
+    try {
+      await generateHTMLPage(req, res, req.params.language)
+      return
+    } catch (err) {
+      logger.error('Cannot generate HTML page.', err)
+    }
   }
 
   return res.status(404).end()
index 11304cafb684988f4c7ebedd88f7d05d0ef62290..53f881fb36a4a5fe8a5106bf6783169c2a194cd9 100644 (file)
@@ -1,6 +1,6 @@
 import * as retry from 'async/retry'
 import * as Bluebird from 'bluebird'
-import { Model } from 'sequelize-typescript'
+import { Model, Sequelize } from 'sequelize-typescript'
 import { logger } from './logger'
 
 function retryTransactionWrapper <T, A, B, C> (
index ba48399de156681ae4f3327acd74ed256c8198fd..b966c0acb536467441e4ddf42ecf05d066c0cfc3 100644 (file)
@@ -35,7 +35,9 @@ const SORTABLE_COLUMNS = {
   VIDEO_COMMENT_THREADS: [ 'createdAt' ],
   BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
   FOLLOWERS: [ 'createdAt' ],
-  FOLLOWING: [ 'createdAt' ]
+  FOLLOWING: [ 'createdAt' ],
+
+  VIDEOS_SEARCH: [ 'bestmatch', 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ]
 }
 
 const OAUTH_LIFETIME = {
index 434d7ef19aca7cdc035975f0dc84baa8a2bf12a2..045f41a96a3cc9665abb31702972e7fe730d4025 100644 (file)
@@ -80,6 +80,14 @@ async function initDatabaseModels (silent: boolean) {
     ScheduleVideoUpdateModel
   ])
 
+  // Check extensions exist in the database
+  await checkPostgresExtensions()
+
+  // Create custom PostgreSQL functions
+  await createFunctions()
+
+  await sequelizeTypescript.query('CREATE EXTENSION IF NOT EXISTS pg_trgm', { raw: true })
+
   if (!silent) logger.info('Database %s is ready.', dbname)
 
   return
@@ -91,3 +99,38 @@ export {
   initDatabaseModels,
   sequelizeTypescript
 }
+
+// ---------------------------------------------------------------------------
+
+async function checkPostgresExtensions () {
+  const extensions = [
+    'pg_trgm',
+    'unaccent'
+  ]
+
+  for (const extension of extensions) {
+    const query = `SELECT true AS enabled FROM pg_available_extensions WHERE name = '${extension}' AND installed_version IS NOT NULL;`
+    const [ res ] = await sequelizeTypescript.query(query, { raw: true })
+
+    if (!res || res.length === 0 || res[ 0 ][ 'enabled' ] !== true) {
+      // Try to create the extension ourself
+      try {
+        await sequelizeTypescript.query(`CREATE EXTENSION ${extension};`, { raw: true })
+
+      } catch {
+        const errorMessage = `You need to enable ${extension} extension in PostgreSQL. ` +
+          `You can do so by running 'CREATE EXTENSION ${extension};' as a PostgreSQL super user in ${CONFIG.DATABASE.DBNAME} database.`
+        throw new Error(errorMessage)
+      }
+    }
+  }
+}
+
+async function createFunctions () {
+  const query = `CREATE OR REPLACE FUNCTION immutable_unaccent(varchar)
+  RETURNS text AS $$
+    SELECT unaccent($1)
+  $$ LANGUAGE sql IMMUTABLE;`
+
+  return sequelizeTypescript.query(query, { raw: true })
+}
index cdb809e75e80084d5f6774e4f21ba4159be89fee..6307ee1547e6359eb0c9a410525535cd941ed588 100644 (file)
@@ -8,6 +8,12 @@ function setDefaultSort (req: express.Request, res: express.Response, next: expr
   return next()
 }
 
+function setDefaultSearchSort (req: express.Request, res: express.Response, next: express.NextFunction) {
+  if (!req.query.sort) req.query.sort = '-bestmatch'
+
+  return next()
+}
+
 function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) {
   let newSort: SortType = { sortModel: undefined, sortValue: undefined }
 
@@ -33,5 +39,6 @@ function setBlacklistSort (req: express.Request, res: express.Response, next: ex
 
 export {
   setDefaultSort,
+  setDefaultSearchSort,
   setBlacklistSort
 }
index b69e1f14bf64b81d5b48e221e6caf03ae5cc4389..e3f0f5963c9d803bf6e6dfa9bbaaff8f1f34b0dc 100644 (file)
@@ -10,3 +10,4 @@ export * from './videos'
 export * from './video-blacklist'
 export * from './video-channels'
 export * from './webfinger'
+export * from './search'
diff --git a/server/middlewares/validators/search.ts b/server/middlewares/validators/search.ts
new file mode 100644 (file)
index 0000000..774845e
--- /dev/null
@@ -0,0 +1,22 @@
+import * as express from 'express'
+import { areValidationErrors } from './utils'
+import { logger } from '../../helpers/logger'
+import { query } from 'express-validator/check'
+
+const searchValidator = [
+  query('search').not().isEmpty().withMessage('Should have a valid search'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking search parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  searchValidator
+}
index 925f47e5702b94cb00412753f7c91d6e0da68587..00bde548c517758393e7e0b63a555061feb9a3f8 100644 (file)
@@ -7,6 +7,7 @@ const SORTABLE_ACCOUNTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNT
 const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS)
 const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES)
 const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
+const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH)
 const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
 const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS)
 const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS)
@@ -18,6 +19,7 @@ const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
 const jobsSortValidator = checkSort(SORTABLE_JOBS_COLUMNS)
 const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS)
 const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
+const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
 const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
 const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS)
 const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
@@ -30,6 +32,7 @@ export {
   usersSortValidator,
   videoAbusesSortValidator,
   videoChannelsSortValidator,
+  videosSearchSortValidator,
   videosSortValidator,
   blacklistSortValidator,
   accountsSortValidator,
index abb23b510f04699f28476fd38ed4090131ddc3cf..d9af2aa0ab9a99bfb5e6f7d25ba4c76d64a42571 100644 (file)
@@ -1,6 +1,6 @@
 import * as express from 'express'
 import 'express-validator'
-import { body, param, query, ValidationChain } from 'express-validator/check'
+import { body, param, ValidationChain } from 'express-validator/check'
 import { UserRight, VideoPrivacy } from '../../../shared'
 import {
   isBooleanValid,
@@ -172,18 +172,6 @@ const videosRemoveValidator = [
   }
 ]
 
-const videosSearchValidator = [
-  query('search').not().isEmpty().withMessage('Should have a valid search'),
-
-  (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    logger.debug('Checking videosSearch parameters', { parameters: req.params })
-
-    if (areValidationErrors(req, res)) return
-
-    return next()
-  }
-]
-
 const videoAbuseReportValidator = [
   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
   body('reason').custom(isVideoAbuseReasonValid).withMessage('Should have a valid reason'),
@@ -240,7 +228,6 @@ export {
   videosUpdateValidator,
   videosGetValidator,
   videosRemoveValidator,
-  videosSearchValidator,
   videosShareValidator,
 
   videoAbuseReportValidator,
index 1d0e54ee302f8caf59ba996257005f8f4fc4e9fc..38a689fea5da9ac7481d3c5480c5b188077bdfd8 100644 (file)
@@ -88,6 +88,12 @@ enum ScopeNames {
     },
     {
       fields: [ 'inboxUrl', 'sharedInboxUrl' ]
+    },
+    {
+      fields: [ 'serverId' ]
+    },
+    {
+      fields: [ 'avatarId' ]
     }
   ]
 })
index 59ce83c16e09a79b3fbeac8a97a2b016b0e6e7a5..49d32c24f95c11c4c9e3bbdba1cef372c68e9e7c 100644 (file)
@@ -1,6 +1,8 @@
 // Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
+import { Sequelize } from 'sequelize-typescript'
+
 function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
-  let field: string
+  let field: any
   let direction: 'ASC' | 'DESC'
 
   if (value.substring(0, 1) === '-') {
@@ -11,6 +13,9 @@ function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
     field = value
   }
 
+  // Alias
+  if (field.toLowerCase() === 'bestmatch') field = Sequelize.col('similarity')
+
   return [ [ field, direction ], lastSort ]
 }
 
@@ -27,10 +32,53 @@ function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldN
   }
 }
 
+function buildTrigramSearchIndex (indexName: string, attribute: string) {
+  return {
+    name: indexName,
+    fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + '))') as any ],
+    using: 'gin',
+    operator: 'gin_trgm_ops'
+  }
+}
+
+function createSimilarityAttribute (col: string, value: string) {
+  return Sequelize.fn(
+    'similarity',
+
+    searchTrigramNormalizeCol(col),
+
+    searchTrigramNormalizeValue(value)
+  )
+}
+
+function createSearchTrigramQuery (col: string, value: string) {
+  return {
+    [ Sequelize.Op.or ]: [
+      // FIXME: use word_similarity instead of just similarity?
+      Sequelize.where(searchTrigramNormalizeCol(col), ' % ', searchTrigramNormalizeValue(value)),
+
+      Sequelize.where(searchTrigramNormalizeCol(col), ' LIKE ', searchTrigramNormalizeValue(`%${value}%`))
+    ]
+  }
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   getSort,
   getSortOnModel,
-  throwIfNotValid
+  createSimilarityAttribute,
+  throwIfNotValid,
+  buildTrigramSearchIndex,
+  createSearchTrigramQuery
+}
+
+// ---------------------------------------------------------------------------
+
+function searchTrigramNormalizeValue (value: string) {
+  return Sequelize.fn('lower', Sequelize.fn('unaccent', value))
+}
+
+function searchTrigramNormalizeCol (col: string) {
+  return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
 }
index 74a3a5d05a939d4961e6e437b91f8a21c7da4499..15b4dda5b2fb57fee266cdab07fb422b921fc7fb 100644 (file)
@@ -83,7 +83,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
 import { ActorModel } from '../activitypub/actor'
 import { AvatarModel } from '../avatar/avatar'
 import { ServerModel } from '../server/server'
-import { getSort, throwIfNotValid } from '../utils'
+import { buildTrigramSearchIndex, createSearchTrigramQuery, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
 import { TagModel } from './tag'
 import { VideoAbuseModel } from './video-abuse'
 import { VideoChannelModel } from './video-channel'
@@ -94,6 +94,37 @@ import { VideoTagModel } from './video-tag'
 import { ScheduleVideoUpdateModel } from './schedule-video-update'
 import { VideoCaptionModel } from './video-caption'
 
+// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
+const indexes: Sequelize.DefineIndexesOptions[] = [
+  buildTrigramSearchIndex('video_name_trigram', 'name'),
+
+  {
+    fields: [ 'createdAt' ]
+  },
+  {
+    fields: [ 'duration' ]
+  },
+  {
+    fields: [ 'views' ]
+  },
+  {
+    fields: [ 'likes' ]
+  },
+  {
+    fields: [ 'uuid' ]
+  },
+  {
+    fields: [ 'channelId' ]
+  },
+  {
+    fields: [ 'id', 'privacy', 'state', 'waitTranscoding' ]
+  },
+  {
+    fields: [ 'url'],
+    unique: true
+  }
+]
+
 export enum ScopeNames {
   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
   WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
@@ -309,36 +340,7 @@ export enum ScopeNames {
 })
 @Table({
   tableName: 'video',
-  indexes: [
-    {
-      fields: [ 'name' ]
-    },
-    {
-      fields: [ 'createdAt' ]
-    },
-    {
-      fields: [ 'duration' ]
-    },
-    {
-      fields: [ 'views' ]
-    },
-    {
-      fields: [ 'likes' ]
-    },
-    {
-      fields: [ 'uuid' ]
-    },
-    {
-      fields: [ 'channelId' ]
-    },
-    {
-      fields: [ 'id', 'privacy', 'state', 'waitTranscoding' ]
-    },
-    {
-      fields: [ 'url'],
-      unique: true
-    }
-  ]
+  indexes
 })
 export class VideoModel extends Model<VideoModel> {
 
@@ -794,33 +796,13 @@ export class VideoModel extends Model<VideoModel> {
 
   static async searchAndPopulateAccountAndServer (value: string, start: number, count: number, sort: string, hideNSFW: boolean) {
     const query: IFindOptions<VideoModel> = {
+      attributes: {
+        include: [ createSimilarityAttribute('VideoModel.name', value) ]
+      },
       offset: start,
       limit: count,
       order: getSort(sort),
-      where: {
-        [Sequelize.Op.or]: [
-          {
-            name: {
-              [ Sequelize.Op.iLike ]: '%' + value + '%'
-            }
-          },
-          {
-            preferredUsernameChannel: Sequelize.where(Sequelize.col('VideoChannel->Actor.preferredUsername'), {
-              [ Sequelize.Op.iLike ]: '%' + value + '%'
-            })
-          },
-          {
-            preferredUsernameAccount: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor.preferredUsername'), {
-              [ Sequelize.Op.iLike ]: '%' + value + '%'
-            })
-          },
-          {
-            host: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor->Server.host'), {
-              [ Sequelize.Op.iLike ]: '%' + value + '%'
-            })
-          }
-        ]
-      }
+      where: createSearchTrigramQuery('VideoModel.name', value)
     }
 
     const serverActor = await getServerActor()
index 74bf7354e1f9dc6db265a7a67ca85092d7b3c204..a42d0f0431642041bd7fdc364ea26ba1c52d89fd 100644 (file)
@@ -248,9 +248,9 @@ function removeVideo (url: string, token: string, id: number | string, expectedS
 }
 
 function searchVideo (url: string, search: string) {
-  const path = '/api/v1/videos'
+  const path = '/api/v1/search/videos'
   const req = request(url)
-    .get(path + '/search')
+    .get(path)
     .query({ search })
     .set('Accept', 'application/json')
 
@@ -271,10 +271,10 @@ function searchVideoWithToken (url: string, search: string, token: string) {
 }
 
 function searchVideoWithPagination (url: string, search: string, start: number, count: number, sort?: string) {
-  const path = '/api/v1/videos'
+  const path = '/api/v1/search/videos'
 
   const req = request(url)
-                .get(path + '/search')
+                .get(path)
                 .query({ start })
                 .query({ search })
                 .query({ count })
@@ -287,10 +287,10 @@ function searchVideoWithPagination (url: string, search: string, start: number,
 }
 
 function searchVideoWithSort (url: string, search: string, sort: string) {
-  const path = '/api/v1/videos'
+  const path = '/api/v1/search/videos'
 
   return request(url)
-          .get(path + '/search')
+          .get(path)
           .query({ search })
           .query({ sort })
           .set('Accept', 'application/json')
index 08796180292d63917eef8dd7ebfd78a09817aae0..cab336344623167effa3b396a68d026c6f47f212 100644 (file)
@@ -53,6 +53,10 @@ $ docker-compose up
 **Important**: note that you'll get the initial `root` user password from the
 program output, so check out your logs to find them.
 
+### What now?
+
+See the production guide ["What now" section](/support/doc/production.md#what-now). 
+
 ### Upgrade
 
 Pull the latest images and rerun PeerTube:
index 8310e7fda880b042bc7d669c9c6b41a875224108..8d2a4da11610021a26fa1424817d8da4ee601650 100644 (file)
@@ -41,6 +41,13 @@ $ sudo -u postgres createuser -P peertube
 $ sudo -u postgres createdb -O peertube peertube_prod
 ```
 
+Then enable extensions PeerTube needs:
+
+```
+$ sudo -u postgres psql -c "CREATE EXTENSION pg_trgm;" peertube_prod
+$ sudo -u postgres psql -c "CREATE EXTENSION unaccent;" peertube_prod
+```
+
 ### Prepare PeerTube directory
 
 Fetch the latest tagged version of Peertube
@@ -194,7 +201,7 @@ Now your instance is up you can:
 
 ## Upgrade
 
-### PeerTube code
+### PeerTube instance
 
 **Check the changelog (in particular BREAKING CHANGES!):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md