--- /dev/null
+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
+ }
+ }
+}
--- /dev/null
+<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
--- /dev/null
+@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
--- /dev/null
+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()
+ }
+}
-<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">
@import '_mixins';
.no-result {
- height: 70vh;
+ height: 40vh;
display: flex;
align-items: center;
justify-content: center;
}
.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;
+ }
}
}
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'
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',
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
constructor (
private i18n: I18n,
private route: ActivatedRoute,
+ private router: Router,
private metaService: MetaService,
private redirectService: RedirectService,
private notificationsService: NotificationsService,
) { }
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']
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()
}
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)
this.search()
}
+ onFiltered () {
+ this.updateUrlFromAdvancedSearch()
+ // Hide the filters
+ this.isSearchFilterCollapsed = true
+
+ this.reload()
+ }
+
private reload () {
this.pagination.currentPage = 1
this.pagination.totalItems = null
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 })
+ })
+ }
}
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: [
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[] }
searchVideos (
search: string,
- componentPagination: ComponentPagination
+ componentPagination: ComponentPagination,
+ advancedSearch: AdvancedSearch
): Observable<{ videos: Video[], totalVideos: number }> {
const url = SearchService.BASE_SEARCH_URL + 'videos'
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(
--- /dev/null
+<?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
"stream": [ "./shims/noop" ],
"crypto": [ "./shims/noop" ]
}
- }
+ },
+ "exclude": [
+ "../node_modules",
+ "node_modules",
+ "dist",
+ "../server"
+ ]
}
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
}
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
// 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
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 = [
.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 })
})
}
- 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) {
search: '1111 2222 3333',
languageOneOf: [ 'pl', 'fr' ],
durationMax: 4,
- nsfw: false,
+ nsfw: 'false' as 'false',
licenceOneOf: [ 1, 4 ]
}
search: '1111 2222 3333',
languageOneOf: [ 'pl', 'fr' ],
durationMax: 4,
- nsfw: false,
+ nsfw: 'false' as 'false',
licenceOneOf: [ 1, 4 ],
sort: '-name'
}
search: '1111 2222 3333',
languageOneOf: [ 'pl', 'fr' ],
durationMax: 4,
- nsfw: false,
+ nsfw: 'false' as 'false',
licenceOneOf: [ 1, 4 ],
sort: '-name',
start: 0,
search: '1111 2222 3333',
languageOneOf: [ 'pl', 'fr' ],
durationMax: 4,
- nsfw: false,
+ nsfw: 'false' as 'false',
licenceOneOf: [ 1, 4 ],
sort: '-name',
start: 3,
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 () {
+export * from './nsfw-query.model'
export * from './videos-search-query.model'
--- /dev/null
+export type NSFWQuery = 'true' | 'false' | 'both'
+import { NSFWQuery } from './nsfw-query.model'
+
export interface VideosSearchQuery {
search: string
startDate?: string // ISO 8601
endDate?: string // ISO 8601
- nsfw?: boolean
+ nsfw?: NSFWQuery
categoryOneOf?: number[]