From 0b18f4aa80df8868bf34605423c7a298dffbb2aa Mon Sep 17 00:00:00 2001
From: Chocobozzz <me@florianbigard.com>
Date: Fri, 20 Jul 2018 18:31:49 +0200
Subject: [PATCH] Add advanced search in client

---
 .../src/app/search/advanced-search.model.ts   | 101 +++++++++++
 .../app/search/search-filters.component.html  |  87 +++++++++
 .../app/search/search-filters.component.scss  |  40 +++++
 .../app/search/search-filters.component.ts    | 170 ++++++++++++++++++
 client/src/app/search/search.component.html   |  30 +++-
 client/src/app/search/search.component.scss   |  46 ++++-
 client/src/app/search/search.component.ts     |  29 ++-
 client/src/app/search/search.module.ts        |   9 +-
 client/src/app/search/search.service.ts       |  17 +-
 client/src/assets/images/search/filter.svg    |  17 ++
 client/tsconfig.json                          |   8 +-
 server/helpers/custom-validators/search.ts    |   7 +-
 server/helpers/express-utils.ts               |   6 +-
 server/initializers/database.ts               |   2 -
 server/middlewares/validators/search.ts       |   5 +-
 server/models/video/video.ts                  |  17 +-
 server/tests/api/search/search-videos.ts      |   8 +-
 server/tests/api/videos/video-nsfw.ts         |  11 ++
 shared/models/search/index.ts                 |   1 +
 shared/models/search/nsfw-query.model.ts      |   1 +
 .../search/videos-search-query.model.ts       |   4 +-
 21 files changed, 583 insertions(+), 33 deletions(-)
 create mode 100644 client/src/app/search/advanced-search.model.ts
 create mode 100644 client/src/app/search/search-filters.component.html
 create mode 100644 client/src/app/search/search-filters.component.scss
 create mode 100644 client/src/app/search/search-filters.component.ts
 create mode 100644 client/src/assets/images/search/filter.svg
 create mode 100644 shared/models/search/nsfw-query.model.ts

diff --git a/client/src/app/search/advanced-search.model.ts b/client/src/app/search/advanced-search.model.ts
new file mode 100644
index 000000000..a0f333175
--- /dev/null
+++ b/client/src/app/search/advanced-search.model.ts
@@ -0,0 +1,101 @@
+import { NSFWQuery } from '../../../../shared/models/search'
+
+export class AdvancedSearch {
+  startDate: string // ISO 8601
+  endDate: string // ISO 8601
+
+  nsfw: NSFWQuery
+
+  categoryOneOf: string
+
+  licenceOneOf: string
+
+  languageOneOf: string
+
+  tagsOneOf: string
+  tagsAllOf: string
+
+  durationMin: number // seconds
+  durationMax: number // seconds
+
+  constructor (options?: {
+    startDate?: string
+    endDate?: string
+    nsfw?: NSFWQuery
+    categoryOneOf?: string
+    licenceOneOf?: string
+    languageOneOf?: string
+    tagsOneOf?: string
+    tagsAllOf?: string
+    durationMin?: string
+    durationMax?: string
+  }) {
+    if (!options) return
+
+    this.startDate = options.startDate
+    this.endDate = options.endDate
+    this.nsfw = options.nsfw
+    this.categoryOneOf = options.categoryOneOf
+    this.licenceOneOf = options.licenceOneOf
+    this.languageOneOf = options.languageOneOf
+    this.tagsOneOf = options.tagsOneOf
+    this.tagsAllOf = options.tagsAllOf
+    this.durationMin = parseInt(options.durationMin, 10)
+    this.durationMax = parseInt(options.durationMax, 10)
+
+    if (isNaN(this.durationMin)) this.durationMin = undefined
+    if (isNaN(this.durationMax)) this.durationMax = undefined
+  }
+
+  containsValues () {
+    const obj = this.toUrlObject()
+    for (const k of Object.keys(obj)) {
+      if (obj[k] !== undefined) return true
+    }
+
+    return false
+  }
+
+  reset () {
+    this.startDate = undefined
+    this.endDate = undefined
+    this.nsfw = undefined
+    this.categoryOneOf = undefined
+    this.licenceOneOf = undefined
+    this.languageOneOf = undefined
+    this.tagsOneOf = undefined
+    this.tagsAllOf = undefined
+    this.durationMin = undefined
+    this.durationMax = undefined
+  }
+
+  toUrlObject () {
+    return {
+      startDate: this.startDate,
+      endDate: this.endDate,
+      nsfw: this.nsfw,
+      categoryOneOf: this.categoryOneOf,
+      licenceOneOf: this.licenceOneOf,
+      languageOneOf: this.languageOneOf,
+      tagsOneOf: this.tagsOneOf,
+      tagsAllOf: this.tagsAllOf,
+      durationMin: this.durationMin,
+      durationMax: this.durationMax
+    }
+  }
+
+  toAPIObject () {
+    return {
+      startDate: this.startDate,
+      endDate: this.endDate,
+      nsfw: this.nsfw,
+      categoryOneOf: this.categoryOneOf ? this.categoryOneOf.split(',') : undefined,
+      licenceOneOf: this.licenceOneOf ? this.licenceOneOf.split(',') : undefined,
+      languageOneOf: this.languageOneOf ? this.languageOneOf.split(',') : undefined,
+      tagsOneOf: this.tagsOneOf ? this.tagsOneOf.split(',') : undefined,
+      tagsAllOf: this.tagsAllOf ? this.tagsAllOf.split(',') : undefined,
+      durationMin: this.durationMin,
+      durationMax: this.durationMax
+    }
+  }
+}
diff --git a/client/src/app/search/search-filters.component.html b/client/src/app/search/search-filters.component.html
new file mode 100644
index 000000000..f8b3675e5
--- /dev/null
+++ b/client/src/app/search/search-filters.component.html
@@ -0,0 +1,87 @@
+<form role="form" (ngSubmit)="formUpdated()">
+
+  <div class="row">
+    <div class="col-lg-4 col-md-6 col-xs-12">
+      <div class="form-group">
+        <div i18n class="radio-label">Published date</div>
+
+        <div class="peertube-radio-container" *ngFor="let date of publishedDateRanges">
+          <input type="radio" name="publishedDateRange" [id]="date.id" [value]="date.id" [(ngModel)]="publishedDateRange">
+          <label [for]="date.id" class="radio">{{ date.label }}</label>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <div i18n class="radio-label">Duration</div>
+
+        <div class="peertube-radio-container" *ngFor="let duration of durationRanges">
+          <input type="radio" name="durationRange" [id]="duration.id" [value]="duration.id" [(ngModel)]="durationRange">
+          <label [for]="duration.id" class="radio">{{ duration.label }}</label>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <div i18n class="radio-label">Display sensitive content</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>
+        <div class="peertube-select-container">
+          <select id="category" name="category" [(ngModel)]="advancedSearch.categoryOneOf">
+            <option></option>
+            <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option>
+          </select>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label i18n for="licence">Licence</label>
+        <div class="peertube-select-container">
+          <select id="licence" name="licence" [(ngModel)]="advancedSearch.licenceOneOf">
+            <option></option>
+            <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option>
+          </select>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label i18n for="language">Language</label>
+        <div class="peertube-select-container">
+          <select id="language" name="language" [(ngModel)]="advancedSearch.languageOneOf">
+            <option></option>
+            <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option>
+          </select>
+        </div>
+      </div>
+    </div>
+
+    <div class="col-lg-4 col-md-6 col-xs-12">
+      <div class="form-group">
+        <label i18n for="tagsAllOf">All of these tags</label>
+        <input type="text" name="tagsAllOf" id="tagsAllOf" [(ngModel)]="advancedSearch.tagsAllOf" />
+      </div>
+
+      <div class="form-group">
+        <label i18n for="tagsOneOf">One of these tags</label>
+        <input type="text" name="tagsOneOf" id="tagsOneOf" [(ngModel)]="advancedSearch.tagsOneOf" />
+      </div>
+    </div>
+  </div>
+
+  <div class="submit-button">
+    <input type="submit" i18n-value value="Filter">
+  </div>
+</form>
\ No newline at end of file
diff --git a/client/src/app/search/search-filters.component.scss b/client/src/app/search/search-filters.component.scss
new file mode 100644
index 000000000..cfc48fbef
--- /dev/null
+++ b/client/src/app/search/search-filters.component.scss
@@ -0,0 +1,40 @@
+@import '_variables';
+@import '_mixins';
+
+form {
+  margin-top: 40px;
+}
+
+.radio-label {
+  font-size: 15px;
+  font-weight: $font-bold;
+}
+
+.peertube-radio-container {
+  @include peertube-radio-container;
+
+  display: inline-block;
+  margin-right: 30px;
+}
+
+.peertube-select-container {
+  @include peertube-select-container(auto);
+}
+
+.form-group {
+  margin-bottom: 25px;
+}
+
+input[type=text] {
+  @include peertube-input-text(100%);
+  display: block;
+}
+
+input[type=submit] {
+  @include peertube-button-link;
+  @include orange-button;
+}
+
+.submit-button {
+  text-align: right;
+}
\ No newline at end of file
diff --git a/client/src/app/search/search-filters.component.ts b/client/src/app/search/search-filters.component.ts
new file mode 100644
index 000000000..4219f99a9
--- /dev/null
+++ b/client/src/app/search/search-filters.component.ts
@@ -0,0 +1,170 @@
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
+import { RedirectService, ServerService } from '@app/core'
+import { NotificationsService } from 'angular2-notifications'
+import { SearchService } from '@app/search/search.service'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { MetaService } from '@ngx-meta/core'
+import { AdvancedSearch } from '@app/search/advanced-search.model'
+import { VideoConstant } from '../../../../shared'
+
+@Component({
+  selector: 'my-search-filters',
+  styleUrls: [ './search-filters.component.scss' ],
+  templateUrl: './search-filters.component.html'
+})
+export class SearchFiltersComponent implements OnInit {
+  @Input() advancedSearch: AdvancedSearch = new AdvancedSearch()
+
+  @Output() filtered = new EventEmitter<AdvancedSearch>()
+
+  videoCategories: VideoConstant<string>[] = []
+  videoLicences: VideoConstant<string>[] = []
+  videoLanguages: VideoConstant<string>[] = []
+
+  publishedDateRanges: { id: string, label: string }[] = []
+  durationRanges: { id: string, label: string }[] = []
+
+  publishedDateRange: string
+  durationRange: string
+
+  constructor (
+    private i18n: I18n,
+    private route: ActivatedRoute,
+    private metaService: MetaService,
+    private redirectService: RedirectService,
+    private notificationsService: NotificationsService,
+    private searchService: SearchService,
+    private serverService: ServerService
+  ) {
+    this.publishedDateRanges = [
+      {
+        id: 'today',
+        label: this.i18n('Today')
+      },
+      {
+        id: 'last_7days',
+        label: this.i18n('Last 7 days')
+      },
+      {
+        id: 'last_30days',
+        label: this.i18n('Last 30 days')
+      },
+      {
+        id: 'last_365days',
+        label: this.i18n('Last 365 days')
+      }
+    ]
+
+    this.durationRanges = [
+      {
+        id: 'short',
+        label: this.i18n('Short (< 4 minutes)')
+      },
+      {
+        id: 'long',
+        label: this.i18n('Long (> 10 minutes)')
+      },
+      {
+        id: 'medium',
+        label: this.i18n('Medium (4-10 minutes)')
+      }
+    ]
+  }
+
+  ngOnInit () {
+    this.videoCategories = this.serverService.getVideoCategories()
+    this.videoLicences = this.serverService.getVideoLicences()
+    this.videoLanguages = this.serverService.getVideoLanguages()
+
+    this.loadFromDurationRange()
+    this.loadFromPublishedRange()
+  }
+
+  formUpdated () {
+    this.updateModelFromDurationRange()
+    this.updateModelFromPublishedRange()
+
+    this.filtered.emit(this.advancedSearch)
+  }
+
+  private loadFromDurationRange () {
+    if (this.advancedSearch.durationMin || this.advancedSearch.durationMax) {
+      const fourMinutes = 60 * 4
+      const tenMinutes = 60 * 10
+
+      if (this.advancedSearch.durationMin === fourMinutes && this.advancedSearch.durationMax === tenMinutes) {
+        this.durationRange = 'medium'
+      } else if (this.advancedSearch.durationMax === fourMinutes) {
+        this.durationRange = 'short'
+      } else if (this.advancedSearch.durationMin === tenMinutes) {
+        this.durationRange = 'long'
+      }
+    }
+  }
+
+  private loadFromPublishedRange () {
+    if (this.advancedSearch.startDate) {
+      const date = new Date(this.advancedSearch.startDate)
+      const now = new Date()
+
+      const diff = Math.abs(date.getTime() - now.getTime())
+
+      const dayMS = 1000 * 3600 * 24
+      const numberOfDays = diff / dayMS
+
+      if (numberOfDays >= 365) this.publishedDateRange = 'last_365days'
+      else if (numberOfDays >= 30) this.publishedDateRange = 'last_30days'
+      else if (numberOfDays >= 7) this.publishedDateRange = 'last_7days'
+      else if (numberOfDays >= 0) this.publishedDateRange = 'today'
+    }
+  }
+
+  private updateModelFromDurationRange () {
+    if (!this.durationRange) return
+
+    const fourMinutes = 60 * 4
+    const tenMinutes = 60 * 10
+
+    switch (this.durationRange) {
+      case 'short':
+        this.advancedSearch.durationMin = undefined
+        this.advancedSearch.durationMax = fourMinutes
+        break
+
+      case 'medium':
+        this.advancedSearch.durationMin = fourMinutes
+        this.advancedSearch.durationMax = tenMinutes
+        break
+
+      case 'long':
+        this.advancedSearch.durationMin = tenMinutes
+        this.advancedSearch.durationMax = undefined
+        break
+    }
+  }
+
+  private updateModelFromPublishedRange () {
+    if (!this.publishedDateRange) return
+
+    // today
+    const date = new Date()
+    date.setHours(0, 0, 0, 0)
+
+    switch (this.publishedDateRange) {
+      case 'last_7days':
+        date.setDate(date.getDate() - 7)
+        break
+
+      case 'last_30days':
+        date.setDate(date.getDate() - 30)
+        break
+
+      case 'last_365days':
+        date.setDate(date.getDate() - 365)
+        break
+    }
+
+    this.advancedSearch.startDate = date.toISOString()
+  }
+}
diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html
index b8c4d7dc5..3a63dbcec 100644
--- a/client/src/app/search/search.component.html
+++ b/client/src/app/search/search.component.html
@@ -1,10 +1,28 @@
-<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 i18n class="results-header">
+    <div class="first-line">
+      <div class="results-counter">
+        <ng-container *ngIf="pagination.totalItems">
+          {{ pagination.totalItems | myNumberFormatter }} results for <span class="search-value">{{ currentSearch }}</span>
+        </ng-container>
+      </div>
+
+      <div
+        class="results-filter-button" (click)="isSearchFilterCollapsed = !isSearchFilterCollapsed" role="button"
+        [attr.aria-expanded]="isSearchFilterCollapsed" aria-controls="collapseBasic"
+      >
+        <span class="icon icon-filter"></span>
+        <ng-container i18n>Filters</ng-container>
+      </div>
+    </div>
+
+    <div class="results-filter" [collapse]="isSearchFilterCollapsed">
+      <my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered($event)"></my-search-filters>
+    </div>
+  </div>
+
+  <div i18n *ngIf="pagination.totalItems === 0" class="no-result">
+    No results found
   </div>
 
   <div *ngFor="let video of videos" class="entry video">
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss
index 06e3c9542..f70d4bf87 100644
--- a/client/src/app/search/search.component.scss
+++ b/client/src/app/search/search.component.scss
@@ -2,7 +2,7 @@
 @import '_mixins';
 
 .no-result {
-  height: 70vh;
+  height: 40vh;
   display: flex;
   align-items: center;
   justify-content: center;
@@ -11,17 +11,49 @@
 }
 
 .search-result {
-  margin-left: 40px;
-  margin-top: 40px;
+  margin: 40px;
 
-  .results-counter {
-    font-size: 15px;
+  .results-header {
+    font-size: 16px;
     padding-bottom: 20px;
     margin-bottom: 30px;
     border-bottom: 1px solid #DADADA;
 
-    .search-value {
-      font-weight: $font-semibold;
+    .first-line {
+      display: flex;
+      flex-direction: row;
+
+      .results-counter {
+        flex-grow: 1;
+
+        .search-value {
+          font-weight: $font-semibold;
+        }
+      }
+
+      .results-filter-button {
+
+        .icon.icon-filter {
+          @include icon(20px);
+
+          position: relative;
+          top: -1px;
+          margin-right: 5px;
+          background-image: url('../../assets/images/search/filter.svg');
+        }
+      }
+    }
+
+    .results-filter {
+      // Animation when we show/hide the filters
+      transition: max-height 0.3s;
+      display: block !important;
+      overflow: hidden !important;
+      max-height: 0;
+
+      &.show {
+        max-height: 800px;
+      }
     }
   }
 
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts
index be1cb3689..09028fec5 100644
--- a/client/src/app/search/search.component.ts
+++ b/client/src/app/search/search.component.ts
@@ -1,5 +1,5 @@
 import { Component, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute } from '@angular/router'
+import { ActivatedRoute, Router } from '@angular/router'
 import { RedirectService } from '@app/core'
 import { NotificationsService } from 'angular2-notifications'
 import { Subscription } from 'rxjs'
@@ -8,6 +8,7 @@ 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'
+import { AdvancedSearch } from '@app/search/advanced-search.model'
 
 @Component({
   selector: 'my-search',
@@ -21,6 +22,8 @@ export class SearchComponent implements OnInit, OnDestroy {
     itemsPerPage: 10, // It's per object type (so 10 videos, 10 video channels etc)
     totalItems: null
   }
+  advancedSearch: AdvancedSearch = new AdvancedSearch()
+  isSearchFilterCollapsed = true
 
   private subActivatedRoute: Subscription
   private currentSearch: string
@@ -28,6 +31,7 @@ export class SearchComponent implements OnInit, OnDestroy {
   constructor (
     private i18n: I18n,
     private route: ActivatedRoute,
+    private router: Router,
     private metaService: MetaService,
     private redirectService: RedirectService,
     private notificationsService: NotificationsService,
@@ -35,6 +39,9 @@ export class SearchComponent implements OnInit, OnDestroy {
   ) { }
 
   ngOnInit () {
+    this.advancedSearch = new AdvancedSearch(this.route.snapshot.queryParams)
+    if (this.advancedSearch.containsValues()) this.isSearchFilterCollapsed = false
+
     this.subActivatedRoute = this.route.queryParams.subscribe(
       queryParams => {
         const querySearch = queryParams['search']
@@ -42,6 +49,9 @@ export class SearchComponent implements OnInit, OnDestroy {
         if (!querySearch) return this.redirectService.redirectToHomepage()
         if (querySearch === this.currentSearch) return
 
+        // Search updated, reset filters
+        if (this.currentSearch) this.advancedSearch.reset()
+
         this.currentSearch = querySearch
         this.updateTitle()
 
@@ -57,7 +67,7 @@ export class SearchComponent implements OnInit, OnDestroy {
   }
 
   search () {
-    return this.searchService.searchVideos(this.currentSearch, this.pagination)
+    return this.searchService.searchVideos(this.currentSearch, this.pagination, this.advancedSearch)
       .subscribe(
         ({ videos, totalVideos }) => {
           this.videos = this.videos.concat(videos)
@@ -78,6 +88,14 @@ export class SearchComponent implements OnInit, OnDestroy {
     this.search()
   }
 
+  onFiltered () {
+    this.updateUrlFromAdvancedSearch()
+    // Hide the filters
+    this.isSearchFilterCollapsed = true
+
+    this.reload()
+  }
+
   private reload () {
     this.pagination.currentPage = 1
     this.pagination.totalItems = null
@@ -90,4 +108,11 @@ export class SearchComponent implements OnInit, OnDestroy {
   private updateTitle () {
     this.metaService.setTitle(this.i18n('Search') + ' ' + this.currentSearch)
   }
+
+  private updateUrlFromAdvancedSearch () {
+    this.router.navigate([], {
+      relativeTo: this.route,
+      queryParams: Object.assign({}, this.advancedSearch.toUrlObject(), { search: this.currentSearch })
+    })
+  }
 }
diff --git a/client/src/app/search/search.module.ts b/client/src/app/search/search.module.ts
index c6ec74d20..488046cf1 100644
--- a/client/src/app/search/search.module.ts
+++ b/client/src/app/search/search.module.ts
@@ -3,15 +3,20 @@ 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'
+import { SearchFiltersComponent } from '@app/search/search-filters.component'
+import { CollapseModule } from 'ngx-bootstrap/collapse'
 
 @NgModule({
   imports: [
     SearchRoutingModule,
-    SharedModule
+    SharedModule,
+
+    CollapseModule.forRoot()
   ],
 
   declarations: [
-    SearchComponent
+    SearchComponent,
+    SearchFiltersComponent
   ],
 
   exports: [
diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts
index 02d5f5915..c6106afd6 100644
--- a/client/src/app/search/search.service.ts
+++ b/client/src/app/search/search.service.ts
@@ -8,6 +8,7 @@ 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'
+import { AdvancedSearch } from '@app/search/advanced-search.model'
 
 export type SearchResult = {
   videosResult: { totalVideos: number, videos: Video[] }
@@ -26,7 +27,8 @@ export class SearchService {
 
   searchVideos (
     search: string,
-    componentPagination: ComponentPagination
+    componentPagination: ComponentPagination,
+    advancedSearch: AdvancedSearch
   ): Observable<{ videos: Video[], totalVideos: number }> {
     const url = SearchService.BASE_SEARCH_URL + 'videos'
 
@@ -36,6 +38,19 @@ export class SearchService {
     params = this.restService.addRestGetParams(params, pagination)
     params = params.append('search', search)
 
+    const advancedSearchObject = advancedSearch.toAPIObject()
+
+    for (const name of Object.keys(advancedSearchObject)) {
+      const value = advancedSearchObject[name]
+      if (!value) continue
+
+      if (Array.isArray(value)) {
+        for (const v of value) params = params.append(name, v)
+      } else {
+        params = params.append(name, value)
+      }
+    }
+
     return this.authHttp
                .get<ResultList<VideoServerModel>>(url, { params })
                .pipe(
diff --git a/client/src/assets/images/search/filter.svg b/client/src/assets/images/search/filter.svg
new file mode 100644
index 000000000..218d6dee7
--- /dev/null
+++ b/client/src/assets/images/search/filter.svg
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
+    <title>filter-ios</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Artboard-4" transform="translate(-796.000000, -291.000000)">
+            <g id="98" transform="translate(796.000000, 291.000000)">
+                <circle id="Oval-23" stroke="#333333" stroke-width="2" cx="12" cy="12" r="10"></circle>
+                <rect id="Rectangle-44" fill="#333333" x="6" y="8" width="12" height="2" rx="1"></rect>
+                <rect id="Rectangle-44" fill="#333333" x="8" y="12" width="8" height="2" rx="1"></rect>
+                <rect id="Rectangle-44" fill="#333333" x="10" y="16" width="4" height="2" rx="1"></rect>
+            </g>
+        </g>
+    </g>
+</svg>
\ No newline at end of file
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 60c343867..6ac5e6a9e 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -28,5 +28,11 @@
       "stream": [ "./shims/noop" ],
       "crypto": [ "./shims/noop" ]
     }
-  }
+  },
+  "exclude": [
+    "../node_modules",
+    "node_modules",
+    "dist",
+    "../server"
+  ]
 }
diff --git a/server/helpers/custom-validators/search.ts b/server/helpers/custom-validators/search.ts
index 2fde39160..15b389a58 100644
--- a/server/helpers/custom-validators/search.ts
+++ b/server/helpers/custom-validators/search.ts
@@ -11,9 +11,14 @@ function isStringArray (value: any) {
   return isArray(value) && value.every(v => typeof v === 'string')
 }
 
+function isNSFWQueryValid (value: any) {
+  return value === 'true' || value === 'false' || value === 'both'
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   isNumberArray,
-  isStringArray
+  isStringArray,
+  isNSFWQueryValid
 }
diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts
index 5bf1e1a5f..76440348f 100644
--- a/server/helpers/express-utils.ts
+++ b/server/helpers/express-utils.ts
@@ -5,8 +5,10 @@ import { logger } from './logger'
 import { User } from '../../shared/models/users'
 import { generateRandomString } from './utils'
 
-function buildNSFWFilter (res: express.Response, paramNSFW?: boolean) {
-  if (paramNSFW === true || paramNSFW === false) return paramNSFW
+function buildNSFWFilter (res: express.Response, paramNSFW?: string) {
+  if (paramNSFW === 'true') return true
+  if (paramNSFW === 'false') return false
+  if (paramNSFW === 'both') return undefined
 
   if (res.locals.oauth) {
     const user: User = res.locals.oauth.token.User
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 045f41a96..d95e34bce 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -86,8 +86,6 @@ async function initDatabaseModels (silent: boolean) {
   // 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
diff --git a/server/middlewares/validators/search.ts b/server/middlewares/validators/search.ts
index fb2148eb3..a97f5b581 100644
--- a/server/middlewares/validators/search.ts
+++ b/server/middlewares/validators/search.ts
@@ -2,7 +2,7 @@ import * as express from 'express'
 import { areValidationErrors } from './utils'
 import { logger } from '../../helpers/logger'
 import { query } from 'express-validator/check'
-import { isNumberArray, isStringArray } from '../../helpers/custom-validators/search'
+import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search'
 import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc'
 
 const searchValidator = [
@@ -46,8 +46,7 @@ const commonVideosFiltersValidator = [
     .custom(isStringArray).withMessage('Should have a valid all of tags array'),
   query('nsfw')
     .optional()
-    .toBoolean()
-    .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
+    .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
 
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking commons video filters query', { parameters: req.query })
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 68116e309..b97dfd96f 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -851,7 +851,22 @@ export class VideoModel extends Model<VideoModel> {
       })
   }
 
-  static async searchAndPopulateAccountAndServer (options: VideosSearchQuery) {
+  static async searchAndPopulateAccountAndServer (options: {
+    search: string
+    start?: number
+    count?: number
+    sort?: string
+    startDate?: string // ISO 8601
+    endDate?: string // ISO 8601
+    nsfw?: boolean
+    categoryOneOf?: number[]
+    licenceOneOf?: number[]
+    languageOneOf?: string[]
+    tagsOneOf?: string[]
+    tagsAllOf?: string[]
+    durationMin?: number // seconds
+    durationMax?: number // seconds
+  }) {
     const whereAnd = [ ]
 
     if (options.startDate || options.endDate) {
diff --git a/server/tests/api/search/search-videos.ts b/server/tests/api/search/search-videos.ts
index 7fc133b46..d2b0f0312 100644
--- a/server/tests/api/search/search-videos.ts
+++ b/server/tests/api/search/search-videos.ts
@@ -216,7 +216,7 @@ describe('Test a videos search', function () {
       search: '1111 2222 3333',
       languageOneOf: [ 'pl', 'fr' ],
       durationMax: 4,
-      nsfw: false,
+      nsfw: 'false' as 'false',
       licenceOneOf: [ 1, 4 ]
     }
 
@@ -235,7 +235,7 @@ describe('Test a videos search', function () {
       search: '1111 2222 3333',
       languageOneOf: [ 'pl', 'fr' ],
       durationMax: 4,
-      nsfw: false,
+      nsfw: 'false' as 'false',
       licenceOneOf: [ 1, 4 ],
       sort: '-name'
     }
@@ -255,7 +255,7 @@ describe('Test a videos search', function () {
       search: '1111 2222 3333',
       languageOneOf: [ 'pl', 'fr' ],
       durationMax: 4,
-      nsfw: false,
+      nsfw: 'false' as 'false',
       licenceOneOf: [ 1, 4 ],
       sort: '-name',
       start: 0,
@@ -274,7 +274,7 @@ describe('Test a videos search', function () {
       search: '1111 2222 3333',
       languageOneOf: [ 'pl', 'fr' ],
       durationMax: 4,
-      nsfw: false,
+      nsfw: 'false' as 'false',
       licenceOneOf: [ 1, 4 ],
       sort: '-name',
       start: 3,
diff --git a/server/tests/api/videos/video-nsfw.ts b/server/tests/api/videos/video-nsfw.ts
index 38bdaa54e..370e69d2a 100644
--- a/server/tests/api/videos/video-nsfw.ts
+++ b/server/tests/api/videos/video-nsfw.ts
@@ -220,6 +220,17 @@ describe('Test video NSFW policy', function () {
         expect(videos[ 0 ].name).to.equal('normal')
       }
     })
+
+    it('Should display both videos when the nsfw param === both', async function () {
+      for (const res of await getVideosFunctions(server.accessToken, { nsfw: 'both' })) {
+        expect(res.body.total).to.equal(2)
+
+        const videos = res.body.data
+        expect(videos).to.have.lengthOf(2)
+        expect(videos[ 0 ].name).to.equal('normal')
+        expect(videos[ 1 ].name).to.equal('nsfw')
+      }
+    })
   })
 
   after(async function () {
diff --git a/shared/models/search/index.ts b/shared/models/search/index.ts
index 288ee41ef..928846c39 100644
--- a/shared/models/search/index.ts
+++ b/shared/models/search/index.ts
@@ -1 +1,2 @@
+export * from './nsfw-query.model'
 export * from './videos-search-query.model'
diff --git a/shared/models/search/nsfw-query.model.ts b/shared/models/search/nsfw-query.model.ts
new file mode 100644
index 000000000..6b6ad1991
--- /dev/null
+++ b/shared/models/search/nsfw-query.model.ts
@@ -0,0 +1 @@
+export type NSFWQuery = 'true' | 'false' | 'both'
diff --git a/shared/models/search/videos-search-query.model.ts b/shared/models/search/videos-search-query.model.ts
index bb23bd636..dc14b1177 100644
--- a/shared/models/search/videos-search-query.model.ts
+++ b/shared/models/search/videos-search-query.model.ts
@@ -1,3 +1,5 @@
+import { NSFWQuery } from './nsfw-query.model'
+
 export interface VideosSearchQuery {
   search: string
 
@@ -8,7 +10,7 @@ export interface VideosSearchQuery {
   startDate?: string // ISO 8601
   endDate?: string // ISO 8601
 
-  nsfw?: boolean
+  nsfw?: NSFWQuery
 
   categoryOneOf?: number[]
 
-- 
2.41.0