- cp ffmpeg-*-64bit-static/{ffmpeg,ffprobe,ffserver} $HOME/bin
- export PATH=$HOME/bin:$PATH
- export NODE_TEST_IMAGE=true
- - psql -c 'create database peertube_test1;' -U postgres
- - psql -c 'create database peertube_test2;' -U postgres
- - psql -c 'create database peertube_test3;' -U postgres
- - psql -c 'create database peertube_test4;' -U postgres
- - psql -c 'create database peertube_test5;' -U postgres
- - psql -c 'create database peertube_test6;' -U postgres
- psql -c "create user peertube with password 'peertube';" -U postgres
matrix:
import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n'
import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
+import { SearchModule } from '@app/search'
export function metaFactory (serverService: ServerService): MetaLoader {
return new MetaStaticLoader({
LoginModule,
ResetPasswordModule,
SignupModule,
+ SearchModule,
SharedModule,
VideosModule,
}
doSearch () {
- this.router.navigate([ '/videos', 'search' ], {
+ this.router.navigate([ '/search' ], {
queryParams: { search: this.searchValue }
})
}
--- /dev/null
+export * from './search-routing.module'
+export * from './search.component'
+export * from './search.module'
--- /dev/null
+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 {}
--- /dev/null
+<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>
--- /dev/null
+@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;
+ }
+ }
+ }
+}
--- /dev/null
+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)
+ }
+}
--- /dev/null
+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 { }
--- /dev/null
+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))
+ )
+ }
+}
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import {
CustomConfigValidatorsService,
- LoginValidatorsService, ReactiveFileComponent,
+ LoginValidatorsService,
+ ReactiveFileComponent,
ResetPasswordValidatorsService,
- UserValidatorsService, VideoAbuseValidatorsService, VideoChannelValidatorsService, VideoCommentValidatorsService, VideoValidatorsService
+ UserValidatorsService,
+ VideoAbuseValidatorsService,
+ VideoChannelValidatorsService,
+ VideoCommentValidatorsService,
+ VideoValidatorsService
} from '@app/shared/forms'
import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
import { ScreenService } from '@app/shared/misc/screen.service'
<div class="video-miniature-information">
<a
- class="video-miniature-name" alt=""
+ class="video-miniature-name"
[routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur() }"
>
{{ video.name }}
[routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name"
class="video-thumbnail"
>
-<img [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
+<img alt="" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
<div class="video-thumbnail-overlay">
{{ video.durationLabel }}
return this.buildBaseFeedUrls(params)
}
- searchVideos (
- search: string,
- videoPagination: ComponentPagination,
- sort: VideoSortField
- ): Observable<{ videos: Video[], totalVideos: number }> {
- const url = VideoService.BASE_VIDEO_URL + 'search'
-
- const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
- params = params.append('search', search)
-
- return this.authHttp
- .get<ResultList<VideoServerModel>>(url, { params })
- .pipe(
- switchMap(res => this.extractVideos(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
removeVideo (id: number) {
return this.authHttp
.delete(VideoService.BASE_VIDEO_URL + id)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
- private setVideoRate (id: number, rateType: VideoRateType) {
- const url = VideoService.BASE_VIDEO_URL + id + '/rate'
- const body: UserVideoRateUpdate = {
- rating: rateType
- }
-
- return this.authHttp
- .put(url, body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- private extractVideos (result: ResultList<VideoServerModel>) {
+ extractVideos (result: ResultList<VideoServerModel>) {
return this.serverService.localeObservable
.pipe(
map(translations => {
})
)
}
+
+ 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))
+ )
+ }
}
+export * from './video-local.component'
export * from './video-recently-added.component'
export * from './video-trending.component'
-export * from './video-search.component'
+++ /dev/null
-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.')
- }
-}
import { NgModule } from '@angular/core'
-import { RouterModule, Routes, UrlSegment } from '@angular/router'
+import { RouterModule, Routes } from '@angular/router'
import { VideoLocalComponent } from '@app/videos/video-list/video-local.component'
import { MetaGuard } from '@ngx-meta/core'
-import { VideoSearchComponent } from './video-list'
import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
import { VideoTrendingComponent } from './video-list/video-trending.component'
import { VideosComponent } from './videos.component'
}
}
},
- {
- path: 'search',
- component: VideoSearchComponent,
- data: {
- meta: {
- title: 'Search videos'
- }
- }
- },
{
path: 'upload',
loadChildren: 'app/videos/+video-edit/video-add.module#VideoAddModule',
import { NgModule } from '@angular/core'
import { VideoLocalComponent } from '@app/videos/video-list/video-local.component'
import { SharedModule } from '../shared'
-import { VideoSearchComponent } from './video-list'
import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
import { VideoTrendingComponent } from './video-list/video-trending.component'
import { VideosRoutingModule } from './videos-routing.module'
VideoTrendingComponent,
VideoRecentlyAddedComponent,
- VideoLocalComponent,
- VideoSearchComponent
+ VideoLocalComponent
],
exports: [
set -eu
for i in $(seq 1 6); do
- dropdb --if-exists "peertube_test$i"
+ dbname="peertube_test$i"
+
+ dropdb --if-exists "$dbname"
rm -rf "./test$i"
rm -f "./config/local-test.json"
rm -f "./config/local-test-$i.json"
- createdb -O peertube "peertube_test$i"
+ createdb -O peertube "$dbname"
+ psql -c "CREATE EXTENSION pg_trgm;" "$dbname"
+ psql -c "CREATE EXTENSION unaccent;" "$dbname"
redis-cli KEYS "bull-localhost:900$i*" | grep -v empty | xargs --no-run-if-empty redis-cli DEL
done
// Trust our proxy (IP forwarding...)
app.set('trust proxy', CONFIG.TRUST_PROXY)
-// Security middlewares
+// Security middleware
app.use(helmet({
frameguard: {
action: 'deny' // we only allow it for /videos/embed, see server/controllers/client.ts
import { badRequest } from '../../helpers/express-utils'
import { videoChannelRouter } from './video-channel'
import * as cors from 'cors'
+import { searchRouter } from './search'
const apiRouter = express.Router()
apiRouter.use('/video-channels', videoChannelRouter)
apiRouter.use('/videos', videosRouter)
apiRouter.use('/jobs', jobsRouter)
+apiRouter.use('/search', searchRouter)
apiRouter.use('/ping', pong)
apiRouter.use('/*', badRequest)
--- /dev/null
+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))
+}
videosAddValidator,
videosGetValidator,
videosRemoveValidator,
- videosSearchValidator,
videosSortValidator,
videosUpdateValidator
} from '../../../middlewares'
import { videoCommentRouter } from './comment'
import { rateVideoRouter } from './rate'
import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
-import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
import { videoCaptionsRouter } from './captions'
optionalAuthenticate,
asyncMiddleware(listVideos)
)
-videosRouter.get('/search',
- videosSearchValidator,
- paginationValidator,
- videosSortValidator,
- setDefaultSort,
- setDefaultPagination,
- optionalAuthenticate,
- asyncMiddleware(searchVideos)
-)
videosRouter.put('/:id',
authenticate,
reqVideoFileUpdate,
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))
-}
import { asyncMiddleware } from '../middlewares'
import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '../../shared/models/i18n/i18n'
import { ClientHtml } from '../lib/client-html'
+import { logger } from '../helpers/logger'
const clientsRouter = express.Router()
// Always serve index client page (the client is a single page application, let it handle routing)
// Try to provide the right language index.html
-clientsRouter.use('/(:language)?', function (req, res) {
+clientsRouter.use('/(:language)?', async function (req, res) {
if (req.accepts(ACCEPT_HEADERS) === 'html') {
- return generateHTMLPage(req, res, req.params.language)
+ try {
+ await generateHTMLPage(req, res, req.params.language)
+ return
+ } catch (err) {
+ logger.error('Cannot generate HTML page.', err)
+ }
}
return res.status(404).end()
import * as retry from 'async/retry'
import * as Bluebird from 'bluebird'
-import { Model } from 'sequelize-typescript'
+import { Model, Sequelize } from 'sequelize-typescript'
import { logger } from './logger'
function retryTransactionWrapper <T, A, B, C> (
VIDEO_COMMENT_THREADS: [ 'createdAt' ],
BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
FOLLOWERS: [ 'createdAt' ],
- FOLLOWING: [ 'createdAt' ]
+ FOLLOWING: [ 'createdAt' ],
+
+ VIDEOS_SEARCH: [ 'bestmatch', 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ]
}
const OAUTH_LIFETIME = {
ScheduleVideoUpdateModel
])
+ // Check extensions exist in the database
+ await checkPostgresExtensions()
+
+ // Create custom PostgreSQL functions
+ await createFunctions()
+
+ await sequelizeTypescript.query('CREATE EXTENSION IF NOT EXISTS pg_trgm', { raw: true })
+
if (!silent) logger.info('Database %s is ready.', dbname)
return
initDatabaseModels,
sequelizeTypescript
}
+
+// ---------------------------------------------------------------------------
+
+async function checkPostgresExtensions () {
+ const extensions = [
+ 'pg_trgm',
+ 'unaccent'
+ ]
+
+ for (const extension of extensions) {
+ const query = `SELECT true AS enabled FROM pg_available_extensions WHERE name = '${extension}' AND installed_version IS NOT NULL;`
+ const [ res ] = await sequelizeTypescript.query(query, { raw: true })
+
+ if (!res || res.length === 0 || res[ 0 ][ 'enabled' ] !== true) {
+ // Try to create the extension ourself
+ try {
+ await sequelizeTypescript.query(`CREATE EXTENSION ${extension};`, { raw: true })
+
+ } catch {
+ const errorMessage = `You need to enable ${extension} extension in PostgreSQL. ` +
+ `You can do so by running 'CREATE EXTENSION ${extension};' as a PostgreSQL super user in ${CONFIG.DATABASE.DBNAME} database.`
+ throw new Error(errorMessage)
+ }
+ }
+ }
+}
+
+async function createFunctions () {
+ const query = `CREATE OR REPLACE FUNCTION immutable_unaccent(varchar)
+ RETURNS text AS $$
+ SELECT unaccent($1)
+ $$ LANGUAGE sql IMMUTABLE;`
+
+ return sequelizeTypescript.query(query, { raw: true })
+}
return next()
}
+function setDefaultSearchSort (req: express.Request, res: express.Response, next: express.NextFunction) {
+ if (!req.query.sort) req.query.sort = '-bestmatch'
+
+ return next()
+}
+
function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) {
let newSort: SortType = { sortModel: undefined, sortValue: undefined }
export {
setDefaultSort,
+ setDefaultSearchSort,
setBlacklistSort
}
export * from './video-blacklist'
export * from './video-channels'
export * from './webfinger'
+export * from './search'
--- /dev/null
+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
+}
const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS)
const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES)
const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
+const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH)
const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS)
const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS)
const jobsSortValidator = checkSort(SORTABLE_JOBS_COLUMNS)
const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS)
const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
+const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS)
const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
usersSortValidator,
videoAbusesSortValidator,
videoChannelsSortValidator,
+ videosSearchSortValidator,
videosSortValidator,
blacklistSortValidator,
accountsSortValidator,
import * as express from 'express'
import 'express-validator'
-import { body, param, query, ValidationChain } from 'express-validator/check'
+import { body, param, ValidationChain } from 'express-validator/check'
import { UserRight, VideoPrivacy } from '../../../shared'
import {
isBooleanValid,
}
]
-const videosSearchValidator = [
- query('search').not().isEmpty().withMessage('Should have a valid search'),
-
- (req: express.Request, res: express.Response, next: express.NextFunction) => {
- logger.debug('Checking videosSearch parameters', { parameters: req.params })
-
- if (areValidationErrors(req, res)) return
-
- return next()
- }
-]
-
const videoAbuseReportValidator = [
param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
body('reason').custom(isVideoAbuseReasonValid).withMessage('Should have a valid reason'),
videosUpdateValidator,
videosGetValidator,
videosRemoveValidator,
- videosSearchValidator,
videosShareValidator,
videoAbuseReportValidator,
},
{
fields: [ 'inboxUrl', 'sharedInboxUrl' ]
+ },
+ {
+ fields: [ 'serverId' ]
+ },
+ {
+ fields: [ 'avatarId' ]
}
]
})
// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
+import { Sequelize } from 'sequelize-typescript'
+
function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
- let field: string
+ let field: any
let direction: 'ASC' | 'DESC'
if (value.substring(0, 1) === '-') {
field = value
}
+ // Alias
+ if (field.toLowerCase() === 'bestmatch') field = Sequelize.col('similarity')
+
return [ [ field, direction ], lastSort ]
}
}
}
+function buildTrigramSearchIndex (indexName: string, attribute: string) {
+ return {
+ name: indexName,
+ fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + '))') as any ],
+ using: 'gin',
+ operator: 'gin_trgm_ops'
+ }
+}
+
+function createSimilarityAttribute (col: string, value: string) {
+ return Sequelize.fn(
+ 'similarity',
+
+ searchTrigramNormalizeCol(col),
+
+ searchTrigramNormalizeValue(value)
+ )
+}
+
+function createSearchTrigramQuery (col: string, value: string) {
+ return {
+ [ Sequelize.Op.or ]: [
+ // FIXME: use word_similarity instead of just similarity?
+ Sequelize.where(searchTrigramNormalizeCol(col), ' % ', searchTrigramNormalizeValue(value)),
+
+ Sequelize.where(searchTrigramNormalizeCol(col), ' LIKE ', searchTrigramNormalizeValue(`%${value}%`))
+ ]
+ }
+}
+
// ---------------------------------------------------------------------------
export {
getSort,
getSortOnModel,
- throwIfNotValid
+ createSimilarityAttribute,
+ throwIfNotValid,
+ buildTrigramSearchIndex,
+ createSearchTrigramQuery
+}
+
+// ---------------------------------------------------------------------------
+
+function searchTrigramNormalizeValue (value: string) {
+ return Sequelize.fn('lower', Sequelize.fn('unaccent', value))
+}
+
+function searchTrigramNormalizeCol (col: string) {
+ return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
}
import { ActorModel } from '../activitypub/actor'
import { AvatarModel } from '../avatar/avatar'
import { ServerModel } from '../server/server'
-import { getSort, throwIfNotValid } from '../utils'
+import { buildTrigramSearchIndex, createSearchTrigramQuery, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
import { TagModel } from './tag'
import { VideoAbuseModel } from './video-abuse'
import { VideoChannelModel } from './video-channel'
import { ScheduleVideoUpdateModel } from './schedule-video-update'
import { VideoCaptionModel } from './video-caption'
+// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
+const indexes: Sequelize.DefineIndexesOptions[] = [
+ buildTrigramSearchIndex('video_name_trigram', 'name'),
+
+ {
+ fields: [ 'createdAt' ]
+ },
+ {
+ fields: [ 'duration' ]
+ },
+ {
+ fields: [ 'views' ]
+ },
+ {
+ fields: [ 'likes' ]
+ },
+ {
+ fields: [ 'uuid' ]
+ },
+ {
+ fields: [ 'channelId' ]
+ },
+ {
+ fields: [ 'id', 'privacy', 'state', 'waitTranscoding' ]
+ },
+ {
+ fields: [ 'url'],
+ unique: true
+ }
+]
+
export enum ScopeNames {
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
})
@Table({
tableName: 'video',
- indexes: [
- {
- fields: [ 'name' ]
- },
- {
- fields: [ 'createdAt' ]
- },
- {
- fields: [ 'duration' ]
- },
- {
- fields: [ 'views' ]
- },
- {
- fields: [ 'likes' ]
- },
- {
- fields: [ 'uuid' ]
- },
- {
- fields: [ 'channelId' ]
- },
- {
- fields: [ 'id', 'privacy', 'state', 'waitTranscoding' ]
- },
- {
- fields: [ 'url'],
- unique: true
- }
- ]
+ indexes
})
export class VideoModel extends Model<VideoModel> {
static async searchAndPopulateAccountAndServer (value: string, start: number, count: number, sort: string, hideNSFW: boolean) {
const query: IFindOptions<VideoModel> = {
+ attributes: {
+ include: [ createSimilarityAttribute('VideoModel.name', value) ]
+ },
offset: start,
limit: count,
order: getSort(sort),
- where: {
- [Sequelize.Op.or]: [
- {
- name: {
- [ Sequelize.Op.iLike ]: '%' + value + '%'
- }
- },
- {
- preferredUsernameChannel: Sequelize.where(Sequelize.col('VideoChannel->Actor.preferredUsername'), {
- [ Sequelize.Op.iLike ]: '%' + value + '%'
- })
- },
- {
- preferredUsernameAccount: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor.preferredUsername'), {
- [ Sequelize.Op.iLike ]: '%' + value + '%'
- })
- },
- {
- host: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor->Server.host'), {
- [ Sequelize.Op.iLike ]: '%' + value + '%'
- })
- }
- ]
- }
+ where: createSearchTrigramQuery('VideoModel.name', value)
}
const serverActor = await getServerActor()
}
function searchVideo (url: string, search: string) {
- const path = '/api/v1/videos'
+ const path = '/api/v1/search/videos'
const req = request(url)
- .get(path + '/search')
+ .get(path)
.query({ search })
.set('Accept', 'application/json')
}
function searchVideoWithPagination (url: string, search: string, start: number, count: number, sort?: string) {
- const path = '/api/v1/videos'
+ const path = '/api/v1/search/videos'
const req = request(url)
- .get(path + '/search')
+ .get(path)
.query({ start })
.query({ search })
.query({ count })
}
function searchVideoWithSort (url: string, search: string, sort: string) {
- const path = '/api/v1/videos'
+ const path = '/api/v1/search/videos'
return request(url)
- .get(path + '/search')
+ .get(path)
.query({ search })
.query({ sort })
.set('Accept', 'application/json')
**Important**: note that you'll get the initial `root` user password from the
program output, so check out your logs to find them.
+### What now?
+
+See the production guide ["What now" section](/support/doc/production.md#what-now).
+
### Upgrade
Pull the latest images and rerun PeerTube:
$ sudo -u postgres createdb -O peertube peertube_prod
```
+Then enable extensions PeerTube needs:
+
+```
+$ sudo -u postgres psql -c "CREATE EXTENSION pg_trgm;" peertube_prod
+$ sudo -u postgres psql -c "CREATE EXTENSION unaccent;" peertube_prod
+```
+
### Prepare PeerTube directory
Fetch the latest tagged version of Peertube
## Upgrade
-### PeerTube code
+### PeerTube instance
**Check the changelog (in particular BREAKING CHANGES!):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md