Begin advanced search
This commit is contained in:
parent
7279b45581
commit
57c36b277e
|
@ -29,12 +29,6 @@ before_script:
|
||||||
- cp ffmpeg-*-64bit-static/{ffmpeg,ffprobe,ffserver} $HOME/bin
|
- cp ffmpeg-*-64bit-static/{ffmpeg,ffprobe,ffserver} $HOME/bin
|
||||||
- export PATH=$HOME/bin:$PATH
|
- export PATH=$HOME/bin:$PATH
|
||||||
- export NODE_TEST_IMAGE=true
|
- 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
|
- psql -c "create user peertube with password 'peertube';" -U postgres
|
||||||
|
|
||||||
matrix:
|
matrix:
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { VideosModule } from './videos'
|
||||||
import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n'
|
import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n'
|
||||||
import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
|
import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
|
||||||
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
|
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
|
||||||
|
import { SearchModule } from '@app/search'
|
||||||
|
|
||||||
export function metaFactory (serverService: ServerService): MetaLoader {
|
export function metaFactory (serverService: ServerService): MetaLoader {
|
||||||
return new MetaStaticLoader({
|
return new MetaStaticLoader({
|
||||||
|
@ -52,6 +53,7 @@ export function metaFactory (serverService: ServerService): MetaLoader {
|
||||||
LoginModule,
|
LoginModule,
|
||||||
ResetPasswordModule,
|
ResetPasswordModule,
|
||||||
SignupModule,
|
SignupModule,
|
||||||
|
SearchModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
VideosModule,
|
VideosModule,
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ export class HeaderComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
doSearch () {
|
doSearch () {
|
||||||
this.router.navigate([ '/videos', 'search' ], {
|
this.router.navigate([ '/search' ], {
|
||||||
queryParams: { search: this.searchValue }
|
queryParams: { search: this.searchValue }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './search-routing.module'
|
||||||
|
export * from './search.component'
|
||||||
|
export * from './search.module'
|
|
@ -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 {}
|
|
@ -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>
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 { }
|
|
@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,9 +37,14 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
|
||||||
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
|
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
|
||||||
import {
|
import {
|
||||||
CustomConfigValidatorsService,
|
CustomConfigValidatorsService,
|
||||||
LoginValidatorsService, ReactiveFileComponent,
|
LoginValidatorsService,
|
||||||
|
ReactiveFileComponent,
|
||||||
ResetPasswordValidatorsService,
|
ResetPasswordValidatorsService,
|
||||||
UserValidatorsService, VideoAbuseValidatorsService, VideoChannelValidatorsService, VideoCommentValidatorsService, VideoValidatorsService
|
UserValidatorsService,
|
||||||
|
VideoAbuseValidatorsService,
|
||||||
|
VideoChannelValidatorsService,
|
||||||
|
VideoCommentValidatorsService,
|
||||||
|
VideoValidatorsService
|
||||||
} from '@app/shared/forms'
|
} from '@app/shared/forms'
|
||||||
import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
|
import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
|
||||||
import { ScreenService } from '@app/shared/misc/screen.service'
|
import { ScreenService } from '@app/shared/misc/screen.service'
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
<div class="video-miniature-information">
|
<div class="video-miniature-information">
|
||||||
<a
|
<a
|
||||||
class="video-miniature-name" alt=""
|
class="video-miniature-name"
|
||||||
[routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur() }"
|
[routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur() }"
|
||||||
>
|
>
|
||||||
{{ video.name }}
|
{{ video.name }}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
[routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name"
|
[routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name"
|
||||||
class="video-thumbnail"
|
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">
|
<div class="video-thumbnail-overlay">
|
||||||
{{ video.durationLabel }}
|
{{ video.durationLabel }}
|
||||||
|
|
|
@ -231,27 +231,6 @@ export class VideoService {
|
||||||
return this.buildBaseFeedUrls(params)
|
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) {
|
removeVideo (id: number) {
|
||||||
return this.authHttp
|
return this.authHttp
|
||||||
.delete(VideoService.BASE_VIDEO_URL + id)
|
.delete(VideoService.BASE_VIDEO_URL + id)
|
||||||
|
@ -289,21 +268,7 @@ export class VideoService {
|
||||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
private setVideoRate (id: number, rateType: VideoRateType) {
|
extractVideos (result: ResultList<VideoServerModel>) {
|
||||||
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>) {
|
|
||||||
return this.serverService.localeObservable
|
return this.serverService.localeObservable
|
||||||
.pipe(
|
.pipe(
|
||||||
map(translations => {
|
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))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
|
export * from './video-local.component'
|
||||||
export * from './video-recently-added.component'
|
export * from './video-recently-added.component'
|
||||||
export * from './video-trending.component'
|
export * from './video-trending.component'
|
||||||
export * from './video-search.component'
|
|
||||||
|
|
|
@ -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.')
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { NgModule } from '@angular/core'
|
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 { VideoLocalComponent } from '@app/videos/video-list/video-local.component'
|
||||||
import { MetaGuard } from '@ngx-meta/core'
|
import { MetaGuard } from '@ngx-meta/core'
|
||||||
import { VideoSearchComponent } from './video-list'
|
|
||||||
import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
|
import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
|
||||||
import { VideoTrendingComponent } from './video-list/video-trending.component'
|
import { VideoTrendingComponent } from './video-list/video-trending.component'
|
||||||
import { VideosComponent } from './videos.component'
|
import { VideosComponent } from './videos.component'
|
||||||
|
@ -45,15 +44,6 @@ const videosRoutes: Routes = [
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'search',
|
|
||||||
component: VideoSearchComponent,
|
|
||||||
data: {
|
|
||||||
meta: {
|
|
||||||
title: 'Search videos'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'upload',
|
path: 'upload',
|
||||||
loadChildren: 'app/videos/+video-edit/video-add.module#VideoAddModule',
|
loadChildren: 'app/videos/+video-edit/video-add.module#VideoAddModule',
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { VideoLocalComponent } from '@app/videos/video-list/video-local.component'
|
import { VideoLocalComponent } from '@app/videos/video-list/video-local.component'
|
||||||
import { SharedModule } from '../shared'
|
import { SharedModule } from '../shared'
|
||||||
import { VideoSearchComponent } from './video-list'
|
|
||||||
import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
|
import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
|
||||||
import { VideoTrendingComponent } from './video-list/video-trending.component'
|
import { VideoTrendingComponent } from './video-list/video-trending.component'
|
||||||
import { VideosRoutingModule } from './videos-routing.module'
|
import { VideosRoutingModule } from './videos-routing.module'
|
||||||
|
@ -18,8 +17,7 @@ import { VideosComponent } from './videos.component'
|
||||||
|
|
||||||
VideoTrendingComponent,
|
VideoTrendingComponent,
|
||||||
VideoRecentlyAddedComponent,
|
VideoRecentlyAddedComponent,
|
||||||
VideoLocalComponent,
|
VideoLocalComponent
|
||||||
VideoSearchComponent
|
|
||||||
],
|
],
|
||||||
|
|
||||||
exports: [
|
exports: [
|
||||||
|
|
|
@ -3,10 +3,14 @@
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
for i in $(seq 1 6); do
|
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 -rf "./test$i"
|
||||||
rm -f "./config/local-test.json"
|
rm -f "./config/local-test.json"
|
||||||
rm -f "./config/local-test-$i.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
|
redis-cli KEYS "bull-localhost:900$i*" | grep -v empty | xargs --no-run-if-empty redis-cli DEL
|
||||||
done
|
done
|
||||||
|
|
|
@ -49,7 +49,7 @@ if (errorMessage !== null) {
|
||||||
// Trust our proxy (IP forwarding...)
|
// Trust our proxy (IP forwarding...)
|
||||||
app.set('trust proxy', CONFIG.TRUST_PROXY)
|
app.set('trust proxy', CONFIG.TRUST_PROXY)
|
||||||
|
|
||||||
// Security middlewares
|
// Security middleware
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
frameguard: {
|
frameguard: {
|
||||||
action: 'deny' // we only allow it for /videos/embed, see server/controllers/client.ts
|
action: 'deny' // we only allow it for /videos/embed, see server/controllers/client.ts
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { videosRouter } from './videos'
|
||||||
import { badRequest } from '../../helpers/express-utils'
|
import { badRequest } from '../../helpers/express-utils'
|
||||||
import { videoChannelRouter } from './video-channel'
|
import { videoChannelRouter } from './video-channel'
|
||||||
import * as cors from 'cors'
|
import * as cors from 'cors'
|
||||||
|
import { searchRouter } from './search'
|
||||||
|
|
||||||
const apiRouter = express.Router()
|
const apiRouter = express.Router()
|
||||||
|
|
||||||
|
@ -26,6 +27,7 @@ apiRouter.use('/accounts', accountsRouter)
|
||||||
apiRouter.use('/video-channels', videoChannelRouter)
|
apiRouter.use('/video-channels', videoChannelRouter)
|
||||||
apiRouter.use('/videos', videosRouter)
|
apiRouter.use('/videos', videosRouter)
|
||||||
apiRouter.use('/jobs', jobsRouter)
|
apiRouter.use('/jobs', jobsRouter)
|
||||||
|
apiRouter.use('/search', searchRouter)
|
||||||
apiRouter.use('/ping', pong)
|
apiRouter.use('/ping', pong)
|
||||||
apiRouter.use('/*', badRequest)
|
apiRouter.use('/*', badRequest)
|
||||||
|
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
|
@ -38,7 +38,6 @@ import {
|
||||||
videosAddValidator,
|
videosAddValidator,
|
||||||
videosGetValidator,
|
videosGetValidator,
|
||||||
videosRemoveValidator,
|
videosRemoveValidator,
|
||||||
videosSearchValidator,
|
|
||||||
videosSortValidator,
|
videosSortValidator,
|
||||||
videosUpdateValidator
|
videosUpdateValidator
|
||||||
} from '../../../middlewares'
|
} from '../../../middlewares'
|
||||||
|
@ -50,7 +49,6 @@ import { blacklistRouter } from './blacklist'
|
||||||
import { videoCommentRouter } from './comment'
|
import { videoCommentRouter } from './comment'
|
||||||
import { rateVideoRouter } from './rate'
|
import { rateVideoRouter } from './rate'
|
||||||
import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
|
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 { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils'
|
||||||
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
|
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
|
||||||
import { videoCaptionsRouter } from './captions'
|
import { videoCaptionsRouter } from './captions'
|
||||||
|
@ -94,15 +92,6 @@ videosRouter.get('/',
|
||||||
optionalAuthenticate,
|
optionalAuthenticate,
|
||||||
asyncMiddleware(listVideos)
|
asyncMiddleware(listVideos)
|
||||||
)
|
)
|
||||||
videosRouter.get('/search',
|
|
||||||
videosSearchValidator,
|
|
||||||
paginationValidator,
|
|
||||||
videosSortValidator,
|
|
||||||
setDefaultSort,
|
|
||||||
setDefaultPagination,
|
|
||||||
optionalAuthenticate,
|
|
||||||
asyncMiddleware(searchVideos)
|
|
||||||
)
|
|
||||||
videosRouter.put('/:id',
|
videosRouter.put('/:id',
|
||||||
authenticate,
|
authenticate,
|
||||||
reqVideoFileUpdate,
|
reqVideoFileUpdate,
|
||||||
|
@ -432,15 +421,3 @@ async function removeVideo (req: express.Request, res: express.Response) {
|
||||||
|
|
||||||
return res.type('json').status(204).end()
|
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))
|
|
||||||
}
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { ACCEPT_HEADERS, STATIC_MAX_AGE } from '../initializers'
|
||||||
import { asyncMiddleware } from '../middlewares'
|
import { asyncMiddleware } from '../middlewares'
|
||||||
import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '../../shared/models/i18n/i18n'
|
import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '../../shared/models/i18n/i18n'
|
||||||
import { ClientHtml } from '../lib/client-html'
|
import { ClientHtml } from '../lib/client-html'
|
||||||
|
import { logger } from '../helpers/logger'
|
||||||
|
|
||||||
const clientsRouter = express.Router()
|
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)
|
// Always serve index client page (the client is a single page application, let it handle routing)
|
||||||
// Try to provide the right language index.html
|
// 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') {
|
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()
|
return res.status(404).end()
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as retry from 'async/retry'
|
import * as retry from 'async/retry'
|
||||||
import * as Bluebird from 'bluebird'
|
import * as Bluebird from 'bluebird'
|
||||||
import { Model } from 'sequelize-typescript'
|
import { Model, Sequelize } from 'sequelize-typescript'
|
||||||
import { logger } from './logger'
|
import { logger } from './logger'
|
||||||
|
|
||||||
function retryTransactionWrapper <T, A, B, C> (
|
function retryTransactionWrapper <T, A, B, C> (
|
||||||
|
|
|
@ -35,7 +35,9 @@ const SORTABLE_COLUMNS = {
|
||||||
VIDEO_COMMENT_THREADS: [ 'createdAt' ],
|
VIDEO_COMMENT_THREADS: [ 'createdAt' ],
|
||||||
BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
|
BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
|
||||||
FOLLOWERS: [ 'createdAt' ],
|
FOLLOWERS: [ 'createdAt' ],
|
||||||
FOLLOWING: [ 'createdAt' ]
|
FOLLOWING: [ 'createdAt' ],
|
||||||
|
|
||||||
|
VIDEOS_SEARCH: [ 'bestmatch', 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ]
|
||||||
}
|
}
|
||||||
|
|
||||||
const OAUTH_LIFETIME = {
|
const OAUTH_LIFETIME = {
|
||||||
|
|
|
@ -80,6 +80,14 @@ async function initDatabaseModels (silent: boolean) {
|
||||||
ScheduleVideoUpdateModel
|
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)
|
if (!silent) logger.info('Database %s is ready.', dbname)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
@ -91,3 +99,38 @@ export {
|
||||||
initDatabaseModels,
|
initDatabaseModels,
|
||||||
sequelizeTypescript
|
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 })
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,12 @@ function setDefaultSort (req: express.Request, res: express.Response, next: expr
|
||||||
return next()
|
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) {
|
function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
let newSort: SortType = { sortModel: undefined, sortValue: undefined }
|
let newSort: SortType = { sortModel: undefined, sortValue: undefined }
|
||||||
|
|
||||||
|
@ -33,5 +39,6 @@ function setBlacklistSort (req: express.Request, res: express.Response, next: ex
|
||||||
|
|
||||||
export {
|
export {
|
||||||
setDefaultSort,
|
setDefaultSort,
|
||||||
|
setDefaultSearchSort,
|
||||||
setBlacklistSort
|
setBlacklistSort
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,3 +10,4 @@ export * from './videos'
|
||||||
export * from './video-blacklist'
|
export * from './video-blacklist'
|
||||||
export * from './video-channels'
|
export * from './video-channels'
|
||||||
export * from './webfinger'
|
export * from './webfinger'
|
||||||
|
export * from './search'
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ const SORTABLE_ACCOUNTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNT
|
||||||
const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS)
|
const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS)
|
||||||
const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES)
|
const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES)
|
||||||
const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
|
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_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
|
||||||
const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS)
|
const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS)
|
||||||
const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS)
|
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 jobsSortValidator = checkSort(SORTABLE_JOBS_COLUMNS)
|
||||||
const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS)
|
const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS)
|
||||||
const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
|
const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
|
||||||
|
const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
|
||||||
const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
|
const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
|
||||||
const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS)
|
const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS)
|
||||||
const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
|
const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
|
||||||
|
@ -30,6 +32,7 @@ export {
|
||||||
usersSortValidator,
|
usersSortValidator,
|
||||||
videoAbusesSortValidator,
|
videoAbusesSortValidator,
|
||||||
videoChannelsSortValidator,
|
videoChannelsSortValidator,
|
||||||
|
videosSearchSortValidator,
|
||||||
videosSortValidator,
|
videosSortValidator,
|
||||||
blacklistSortValidator,
|
blacklistSortValidator,
|
||||||
accountsSortValidator,
|
accountsSortValidator,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as express from 'express'
|
import * as express from 'express'
|
||||||
import 'express-validator'
|
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 { UserRight, VideoPrivacy } from '../../../shared'
|
||||||
import {
|
import {
|
||||||
isBooleanValid,
|
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 = [
|
const videoAbuseReportValidator = [
|
||||||
param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
|
param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
|
||||||
body('reason').custom(isVideoAbuseReasonValid).withMessage('Should have a valid reason'),
|
body('reason').custom(isVideoAbuseReasonValid).withMessage('Should have a valid reason'),
|
||||||
|
@ -240,7 +228,6 @@ export {
|
||||||
videosUpdateValidator,
|
videosUpdateValidator,
|
||||||
videosGetValidator,
|
videosGetValidator,
|
||||||
videosRemoveValidator,
|
videosRemoveValidator,
|
||||||
videosSearchValidator,
|
|
||||||
videosShareValidator,
|
videosShareValidator,
|
||||||
|
|
||||||
videoAbuseReportValidator,
|
videoAbuseReportValidator,
|
||||||
|
|
|
@ -88,6 +88,12 @@ enum ScopeNames {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fields: [ 'inboxUrl', 'sharedInboxUrl' ]
|
fields: [ 'inboxUrl', 'sharedInboxUrl' ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: [ 'serverId' ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: [ 'avatarId' ]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
|
// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
|
||||||
|
import { Sequelize } from 'sequelize-typescript'
|
||||||
|
|
||||||
function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
|
function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
|
||||||
let field: string
|
let field: any
|
||||||
let direction: 'ASC' | 'DESC'
|
let direction: 'ASC' | 'DESC'
|
||||||
|
|
||||||
if (value.substring(0, 1) === '-') {
|
if (value.substring(0, 1) === '-') {
|
||||||
|
@ -11,6 +13,9 @@ function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
|
||||||
field = value
|
field = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alias
|
||||||
|
if (field.toLowerCase() === 'bestmatch') field = Sequelize.col('similarity')
|
||||||
|
|
||||||
return [ [ field, direction ], lastSort ]
|
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 {
|
export {
|
||||||
getSort,
|
getSort,
|
||||||
getSortOnModel,
|
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)))
|
||||||
}
|
}
|
||||||
|
|
|
@ -83,7 +83,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
|
||||||
import { ActorModel } from '../activitypub/actor'
|
import { ActorModel } from '../activitypub/actor'
|
||||||
import { AvatarModel } from '../avatar/avatar'
|
import { AvatarModel } from '../avatar/avatar'
|
||||||
import { ServerModel } from '../server/server'
|
import { ServerModel } from '../server/server'
|
||||||
import { getSort, throwIfNotValid } from '../utils'
|
import { buildTrigramSearchIndex, createSearchTrigramQuery, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
|
||||||
import { TagModel } from './tag'
|
import { TagModel } from './tag'
|
||||||
import { VideoAbuseModel } from './video-abuse'
|
import { VideoAbuseModel } from './video-abuse'
|
||||||
import { VideoChannelModel } from './video-channel'
|
import { VideoChannelModel } from './video-channel'
|
||||||
|
@ -94,6 +94,37 @@ import { VideoTagModel } from './video-tag'
|
||||||
import { ScheduleVideoUpdateModel } from './schedule-video-update'
|
import { ScheduleVideoUpdateModel } from './schedule-video-update'
|
||||||
import { VideoCaptionModel } from './video-caption'
|
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 {
|
export enum ScopeNames {
|
||||||
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
|
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
|
||||||
WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
|
WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
|
||||||
|
@ -309,36 +340,7 @@ export enum ScopeNames {
|
||||||
})
|
})
|
||||||
@Table({
|
@Table({
|
||||||
tableName: 'video',
|
tableName: 'video',
|
||||||
indexes: [
|
indexes
|
||||||
{
|
|
||||||
fields: [ 'name' ]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fields: [ 'createdAt' ]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fields: [ 'duration' ]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fields: [ 'views' ]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fields: [ 'likes' ]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fields: [ 'uuid' ]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fields: [ 'channelId' ]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fields: [ 'id', 'privacy', 'state', 'waitTranscoding' ]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fields: [ 'url'],
|
|
||||||
unique: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class VideoModel extends Model<VideoModel> {
|
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) {
|
static async searchAndPopulateAccountAndServer (value: string, start: number, count: number, sort: string, hideNSFW: boolean) {
|
||||||
const query: IFindOptions<VideoModel> = {
|
const query: IFindOptions<VideoModel> = {
|
||||||
|
attributes: {
|
||||||
|
include: [ createSimilarityAttribute('VideoModel.name', value) ]
|
||||||
|
},
|
||||||
offset: start,
|
offset: start,
|
||||||
limit: count,
|
limit: count,
|
||||||
order: getSort(sort),
|
order: getSort(sort),
|
||||||
where: {
|
where: createSearchTrigramQuery('VideoModel.name', value)
|
||||||
[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 + '%'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverActor = await getServerActor()
|
const serverActor = await getServerActor()
|
||||||
|
|
|
@ -248,9 +248,9 @@ function removeVideo (url: string, token: string, id: number | string, expectedS
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchVideo (url: string, search: string) {
|
function searchVideo (url: string, search: string) {
|
||||||
const path = '/api/v1/videos'
|
const path = '/api/v1/search/videos'
|
||||||
const req = request(url)
|
const req = request(url)
|
||||||
.get(path + '/search')
|
.get(path)
|
||||||
.query({ search })
|
.query({ search })
|
||||||
.set('Accept', 'application/json')
|
.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) {
|
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)
|
const req = request(url)
|
||||||
.get(path + '/search')
|
.get(path)
|
||||||
.query({ start })
|
.query({ start })
|
||||||
.query({ search })
|
.query({ search })
|
||||||
.query({ count })
|
.query({ count })
|
||||||
|
@ -287,10 +287,10 @@ function searchVideoWithPagination (url: string, search: string, start: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchVideoWithSort (url: string, search: string, sort: string) {
|
function searchVideoWithSort (url: string, search: string, sort: string) {
|
||||||
const path = '/api/v1/videos'
|
const path = '/api/v1/search/videos'
|
||||||
|
|
||||||
return request(url)
|
return request(url)
|
||||||
.get(path + '/search')
|
.get(path)
|
||||||
.query({ search })
|
.query({ search })
|
||||||
.query({ sort })
|
.query({ sort })
|
||||||
.set('Accept', 'application/json')
|
.set('Accept', 'application/json')
|
||||||
|
|
|
@ -53,6 +53,10 @@ $ docker-compose up
|
||||||
**Important**: note that you'll get the initial `root` user password from the
|
**Important**: note that you'll get the initial `root` user password from the
|
||||||
program output, so check out your logs to find them.
|
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
|
### Upgrade
|
||||||
|
|
||||||
Pull the latest images and rerun PeerTube:
|
Pull the latest images and rerun PeerTube:
|
||||||
|
|
|
@ -41,6 +41,13 @@ $ sudo -u postgres createuser -P peertube
|
||||||
$ sudo -u postgres createdb -O peertube peertube_prod
|
$ 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
|
### Prepare PeerTube directory
|
||||||
|
|
||||||
Fetch the latest tagged version of Peertube
|
Fetch the latest tagged version of Peertube
|
||||||
|
@ -194,7 +201,7 @@ Now your instance is up you can:
|
||||||
|
|
||||||
## Upgrade
|
## Upgrade
|
||||||
|
|
||||||
### PeerTube code
|
### PeerTube instance
|
||||||
|
|
||||||
**Check the changelog (in particular BREAKING CHANGES!):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md
|
**Check the changelog (in particular BREAKING CHANGES!):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue