Add ability to search playlists

This commit is contained in:
Chocobozzz 2021-06-17 16:02:38 +02:00 committed by Chocobozzz
parent 33eb19e519
commit 37a44fc915
79 changed files with 1652 additions and 549 deletions

View File

@ -187,7 +187,7 @@ export class MyVideoPlaylistElementsComponent implements OnInit, OnDestroy {
// Reload playlist thumbnail if the first element changed // Reload playlist thumbnail if the first element changed
const newFirst = this.findFirst() const newFirst = this.findFirst()
if (oldFirst && newFirst && oldFirst.id !== newFirst.id) { if (oldFirst && newFirst && oldFirst.id !== newFirst.id) {
this.playlist.refreshThumbnail() this.loadPlaylistInfo()
} }
} }

View File

@ -1,35 +0,0 @@
import { map } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
import { SearchService } from '@app/shared/shared-search'
@Injectable()
export class ChannelLazyLoadResolver implements Resolve<any> {
constructor (
private router: Router,
private searchService: SearchService
) { }
resolve (route: ActivatedRouteSnapshot) {
const url = route.params.url
if (!url) {
console.error('Could not find url param.', { params: route.params })
return this.router.navigateByUrl('/404')
}
return this.searchService.searchVideoChannels({ search: url })
.pipe(
map(result => {
if (result.data.length !== 1) {
console.error('Cannot find result for this URL')
return this.router.navigateByUrl('/404')
}
const channel = result.data[0]
return this.router.navigateByUrl('/video-channels/' + channel.nameWithHost)
})
)
}
}

View File

@ -1,8 +1,7 @@
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router' import { RouterModule, Routes } from '@angular/router'
import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
import { SearchComponent } from './search.component' import { SearchComponent } from './search.component'
import { VideoLazyLoadResolver } from './video-lazy-load.resolver' import { ChannelLazyLoadResolver, PlaylistLazyLoadResolver, VideoLazyLoadResolver } from './shared'
const searchRoutes: Routes = [ const searchRoutes: Routes = [
{ {
@ -27,6 +26,13 @@ const searchRoutes: Routes = [
resolve: { resolve: {
data: ChannelLazyLoadResolver data: ChannelLazyLoadResolver
} }
},
{
path: 'lazy-load-playlist',
component: SearchComponent,
resolve: {
data: PlaylistLazyLoadResolver
}
} }
] ]

View File

@ -59,10 +59,17 @@
<div *ngIf="isVideo(result)" class="entry video"> <div *ngIf="isVideo(result)" class="entry video">
<my-video-miniature <my-video-miniature
[video]="result" [user]="userMiniature" [displayAsRow]="true" [displayVideoActions]="!hideActions()" [video]="result" [user]="userMiniature" [displayAsRow]="true" [displayVideoActions]="!hideActions()"
[displayOptions]="videoDisplayOptions" [videoLinkType]="getVideoLinkType()" [displayOptions]="videoDisplayOptions" [videoLinkType]="getLinkType()"
(videoBlocked)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)" (videoBlocked)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)"
></my-video-miniature> ></my-video-miniature>
</div> </div>
<div *ngIf="isPlaylist(result)" class="entry video-playlist">
<my-video-playlist-miniature
[playlist]="result" [displayAsRow]="true" [displayChannel]="true"
[linkType]="getLinkType()"
></my-video-playlist-miniature>
</div>
</ng-container> </ng-container>
</div> </div>

View File

@ -1,11 +1,13 @@
import { forkJoin, of, Subscription } from 'rxjs' import { forkJoin, of, Subscription } from 'rxjs'
import { LinkType } from 'src/types/link.type'
import { Component, OnDestroy, OnInit } from '@angular/core' import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, ComponentPagination, HooksService, MetaService, Notifier, ServerService, User, UserService } from '@app/core' import { AuthService, HooksService, MetaService, Notifier, ServerService, User, UserService } from '@app/core'
import { immutableAssign } from '@app/helpers' import { immutableAssign } from '@app/helpers'
import { Video, VideoChannel } from '@app/shared/shared-main' import { Video, VideoChannel } from '@app/shared/shared-main'
import { AdvancedSearch, SearchService } from '@app/shared/shared-search' import { AdvancedSearch, SearchService } from '@app/shared/shared-search'
import { MiniatureDisplayOptions, VideoLinkType } from '@app/shared/shared-video-miniature' import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
import { VideoPlaylist } from '@app/shared/shared-video-playlist'
import { HTMLServerConfig, SearchTargetType } from '@shared/models' import { HTMLServerConfig, SearchTargetType } from '@shared/models'
@Component({ @Component({
@ -16,10 +18,9 @@ import { HTMLServerConfig, SearchTargetType } from '@shared/models'
export class SearchComponent implements OnInit, OnDestroy { export class SearchComponent implements OnInit, OnDestroy {
results: (Video | VideoChannel)[] = [] results: (Video | VideoChannel)[] = []
pagination: ComponentPagination = { pagination = {
currentPage: 1, currentPage: 1,
itemsPerPage: 10, // Only for videos, use another variable for channels totalItems: null as number
totalItems: null
} }
advancedSearch: AdvancedSearch = new AdvancedSearch() advancedSearch: AdvancedSearch = new AdvancedSearch()
isSearchFilterCollapsed = true isSearchFilterCollapsed = true
@ -45,6 +46,11 @@ export class SearchComponent implements OnInit, OnDestroy {
private firstSearch = true private firstSearch = true
private channelsPerPage = 2 private channelsPerPage = 2
private playlistsPerPage = 2
private videosPerPage = 10
private hasMoreResults = true
private isSearching = false
private lastSearchTarget: SearchTargetType private lastSearchTarget: SearchTargetType
@ -104,77 +110,62 @@ export class SearchComponent implements OnInit, OnDestroy {
if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe() if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
} }
isVideoChannel (d: VideoChannel | Video): d is VideoChannel { isVideoChannel (d: VideoChannel | Video | VideoPlaylist): d is VideoChannel {
return d instanceof VideoChannel return d instanceof VideoChannel
} }
isVideo (v: VideoChannel | Video): v is Video { isVideo (v: VideoChannel | Video | VideoPlaylist): v is Video {
return v instanceof Video return v instanceof Video
} }
isPlaylist (v: VideoChannel | Video | VideoPlaylist): v is VideoPlaylist {
return v instanceof VideoPlaylist
}
isUserLoggedIn () { isUserLoggedIn () {
return this.authService.isLoggedIn() return this.authService.isLoggedIn()
} }
getVideoLinkType (): VideoLinkType {
if (this.advancedSearch.searchTarget === 'search-index') {
const remoteUriConfig = this.serverConfig.search.remoteUri
// Redirect on the external instance if not allowed to fetch remote data
if ((!this.isUserLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users) {
return 'external'
}
return 'lazy-load'
}
return 'internal'
}
search () { search () {
this.isSearching = true
forkJoin([ forkJoin([
this.getVideosObs(), this.getVideoChannelObs(),
this.getVideoChannelObs() this.getVideoPlaylistObs(),
]).subscribe( this.getVideosObs()
([videosResult, videoChannelsResult]) => { ]).subscribe(results => {
this.results = this.results for (const result of results) {
.concat(videoChannelsResult.data) this.results = this.results.concat(result.data)
.concat(videosResult.data)
this.pagination.totalItems = videosResult.total + videoChannelsResult.total
this.lastSearchTarget = this.advancedSearch.searchTarget
// Focus on channels if there are no enough videos
if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
this.resetPagination()
this.firstSearch = false
this.channelsPerPage = 10
this.search()
}
this.firstSearch = false
},
err => {
if (this.advancedSearch.searchTarget !== 'search-index') {
this.notifier.error(err.message)
return
}
this.notifier.error(
$localize`Search index is unavailable. Retrying with instance results instead.`,
$localize`Search error`
)
this.advancedSearch.searchTarget = 'local'
this.search()
} }
)
this.pagination.totalItems = results.reduce((p, r) => p += r.total, 0)
this.lastSearchTarget = this.advancedSearch.searchTarget
this.hasMoreResults = this.results.length < this.pagination.totalItems
},
err => {
if (this.advancedSearch.searchTarget !== 'search-index') {
this.notifier.error(err.message)
return
}
this.notifier.error(
$localize`Search index is unavailable. Retrying with instance results instead.`,
$localize`Search error`
)
this.advancedSearch.searchTarget = 'local'
this.search()
},
() => {
this.isSearching = false
})
} }
onNearOfBottom () { onNearOfBottom () {
// Last page // Last page
if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return if (!this.hasMoreResults || this.isSearching) return
this.pagination.currentPage += 1 this.pagination.currentPage += 1
this.search() this.search()
@ -190,18 +181,33 @@ export class SearchComponent implements OnInit, OnDestroy {
return this.advancedSearch.size() return this.advancedSearch.size()
} }
// Add VideoChannel for typings, but the template already checks "video" argument is a video // Add VideoChannel/VideoPlaylist for typings, but the template already checks "video" argument is a video
removeVideoFromArray (video: Video | VideoChannel) { removeVideoFromArray (video: Video | VideoChannel | VideoPlaylist) {
this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id) this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
} }
getLinkType (): LinkType {
if (this.advancedSearch.searchTarget === 'search-index') {
const remoteUriConfig = this.serverConfig.search.remoteUri
// Redirect on the external instance if not allowed to fetch remote data
if ((!this.isUserLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users) {
return 'external'
}
return 'lazy-load'
}
return 'internal'
}
isExternalChannelUrl () { isExternalChannelUrl () {
return this.getVideoLinkType() === 'external' return this.getLinkType() === 'external'
} }
getExternalChannelUrl (channel: VideoChannel) { getExternalChannelUrl (channel: VideoChannel) {
// Same algorithm than videos // Same algorithm than videos
if (this.getVideoLinkType() === 'external') { if (this.getLinkType() === 'external') {
return channel.url return channel.url
} }
@ -210,7 +216,7 @@ export class SearchComponent implements OnInit, OnDestroy {
} }
getInternalChannelUrl (channel: VideoChannel) { getInternalChannelUrl (channel: VideoChannel) {
const linkType = this.getVideoLinkType() const linkType = this.getLinkType()
if (linkType === 'internal') { if (linkType === 'internal') {
return [ '/c', channel.nameWithHost ] return [ '/c', channel.nameWithHost ]
@ -256,7 +262,7 @@ export class SearchComponent implements OnInit, OnDestroy {
private getVideosObs () { private getVideosObs () {
const params = { const params = {
search: this.currentSearch, search: this.currentSearch,
componentPagination: this.pagination, componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.videosPerPage }),
advancedSearch: this.advancedSearch advancedSearch: this.advancedSearch
} }
@ -287,6 +293,24 @@ export class SearchComponent implements OnInit, OnDestroy {
) )
} }
private getVideoPlaylistObs () {
if (!this.currentSearch) return of({ data: [], total: 0 })
const params = {
search: this.currentSearch,
componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.playlistsPerPage }),
searchTarget: this.advancedSearch.searchTarget
}
return this.hooks.wrapObsFun(
this.searchService.searchVideoPlaylists.bind(this.searchService),
params,
'search',
'filter:api.search.video-playlists.list.params',
'filter:api.search.video-playlists.list.result'
)
}
private getDefaultSearchTarget (): SearchTargetType { private getDefaultSearchTarget (): SearchTargetType {
const searchIndexConfig = this.serverConfig.search.searchIndex const searchIndexConfig = this.serverConfig.search.searchIndex

View File

@ -5,12 +5,12 @@ import { SharedMainModule } from '@app/shared/shared-main'
import { SharedSearchModule } from '@app/shared/shared-search' import { SharedSearchModule } from '@app/shared/shared-search'
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
import { SearchService } from '../shared/shared-search/search.service' import { SearchService } from '../shared/shared-search/search.service'
import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
import { SearchFiltersComponent } from './search-filters.component' import { SearchFiltersComponent } from './search-filters.component'
import { SearchRoutingModule } from './search-routing.module' import { SearchRoutingModule } from './search-routing.module'
import { SearchComponent } from './search.component' import { SearchComponent } from './search.component'
import { VideoLazyLoadResolver } from './video-lazy-load.resolver' import { ChannelLazyLoadResolver, PlaylistLazyLoadResolver, VideoLazyLoadResolver } from './shared'
@NgModule({ @NgModule({
imports: [ imports: [
@ -21,7 +21,8 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
SharedFormModule, SharedFormModule,
SharedActorImageModule, SharedActorImageModule,
SharedUserSubscriptionModule, SharedUserSubscriptionModule,
SharedVideoMiniatureModule SharedVideoMiniatureModule,
SharedVideoPlaylistModule
], ],
declarations: [ declarations: [
@ -36,7 +37,8 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
providers: [ providers: [
SearchService, SearchService,
VideoLazyLoadResolver, VideoLazyLoadResolver,
ChannelLazyLoadResolver ChannelLazyLoadResolver,
PlaylistLazyLoadResolver
] ]
}) })
export class SearchModule { } export class SearchModule { }

View File

@ -1,14 +1,10 @@
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators' import { map } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router' import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
import { SearchService } from '@app/shared/shared-search' import { ResultList } from '@shared/models/result-list.model'
@Injectable() export abstract class AbstractLazyLoadResolver <T> implements Resolve<any> {
export class VideoLazyLoadResolver implements Resolve<any> { protected router: Router
constructor (
private router: Router,
private searchService: SearchService
) { }
resolve (route: ActivatedRouteSnapshot) { resolve (route: ActivatedRouteSnapshot) {
const url = route.params.url const url = route.params.url
@ -18,7 +14,7 @@ export class VideoLazyLoadResolver implements Resolve<any> {
return this.router.navigateByUrl('/404') return this.router.navigateByUrl('/404')
} }
return this.searchService.searchVideos({ search: url }) return this.finder(url)
.pipe( .pipe(
map(result => { map(result => {
if (result.data.length !== 1) { if (result.data.length !== 1) {
@ -26,10 +22,13 @@ export class VideoLazyLoadResolver implements Resolve<any> {
return this.router.navigateByUrl('/404') return this.router.navigateByUrl('/404')
} }
const video = result.data[0] const redirectUrl = this.buildUrl(result.data[0])
return this.router.navigateByUrl('/w/' + video.uuid) return this.router.navigateByUrl(redirectUrl)
}) })
) )
} }
protected abstract finder (url: string): Observable<ResultList<T>>
protected abstract buildUrl (e: T): string
} }

View File

@ -0,0 +1,24 @@
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { VideoChannel } from '@app/shared/shared-main'
import { SearchService } from '@app/shared/shared-search'
import { AbstractLazyLoadResolver } from './abstract-lazy-load.resolver'
@Injectable()
export class ChannelLazyLoadResolver extends AbstractLazyLoadResolver<VideoChannel> {
constructor (
protected router: Router,
private searchService: SearchService
) {
super()
}
protected finder (url: string) {
return this.searchService.searchVideoChannels({ search: url })
}
protected buildUrl (channel: VideoChannel) {
return '/video-channels/' + channel.nameWithHost
}
}

View File

@ -0,0 +1,4 @@
export * from './abstract-lazy-load.resolver'
export * from './channel-lazy-load.resolver'
export * from './playlist-lazy-load.resolver'
export * from './video-lazy-load.resolver'

View File

@ -0,0 +1,24 @@
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { SearchService } from '@app/shared/shared-search'
import { VideoPlaylist } from '@app/shared/shared-video-playlist'
import { AbstractLazyLoadResolver } from './abstract-lazy-load.resolver'
@Injectable()
export class PlaylistLazyLoadResolver extends AbstractLazyLoadResolver<VideoPlaylist> {
constructor (
protected router: Router,
private searchService: SearchService
) {
super()
}
protected finder (url: string) {
return this.searchService.searchVideoPlaylists({ search: url })
}
protected buildUrl (playlist: VideoPlaylist) {
return '/w/p/' + playlist.uuid
}
}

View File

@ -0,0 +1,24 @@
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { Video } from '@app/shared/shared-main'
import { SearchService } from '@app/shared/shared-search'
import { AbstractLazyLoadResolver } from './abstract-lazy-load.resolver'
@Injectable()
export class VideoLazyLoadResolver extends AbstractLazyLoadResolver<Video> {
constructor (
protected router: Router,
private searchService: SearchService
) {
super()
}
protected finder (url: string) {
return this.searchService.searchVideos({ search: url })
}
protected buildUrl (video: Video) {
return '/w/' + video.uuid
}
}

View File

@ -1,6 +1,6 @@
<div class="d-inline-flex position-relative" id="typeahead-container"> <div class="d-inline-flex position-relative" id="typeahead-container">
<input <input
type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…" type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, playlists, channels…"
[(ngModel)]="search" (ngModelChange)="onSearchChange()" (keydown)="handleKey($event)" (keydown.enter)="doSearch()" [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keydown)="handleKey($event)" (keydown.enter)="doSearch()"
aria-label="Search" autocomplete="off" aria-label="Search" autocomplete="off"
> >

View File

@ -3,5 +3,6 @@ export * from './bytes.pipe'
export * from './duration-formatter.pipe' export * from './duration-formatter.pipe'
export * from './from-now.pipe' export * from './from-now.pipe'
export * from './infinite-scroller.directive' export * from './infinite-scroller.directive'
export * from './link.component'
export * from './number-formatter.pipe' export * from './number-formatter.pipe'
export * from './peertube-template.directive' export * from './peertube-template.directive'

View File

@ -0,0 +1,11 @@
<ng-template #content>
<ng-content></ng-content>
</ng-template>
<a *ngIf="!href" [routerLink]="internalLink" [attr.title]="title" [tabindex]="tabindex">
<ng-template *ngTemplateOutlet="content"></ng-template>
</a>
<a *ngIf="href" [href]="href" [target]="target" [attr.title]="title" [tabindex]="tabindex">
<ng-template *ngTemplateOutlet="content"></ng-template>
</a>

View File

@ -0,0 +1,7 @@
a {
color: inherit;
text-decoration: inherit;
position: inherit;
width: inherit;
height: inherit;
}

View File

@ -0,0 +1,17 @@
import { Component, Input, ViewEncapsulation } from '@angular/core'
@Component({
selector: 'my-link',
styleUrls: [ './link.component.scss' ],
templateUrl: './link.component.html'
})
export class LinkComponent {
@Input() internalLink?: any[]
@Input() href?: string
@Input() target?: string
@Input() title?: string
@Input() tabindex: string | number
}

View File

@ -4,7 +4,7 @@ import { CommonModule, DatePipe } from '@angular/common'
import { HttpClientModule } from '@angular/common/http' import { HttpClientModule } from '@angular/common/http'
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRouteSnapshot, RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
import { import {
NgbButtonsModule, NgbButtonsModule,
NgbCollapseModule, NgbCollapseModule,
@ -24,6 +24,7 @@ import {
DurationFormatterPipe, DurationFormatterPipe,
FromNowPipe, FromNowPipe,
InfiniteScrollerDirective, InfiniteScrollerDirective,
LinkComponent,
NumberFormatterPipe, NumberFormatterPipe,
PeerTubeTemplateDirective PeerTubeTemplateDirective
} from './angular' } from './angular'
@ -35,11 +36,11 @@ import { FeedComponent } from './feeds'
import { LoaderComponent, SmallLoaderComponent } from './loaders' import { LoaderComponent, SmallLoaderComponent } from './loaders'
import { HelpComponent, ListOverflowComponent, SimpleSearchInputComponent, TopMenuDropdownComponent } from './misc' import { HelpComponent, ListOverflowComponent, SimpleSearchInputComponent, TopMenuDropdownComponent } from './misc'
import { PluginPlaceholderComponent } from './plugins' import { PluginPlaceholderComponent } from './plugins'
import { ActorRedirectGuard } from './router'
import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video' import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
import { VideoCaptionService } from './video-caption' import { VideoCaptionService } from './video-caption'
import { VideoChannelService } from './video-channel' import { VideoChannelService } from './video-channel'
import { ActorRedirectGuard } from './router'
@NgModule({ @NgModule({
imports: [ imports: [
@ -76,6 +77,7 @@ import { ActorRedirectGuard } from './router'
InfiniteScrollerDirective, InfiniteScrollerDirective,
PeerTubeTemplateDirective, PeerTubeTemplateDirective,
LinkComponent,
ActionDropdownComponent, ActionDropdownComponent,
ButtonComponent, ButtonComponent,
@ -130,6 +132,7 @@ import { ActorRedirectGuard } from './router'
InfiniteScrollerDirective, InfiniteScrollerDirective,
PeerTubeTemplateDirective, PeerTubeTemplateDirective,
LinkComponent,
ActionDropdownComponent, ActionDropdownComponent,
ButtonComponent, ButtonComponent,

View File

@ -3,10 +3,17 @@ import { catchError, map, switchMap } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http' import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core' import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
import { ResultList, SearchTargetType, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '@shared/models' import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import {
ResultList,
SearchTargetType,
Video as VideoServerModel,
VideoChannel as VideoChannelServerModel,
VideoPlaylist as VideoPlaylistServerModel
} from '@shared/models'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { VideoPlaylist, VideoPlaylistService } from '../shared-video-playlist'
import { AdvancedSearch } from './advanced-search.model' import { AdvancedSearch } from './advanced-search.model'
@Injectable() @Injectable()
@ -17,7 +24,8 @@ export class SearchService {
private authHttp: HttpClient, private authHttp: HttpClient,
private restExtractor: RestExtractor, private restExtractor: RestExtractor,
private restService: RestService, private restService: RestService,
private videoService: VideoService private videoService: VideoService,
private playlistService: VideoPlaylistService
) { ) {
// Add ability to override search endpoint if the user updated this local storage key // Add ability to override search endpoint if the user updated this local storage key
const searchUrl = peertubeLocalStorage.getItem('search-url') const searchUrl = peertubeLocalStorage.getItem('search-url')
@ -85,4 +93,34 @@ export class SearchService {
catchError(err => this.restExtractor.handleError(err)) catchError(err => this.restExtractor.handleError(err))
) )
} }
searchVideoPlaylists (parameters: {
search: string,
searchTarget?: SearchTargetType,
componentPagination?: ComponentPaginationLight
}): Observable<ResultList<VideoPlaylist>> {
const { search, componentPagination, searchTarget } = parameters
const url = SearchService.BASE_SEARCH_URL + 'video-playlists'
let pagination: RestPagination
if (componentPagination) {
pagination = this.restService.componentPaginationToRestPagination(componentPagination)
}
let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination)
params = params.append('search', search)
if (searchTarget) {
params = params.append('searchTarget', searchTarget as string)
}
return this.authHttp
.get<ResultList<VideoPlaylistServerModel>>(url, { params })
.pipe(
switchMap(res => this.playlistService.extractPlaylists(res)),
catchError(err => this.restExtractor.handleError(err))
)
}
} }

View File

@ -21,13 +21,12 @@
></my-actor-avatar> ></my-actor-avatar>
<div class="w-100 d-flex flex-column"> <div class="w-100 d-flex flex-column">
<a *ngIf="!videoHref" tabindex="-1" class="video-miniature-name" <my-link
[routerLink]="videoRouterLink" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" [internalLink]="videoRouterLink" [href]="videoHref" [target]="videoTarget"
>{{ video.name }}</a> [title]="video.name"class="video-miniature-name" [ngClass]="{ 'blur-filter': isVideoBlur }" tabindex="-1"
>
<a *ngIf="videoHref" tabindex="-1" class="video-miniature-name" {{ video.name }}
[href]="videoHref" [target]="videoTarget" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" </my-link>
>{{ video.name }}</a>
<span class="video-miniature-created-at-views"> <span class="video-miniature-created-at-views">
<my-date-toggle *ngIf="displayOptions.date" [date]="video.publishedAt"></my-date-toggle> <my-date-toggle *ngIf="displayOptions.date" [date]="video.publishedAt"></my-date-toggle>

View File

@ -12,6 +12,7 @@ import {
} from '@angular/core' } from '@angular/core'
import { AuthService, ScreenService, ServerService, User } from '@app/core' import { AuthService, ScreenService, ServerService, User } from '@app/core'
import { HTMLServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '@shared/models' import { HTMLServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '@shared/models'
import { LinkType } from '../../../types/link.type'
import { ActorAvatarSize } from '../shared-actor-image/actor-avatar.component' import { ActorAvatarSize } from '../shared-actor-image/actor-avatar.component'
import { Video } from '../shared-main' import { Video } from '../shared-main'
import { VideoPlaylistService } from '../shared-video-playlist' import { VideoPlaylistService } from '../shared-video-playlist'
@ -28,8 +29,6 @@ export type MiniatureDisplayOptions = {
blacklistInfo?: boolean blacklistInfo?: boolean
nsfw?: boolean nsfw?: boolean
} }
export type VideoLinkType = 'internal' | 'lazy-load' | 'external'
@Component({ @Component({
selector: 'my-video-miniature', selector: 'my-video-miniature',
styleUrls: [ './video-miniature.component.scss' ], styleUrls: [ './video-miniature.component.scss' ],
@ -56,7 +55,7 @@ export class VideoMiniatureComponent implements OnInit {
@Input() displayAsRow = false @Input() displayAsRow = false
@Input() videoLinkType: VideoLinkType = 'internal' @Input() videoLinkType: LinkType = 'internal'
@Output() videoBlocked = new EventEmitter() @Output() videoBlocked = new EventEmitter()
@Output() videoUnblocked = new EventEmitter() @Output() videoUnblocked = new EventEmitter()

View File

@ -1,7 +1,7 @@
<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage, 'display-as-row': displayAsRow }"> <div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage, 'display-as-row': displayAsRow }">
<a <my-link
[routerLink]="getPlaylistUrl()" [attr.title]="playlist.description" [internalLink]="routerLink" [href]="playlistHref" [target]="playlistTarget"
class="miniature-thumbnail" [title]="playlist.description" class="miniature-thumbnail"
> >
<img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" /> <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
@ -12,12 +12,15 @@
<div class="play-overlay"> <div class="play-overlay">
<div class="icon"></div> <div class="icon"></div>
</div> </div>
</a> </my-link>
<div class="miniature-info"> <div class="miniature-info">
<a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description"> <my-link
[internalLink]="routerLink" [href]="playlistHref" [target]="playlistTarget"
[title]="playlist.description" class="miniature-name" tabindex="-1"
>
{{ playlist.displayName }} {{ playlist.displayName }}
</a> </my-link>
<a i18n [routerLink]="[ '/c', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy"> <a i18n [routerLink]="[ '/c', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy">
{{ playlist.videoChannelBy }} {{ playlist.videoChannelBy }}

View File

@ -75,7 +75,10 @@
} }
.miniature:not(.display-as-row) { .miniature:not(.display-as-row) {
.miniature-thumbnail { .miniature-thumbnail {
@include block-ratio($selector: '::ng-deep a');
margin-top: 10px; margin-top: 10px;
margin-bottom: 5px; margin-bottom: 5px;
} }

View File

@ -1,4 +1,5 @@
import { Component, Input } from '@angular/core' import { LinkType } from 'src/types/link.type'
import { Component, Input, OnInit } from '@angular/core'
import { VideoPlaylist } from './video-playlist.model' import { VideoPlaylist } from './video-playlist.model'
@Component({ @Component({
@ -6,18 +7,52 @@ import { VideoPlaylist } from './video-playlist.model'
styleUrls: [ './video-playlist-miniature.component.scss' ], styleUrls: [ './video-playlist-miniature.component.scss' ],
templateUrl: './video-playlist-miniature.component.html' templateUrl: './video-playlist-miniature.component.html'
}) })
export class VideoPlaylistMiniatureComponent { export class VideoPlaylistMiniatureComponent implements OnInit {
@Input() playlist: VideoPlaylist @Input() playlist: VideoPlaylist
@Input() toManage = false @Input() toManage = false
@Input() displayChannel = false @Input() displayChannel = false
@Input() displayDescription = false @Input() displayDescription = false
@Input() displayPrivacy = false @Input() displayPrivacy = false
@Input() displayAsRow = false @Input() displayAsRow = false
getPlaylistUrl () { @Input() linkType: LinkType = 'internal'
if (this.toManage) return [ '/my-library/video-playlists', this.playlist.uuid ]
if (this.playlist.videosLength === 0) return null
return [ '/w/p', this.playlist.uuid ] routerLink: any
playlistHref: string
playlistTarget: string
ngOnInit () {
this.buildPlaylistUrl()
}
buildPlaylistUrl () {
if (this.toManage) {
this.routerLink = [ '/my-library/video-playlists', this.playlist.uuid ]
return
}
if (this.playlist.videosLength === 0) {
this.routerLink = null
return
}
if (this.linkType === 'internal' || !this.playlist.url) {
this.routerLink = [ '/w/p', this.playlist.uuid ]
return
}
if (this.linkType === 'external') {
this.routerLink = null
this.playlistHref = this.playlist.url
this.playlistTarget = '_blank'
return
}
// Lazy load
this.routerLink = [ '/search/lazy-load-playlist', { url: this.playlist.url } ]
return
} }
} }

View File

@ -1,5 +1,5 @@
import { getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers' import { getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers'
import { Account, Actor, VideoChannel } from '@app/shared/shared-main' import { Actor } from '@app/shared/shared-main'
import { peertubeTranslate } from '@shared/core-utils/i18n' import { peertubeTranslate } from '@shared/core-utils/i18n'
import { import {
AccountSummary, AccountSummary,
@ -15,12 +15,12 @@ export class VideoPlaylist implements ServerVideoPlaylist {
uuid: string uuid: string
isLocal: boolean isLocal: boolean
url: string
displayName: string displayName: string
description: string description: string
privacy: VideoConstant<VideoPlaylistPrivacy> privacy: VideoConstant<VideoPlaylistPrivacy>
thumbnailPath: string
videosLength: number videosLength: number
type: VideoConstant<VideoPlaylistType> type: VideoConstant<VideoPlaylistType>
@ -31,6 +31,7 @@ export class VideoPlaylist implements ServerVideoPlaylist {
ownerAccount: AccountSummary ownerAccount: AccountSummary
videoChannel?: VideoChannelSummary videoChannel?: VideoChannelSummary
thumbnailPath: string
thumbnailUrl: string thumbnailUrl: string
embedPath: string embedPath: string
@ -40,14 +41,12 @@ export class VideoPlaylist implements ServerVideoPlaylist {
videoChannelBy?: string videoChannelBy?: string
private thumbnailVersion: number
private originThumbnailUrl: string
constructor (hash: ServerVideoPlaylist, translations: {}) { constructor (hash: ServerVideoPlaylist, translations: {}) {
const absoluteAPIUrl = getAbsoluteAPIUrl() const absoluteAPIUrl = getAbsoluteAPIUrl()
this.id = hash.id this.id = hash.id
this.uuid = hash.uuid this.uuid = hash.uuid
this.url = hash.url
this.isLocal = hash.isLocal this.isLocal = hash.isLocal
this.displayName = hash.displayName this.displayName = hash.displayName
@ -57,15 +56,12 @@ export class VideoPlaylist implements ServerVideoPlaylist {
this.thumbnailPath = hash.thumbnailPath this.thumbnailPath = hash.thumbnailPath
if (this.thumbnailPath) { this.thumbnailUrl = this.thumbnailPath
this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath ? hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
this.originThumbnailUrl = this.thumbnailUrl : absoluteAPIUrl + '/client/assets/images/default-playlist.jpg'
} else {
this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg'
}
this.embedPath = hash.embedPath this.embedPath = hash.embedPath
this.embedUrl = getAbsoluteEmbedUrl() + hash.embedPath this.embedUrl = hash.embedUrl || (getAbsoluteEmbedUrl() + hash.embedPath)
this.videosLength = hash.videosLength this.videosLength = hash.videosLength
@ -88,13 +84,4 @@ export class VideoPlaylist implements ServerVideoPlaylist {
this.displayName = peertubeTranslate(this.displayName, translations) this.displayName = peertubeTranslate(this.displayName, translations)
} }
} }
refreshThumbnail () {
if (!this.originThumbnailUrl) return
if (!this.thumbnailVersion) this.thumbnailVersion = 0
this.thumbnailVersion++
this.thumbnailUrl = this.originThumbnailUrl + '?v' + this.thumbnailVersion
}
} }

View File

@ -880,6 +880,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
top: 0; top: 0;
@content; @content;
} }
} }

View File

@ -0,0 +1 @@
export type LinkType = 'internal' | 'lazy-load' | 'external'

View File

@ -155,7 +155,8 @@ activityPubClientRouter.get('/redundancy/streaming-playlists/:streamingPlaylistT
asyncMiddleware(videoRedundancyController) asyncMiddleware(videoRedundancyController)
) )
activityPubClientRouter.get('/video-playlists/:playlistId', activityPubClientRouter.get(
[ '/video-playlists/:playlistId', '/videos/watch/playlist/:playlistId', '/w/p/:playlistId' ],
executeIfActivityPub, executeIfActivityPub,
asyncMiddleware(videoPlaylistsGetValidator('all')), asyncMiddleware(videoPlaylistsGetValidator('all')),
asyncMiddleware(videoPlaylistController) asyncMiddleware(videoPlaylistController)

View File

@ -1,294 +0,0 @@
import * as express from 'express'
import { sanitizeUrl } from '@server/helpers/core-utils'
import { doJSONRequest } from '@server/helpers/requests'
import { CONFIG } from '@server/initializers/config'
import { getOrCreateAPVideo } from '@server/lib/activitypub/videos'
import { Hooks } from '@server/lib/plugins/hooks'
import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
import { getServerActor } from '@server/models/application/application'
import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
import { ResultList, Video, VideoChannel } from '@shared/models'
import { SearchTargetQuery } from '@shared/models/search/search-target-query.model'
import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search'
import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
import { logger } from '../../helpers/logger'
import { getFormattedObjects } from '../../helpers/utils'
import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../lib/activitypub/actors'
import {
asyncMiddleware,
commonVideosFiltersValidator,
openapiOperationDoc,
optionalAuthenticate,
paginationValidator,
setDefaultPagination,
setDefaultSearchSort,
videoChannelsListSearchValidator,
videoChannelsSearchSortValidator,
videosSearchSortValidator,
videosSearchValidator
} from '../../middlewares'
import { VideoModel } from '../../models/video/video'
import { VideoChannelModel } from '../../models/video/video-channel'
import { MChannelAccountDefault, MVideoAccountLightBlacklistAllFiles } from '../../types/models'
const searchRouter = express.Router()
searchRouter.get('/videos',
openapiOperationDoc({ operationId: 'searchVideos' }),
paginationValidator,
setDefaultPagination,
videosSearchSortValidator,
setDefaultSearchSort,
optionalAuthenticate,
commonVideosFiltersValidator,
videosSearchValidator,
asyncMiddleware(searchVideos)
)
searchRouter.get('/video-channels',
openapiOperationDoc({ operationId: 'searchChannels' }),
paginationValidator,
setDefaultPagination,
videoChannelsSearchSortValidator,
setDefaultSearchSort,
optionalAuthenticate,
videoChannelsListSearchValidator,
asyncMiddleware(searchVideoChannels)
)
// ---------------------------------------------------------------------------
export { searchRouter }
// ---------------------------------------------------------------------------
function searchVideoChannels (req: express.Request, res: express.Response) {
const query: VideoChannelsSearchQuery = req.query
const search = query.search
const isURISearch = search.startsWith('http://') || search.startsWith('https://')
const parts = search.split('@')
// Handle strings like @toto@example.com
if (parts.length === 3 && parts[0].length === 0) parts.shift()
const isWebfingerSearch = parts.length === 2 && parts.every(p => p && !p.includes(' '))
if (isURISearch || isWebfingerSearch) return searchVideoChannelURI(search, isWebfingerSearch, res)
// @username -> username to search in DB
if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '')
if (isSearchIndexSearch(query)) {
return searchVideoChannelsIndex(query, res)
}
return searchVideoChannelsDB(query, res)
}
async function searchVideoChannelsIndex (query: VideoChannelsSearchQuery, res: express.Response) {
const result = await buildMutedForSearchIndex(res)
const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-channels.index.list.params')
const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels'
try {
logger.debug('Doing video channels search index request on %s.', url, { body })
const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoChannel>>(url, { method: 'POST', json: body })
const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-channels.index.list.result')
return res.json(jsonResult)
} catch (err) {
logger.warn('Cannot use search index to make video channels search.', { err })
return res.fail({
status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
message: 'Cannot use search index to make video channels search'
})
}
}
async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) {
const serverActor = await getServerActor()
const apiOptions = await Hooks.wrapObject({
actorId: serverActor.id,
search: query.search,
start: query.start,
count: query.count,
sort: query.sort
}, 'filter:api.search.video-channels.local.list.params')
const resultList = await Hooks.wrapPromiseFun(
VideoChannelModel.searchForApi,
apiOptions,
'filter:api.search.video-channels.local.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean, res: express.Response) {
let videoChannel: MChannelAccountDefault
let uri = search
if (isWebfingerSearch) {
try {
uri = await loadActorUrlOrGetFromWebfinger(search)
} catch (err) {
logger.warn('Cannot load actor URL or get from webfinger.', { search, err })
return res.json({ total: 0, data: [] })
}
}
if (isUserAbleToSearchRemoteURI(res)) {
try {
const actor = await getOrCreateAPActor(uri, 'all', true, true)
videoChannel = actor.VideoChannel
} catch (err) {
logger.info('Cannot search remote video channel %s.', uri, { err })
}
} else {
videoChannel = await VideoChannelModel.loadByUrlAndPopulateAccount(uri)
}
return res.json({
total: videoChannel ? 1 : 0,
data: videoChannel ? [ videoChannel.toFormattedJSON() ] : []
})
}
function searchVideos (req: express.Request, res: express.Response) {
const query: VideosSearchQuery = req.query
const search = query.search
if (search && (search.startsWith('http://') || search.startsWith('https://'))) {
return searchVideoURI(search, res)
}
if (isSearchIndexSearch(query)) {
return searchVideosIndex(query, res)
}
return searchVideosDB(query, res)
}
async function searchVideosIndex (query: VideosSearchQuery, res: express.Response) {
const result = await buildMutedForSearchIndex(res)
let body: VideosSearchQuery = Object.assign(query, result)
// Use the default instance NSFW policy if not specified
if (!body.nsfw) {
const nsfwPolicy = res.locals.oauth
? res.locals.oauth.token.User.nsfwPolicy
: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
body.nsfw = nsfwPolicy === 'do_not_list'
? 'false'
: 'both'
}
body = await Hooks.wrapObject(body, 'filter:api.search.videos.index.list.params')
const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos'
try {
logger.debug('Doing videos search index request on %s.', url, { body })
const { body: searchIndexResult } = await doJSONRequest<ResultList<Video>>(url, { method: 'POST', json: body })
const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.videos.index.list.result')
return res.json(jsonResult)
} catch (err) {
logger.warn('Cannot use search index to make video search.', { err })
return res.fail({
status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
message: 'Cannot use search index to make video search'
})
}
}
async function searchVideosDB (query: VideosSearchQuery, res: express.Response) {
const apiOptions = await Hooks.wrapObject(Object.assign(query, {
includeLocalVideos: true,
nsfw: buildNSFWFilter(res, query.nsfw),
filter: query.filter,
user: res.locals.oauth ? res.locals.oauth.token.User : undefined
}), 'filter:api.search.videos.local.list.params')
const resultList = await Hooks.wrapPromiseFun(
VideoModel.searchAndPopulateAccountAndServer,
apiOptions,
'filter:api.search.videos.local.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function searchVideoURI (url: string, res: express.Response) {
let video: MVideoAccountLightBlacklistAllFiles
// Check if we can fetch a remote video with the URL
if (isUserAbleToSearchRemoteURI(res)) {
try {
const syncParam = {
likes: false,
dislikes: false,
shares: false,
comments: false,
thumbnail: true,
refreshVideo: false
}
const result = await getOrCreateAPVideo({ videoObject: url, syncParam })
video = result ? result.video : undefined
} catch (err) {
logger.info('Cannot search remote video %s.', url, { err })
}
} else {
video = await VideoModel.loadByUrlAndPopulateAccount(url)
}
return res.json({
total: video ? 1 : 0,
data: video ? [ video.toFormattedJSON() ] : []
})
}
function isSearchIndexSearch (query: SearchTargetQuery) {
if (query.searchTarget === 'search-index') return true
const searchIndexConfig = CONFIG.SEARCH.SEARCH_INDEX
if (searchIndexConfig.ENABLED !== true) return false
if (searchIndexConfig.DISABLE_LOCAL_SEARCH) return true
if (searchIndexConfig.IS_DEFAULT_SEARCH && !query.searchTarget) return true
return false
}
async function buildMutedForSearchIndex (res: express.Response) {
const serverActor = await getServerActor()
const accountIds = [ serverActor.Account.id ]
if (res.locals.oauth) {
accountIds.push(res.locals.oauth.token.User.Account.id)
}
const [ blockedHosts, blockedAccounts ] = await Promise.all([
ServerBlocklistModel.listHostsBlockedBy(accountIds),
AccountBlocklistModel.listHandlesBlockedBy(accountIds)
])
return {
blockedHosts,
blockedAccounts
}
}

View File

@ -0,0 +1,16 @@
import * as express from 'express'
import { searchChannelsRouter } from './search-video-channels'
import { searchPlaylistsRouter } from './search-video-playlists'
import { searchVideosRouter } from './search-videos'
const searchRouter = express.Router()
searchRouter.use('/', searchVideosRouter)
searchRouter.use('/', searchChannelsRouter)
searchRouter.use('/', searchPlaylistsRouter)
// ---------------------------------------------------------------------------
export {
searchRouter
}

View File

@ -0,0 +1,150 @@
import * as express from 'express'
import { sanitizeUrl } from '@server/helpers/core-utils'
import { doJSONRequest } from '@server/helpers/requests'
import { CONFIG } from '@server/initializers/config'
import { WEBSERVER } from '@server/initializers/constants'
import { Hooks } from '@server/lib/plugins/hooks'
import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search'
import { getServerActor } from '@server/models/application/application'
import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
import { ResultList, VideoChannel } from '@shared/models'
import { VideoChannelsSearchQuery } from '../../../../shared/models/search'
import { isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils'
import { logger } from '../../../helpers/logger'
import { getFormattedObjects } from '../../../helpers/utils'
import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../../lib/activitypub/actors'
import {
asyncMiddleware,
openapiOperationDoc,
optionalAuthenticate,
paginationValidator,
setDefaultPagination,
setDefaultSearchSort,
videoChannelsListSearchValidator,
videoChannelsSearchSortValidator
} from '../../../middlewares'
import { VideoChannelModel } from '../../../models/video/video-channel'
import { MChannelAccountDefault } from '../../../types/models'
const searchChannelsRouter = express.Router()
searchChannelsRouter.get('/video-channels',
openapiOperationDoc({ operationId: 'searchChannels' }),
paginationValidator,
setDefaultPagination,
videoChannelsSearchSortValidator,
setDefaultSearchSort,
optionalAuthenticate,
videoChannelsListSearchValidator,
asyncMiddleware(searchVideoChannels)
)
// ---------------------------------------------------------------------------
export { searchChannelsRouter }
// ---------------------------------------------------------------------------
function searchVideoChannels (req: express.Request, res: express.Response) {
const query: VideoChannelsSearchQuery = req.query
const search = query.search
const parts = search.split('@')
// Handle strings like @toto@example.com
if (parts.length === 3 && parts[0].length === 0) parts.shift()
const isWebfingerSearch = parts.length === 2 && parts.every(p => p && !p.includes(' '))
if (isURISearch(search) || isWebfingerSearch) return searchVideoChannelURI(search, isWebfingerSearch, res)
// @username -> username to search in DB
if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '')
if (isSearchIndexSearch(query)) {
return searchVideoChannelsIndex(query, res)
}
return searchVideoChannelsDB(query, res)
}
async function searchVideoChannelsIndex (query: VideoChannelsSearchQuery, res: express.Response) {
const result = await buildMutedForSearchIndex(res)
const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-channels.index.list.params')
const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels'
try {
logger.debug('Doing video channels search index request on %s.', url, { body })
const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoChannel>>(url, { method: 'POST', json: body })
const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-channels.index.list.result')
return res.json(jsonResult)
} catch (err) {
logger.warn('Cannot use search index to make video channels search.', { err })
return res.fail({
status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
message: 'Cannot use search index to make video channels search'
})
}
}
async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) {
const serverActor = await getServerActor()
const apiOptions = await Hooks.wrapObject({
actorId: serverActor.id,
search: query.search,
start: query.start,
count: query.count,
sort: query.sort
}, 'filter:api.search.video-channels.local.list.params')
const resultList = await Hooks.wrapPromiseFun(
VideoChannelModel.searchForApi,
apiOptions,
'filter:api.search.video-channels.local.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean, res: express.Response) {
let videoChannel: MChannelAccountDefault
let uri = search
if (isWebfingerSearch) {
try {
uri = await loadActorUrlOrGetFromWebfinger(search)
} catch (err) {
logger.warn('Cannot load actor URL or get from webfinger.', { search, err })
return res.json({ total: 0, data: [] })
}
}
if (isUserAbleToSearchRemoteURI(res)) {
try {
const actor = await getOrCreateAPActor(uri, 'all', true, true)
videoChannel = actor.VideoChannel
} catch (err) {
logger.info('Cannot search remote video channel %s.', uri, { err })
}
} else {
videoChannel = await VideoChannelModel.loadByUrlAndPopulateAccount(sanitizeLocalUrl(uri))
}
return res.json({
total: videoChannel ? 1 : 0,
data: videoChannel ? [ videoChannel.toFormattedJSON() ] : []
})
}
function sanitizeLocalUrl (url: string) {
if (!url) return ''
// Handle alternative channel URLs
return url.replace(new RegExp('^' + WEBSERVER.URL + '/c/'), WEBSERVER.URL + '/video-channels/')
}

View File

@ -0,0 +1,129 @@
import * as express from 'express'
import { sanitizeUrl } from '@server/helpers/core-utils'
import { isUserAbleToSearchRemoteURI } from '@server/helpers/express-utils'
import { logger } from '@server/helpers/logger'
import { doJSONRequest } from '@server/helpers/requests'
import { getFormattedObjects } from '@server/helpers/utils'
import { CONFIG } from '@server/initializers/config'
import { getOrCreateAPVideoPlaylist } from '@server/lib/activitypub/playlists/get'
import { Hooks } from '@server/lib/plugins/hooks'
import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search'
import { getServerActor } from '@server/models/application/application'
import { VideoPlaylistModel } from '@server/models/video/video-playlist'
import { MVideoPlaylistFullSummary } from '@server/types/models'
import { HttpStatusCode } from '@shared/core-utils'
import { ResultList, VideoPlaylist, VideoPlaylistsSearchQuery } from '@shared/models'
import {
asyncMiddleware,
openapiOperationDoc,
optionalAuthenticate,
paginationValidator,
setDefaultPagination,
setDefaultSearchSort,
videoPlaylistsListSearchValidator,
videoPlaylistsSearchSortValidator
} from '../../../middlewares'
import { WEBSERVER } from '@server/initializers/constants'
const searchPlaylistsRouter = express.Router()
searchPlaylistsRouter.get('/video-playlists',
openapiOperationDoc({ operationId: 'searchPlaylists' }),
paginationValidator,
setDefaultPagination,
videoPlaylistsSearchSortValidator,
setDefaultSearchSort,
optionalAuthenticate,
videoPlaylistsListSearchValidator,
asyncMiddleware(searchVideoPlaylists)
)
// ---------------------------------------------------------------------------
export { searchPlaylistsRouter }
// ---------------------------------------------------------------------------
function searchVideoPlaylists (req: express.Request, res: express.Response) {
const query: VideoPlaylistsSearchQuery = req.query
const search = query.search
if (isURISearch(search)) return searchVideoPlaylistsURI(search, res)
if (isSearchIndexSearch(query)) {
return searchVideoPlaylistsIndex(query, res)
}
return searchVideoPlaylistsDB(query, res)
}
async function searchVideoPlaylistsIndex (query: VideoPlaylistsSearchQuery, res: express.Response) {
const result = await buildMutedForSearchIndex(res)
const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-playlists.index.list.params')
const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-playlists'
try {
logger.debug('Doing video playlists search index request on %s.', url, { body })
const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoPlaylist>>(url, { method: 'POST', json: body })
const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-playlists.index.list.result')
return res.json(jsonResult)
} catch (err) {
logger.warn('Cannot use search index to make video playlists search.', { err })
return res.fail({
status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
message: 'Cannot use search index to make video playlists search'
})
}
}
async function searchVideoPlaylistsDB (query: VideoPlaylistsSearchQuery, res: express.Response) {
const serverActor = await getServerActor()
const apiOptions = await Hooks.wrapObject({
followerActorId: serverActor.id,
search: query.search,
start: query.start,
count: query.count,
sort: query.sort
}, 'filter:api.search.video-playlists.local.list.params')
const resultList = await Hooks.wrapPromiseFun(
VideoPlaylistModel.searchForApi,
apiOptions,
'filter:api.search.video-playlists.local.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function searchVideoPlaylistsURI (search: string, res: express.Response) {
let videoPlaylist: MVideoPlaylistFullSummary
if (isUserAbleToSearchRemoteURI(res)) {
try {
videoPlaylist = await getOrCreateAPVideoPlaylist(search)
} catch (err) {
logger.info('Cannot search remote video playlist %s.', search, { err })
}
} else {
videoPlaylist = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(sanitizeLocalUrl(search))
}
return res.json({
total: videoPlaylist ? 1 : 0,
data: videoPlaylist ? [ videoPlaylist.toFormattedJSON() ] : []
})
}
function sanitizeLocalUrl (url: string) {
if (!url) return ''
// Handle alternative channel URLs
return url.replace(new RegExp('^' + WEBSERVER.URL + '/videos/watch/playlist/'), WEBSERVER.URL + '/video-playlists/')
.replace(new RegExp('^' + WEBSERVER.URL + '/w/p/'), WEBSERVER.URL + '/video-playlists/')
}

View File

@ -0,0 +1,153 @@
import * as express from 'express'
import { sanitizeUrl } from '@server/helpers/core-utils'
import { doJSONRequest } from '@server/helpers/requests'
import { CONFIG } from '@server/initializers/config'
import { WEBSERVER } from '@server/initializers/constants'
import { getOrCreateAPVideo } from '@server/lib/activitypub/videos'
import { Hooks } from '@server/lib/plugins/hooks'
import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search'
import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
import { ResultList, Video } from '@shared/models'
import { VideosSearchQuery } from '../../../../shared/models/search'
import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils'
import { logger } from '../../../helpers/logger'
import { getFormattedObjects } from '../../../helpers/utils'
import {
asyncMiddleware,
commonVideosFiltersValidator,
openapiOperationDoc,
optionalAuthenticate,
paginationValidator,
setDefaultPagination,
setDefaultSearchSort,
videosSearchSortValidator,
videosSearchValidator
} from '../../../middlewares'
import { VideoModel } from '../../../models/video/video'
import { MVideoAccountLightBlacklistAllFiles } from '../../../types/models'
const searchVideosRouter = express.Router()
searchVideosRouter.get('/videos',
openapiOperationDoc({ operationId: 'searchVideos' }),
paginationValidator,
setDefaultPagination,
videosSearchSortValidator,
setDefaultSearchSort,
optionalAuthenticate,
commonVideosFiltersValidator,
videosSearchValidator,
asyncMiddleware(searchVideos)
)
// ---------------------------------------------------------------------------
export { searchVideosRouter }
// ---------------------------------------------------------------------------
function searchVideos (req: express.Request, res: express.Response) {
const query: VideosSearchQuery = req.query
const search = query.search
if (isURISearch(search)) {
return searchVideoURI(search, res)
}
if (isSearchIndexSearch(query)) {
return searchVideosIndex(query, res)
}
return searchVideosDB(query, res)
}
async function searchVideosIndex (query: VideosSearchQuery, res: express.Response) {
const result = await buildMutedForSearchIndex(res)
let body: VideosSearchQuery = Object.assign(query, result)
// Use the default instance NSFW policy if not specified
if (!body.nsfw) {
const nsfwPolicy = res.locals.oauth
? res.locals.oauth.token.User.nsfwPolicy
: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
body.nsfw = nsfwPolicy === 'do_not_list'
? 'false'
: 'both'
}
body = await Hooks.wrapObject(body, 'filter:api.search.videos.index.list.params')
const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos'
try {
logger.debug('Doing videos search index request on %s.', url, { body })
const { body: searchIndexResult } = await doJSONRequest<ResultList<Video>>(url, { method: 'POST', json: body })
const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.videos.index.list.result')
return res.json(jsonResult)
} catch (err) {
logger.warn('Cannot use search index to make video search.', { err })
return res.fail({
status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
message: 'Cannot use search index to make video search'
})
}
}
async function searchVideosDB (query: VideosSearchQuery, res: express.Response) {
const apiOptions = await Hooks.wrapObject(Object.assign(query, {
includeLocalVideos: true,
nsfw: buildNSFWFilter(res, query.nsfw),
filter: query.filter,
user: res.locals.oauth ? res.locals.oauth.token.User : undefined
}), 'filter:api.search.videos.local.list.params')
const resultList = await Hooks.wrapPromiseFun(
VideoModel.searchAndPopulateAccountAndServer,
apiOptions,
'filter:api.search.videos.local.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function searchVideoURI (url: string, res: express.Response) {
let video: MVideoAccountLightBlacklistAllFiles
// Check if we can fetch a remote video with the URL
if (isUserAbleToSearchRemoteURI(res)) {
try {
const syncParam = {
likes: false,
dislikes: false,
shares: false,
comments: false,
thumbnail: true,
refreshVideo: false
}
const result = await getOrCreateAPVideo({ videoObject: url, syncParam })
video = result ? result.video : undefined
} catch (err) {
logger.info('Cannot search remote video %s.', url, { err })
}
} else {
video = await VideoModel.loadByUrlAndPopulateAccount(sanitizeLocalUrl(url))
}
return res.json({
total: video ? 1 : 0,
data: video ? [ video.toFormattedJSON() ] : []
})
}
function sanitizeLocalUrl (url: string) {
if (!url) return ''
// Handle alternative video URLs
return url.replace(new RegExp('^' + WEBSERVER.URL + '/w/'), WEBSERVER.URL + '/videos/watch/')
}

View File

@ -32,7 +32,7 @@ import {
videoChannelsUpdateValidator, videoChannelsUpdateValidator,
videoPlaylistsSortValidator videoPlaylistsSortValidator
} from '../../middlewares' } from '../../middlewares'
import { videoChannelsNameWithHostValidator, videoChannelsOwnSearchValidator, videosSortValidator } from '../../middlewares/validators' import { videoChannelsListValidator, videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators'
import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image' import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image'
import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists'
import { AccountModel } from '../../models/account/account' import { AccountModel } from '../../models/account/account'
@ -51,7 +51,7 @@ videoChannelRouter.get('/',
videoChannelsSortValidator, videoChannelsSortValidator,
setDefaultSort, setDefaultSort,
setDefaultPagination, setDefaultPagination,
videoChannelsOwnSearchValidator, videoChannelsListValidator,
asyncMiddleware(listVideoChannels) asyncMiddleware(listVideoChannels)
) )

View File

@ -1,7 +1,9 @@
import * as express from 'express' import * as express from 'express'
import { join } from 'path' import { join } from 'path'
import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists'
import { getServerActor } from '@server/models/application/application' import { getServerActor } from '@server/models/application/application'
import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models' import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models'
import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model' import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model'
import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model' import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model' import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
@ -17,7 +19,6 @@ import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constant
import { sequelizeTypescript } from '../../initializers/database' import { sequelizeTypescript } from '../../initializers/database'
import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send' import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send'
import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url' import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url'
import { JobQueue } from '../../lib/job-queue'
import { updatePlaylistMiniatureFromExisting } from '../../lib/thumbnail' import { updatePlaylistMiniatureFromExisting } from '../../lib/thumbnail'
import { import {
asyncMiddleware, asyncMiddleware,
@ -42,7 +43,6 @@ import {
import { AccountModel } from '../../models/account/account' import { AccountModel } from '../../models/account/account'
import { VideoPlaylistModel } from '../../models/video/video-playlist' import { VideoPlaylistModel } from '../../models/video/video-playlist'
import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR }) const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR })
@ -144,9 +144,7 @@ async function listVideoPlaylists (req: express.Request, res: express.Response)
function getVideoPlaylist (req: express.Request, res: express.Response) { function getVideoPlaylist (req: express.Request, res: express.Response) {
const videoPlaylist = res.locals.videoPlaylistSummary const videoPlaylist = res.locals.videoPlaylistSummary
if (videoPlaylist.isOutdated()) { scheduleRefreshIfNeeded(videoPlaylist)
JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: videoPlaylist.url } })
}
return res.json(videoPlaylist.toFormattedJSON()) return res.json(videoPlaylist.toFormattedJSON())
} }

View File

@ -1,13 +1,16 @@
import { exists, isDateValid } from '../misc'
import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
import validator from 'validator' import validator from 'validator'
import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object' import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object'
import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
import { exists, isDateValid, isUUIDValid } from '../misc'
import { isVideoPlaylistNameValid } from '../video-playlists'
import { isActivityPubUrlValid } from './misc' import { isActivityPubUrlValid } from './misc'
function isPlaylistObjectValid (object: PlaylistObject) { function isPlaylistObjectValid (object: PlaylistObject) {
return exists(object) && return exists(object) &&
object.type === 'Playlist' && object.type === 'Playlist' &&
validator.isInt(object.totalItems + '') && validator.isInt(object.totalItems + '') &&
isVideoPlaylistNameValid(object.name) &&
isUUIDValid(object.uuid) &&
isDateValid(object.published) && isDateValid(object.published) &&
isDateValid(object.updated) isDateValid(object.updated)
} }

View File

@ -77,6 +77,7 @@ const SORTABLE_COLUMNS = {
// Don't forget to update peertube-search-index with the same values // Don't forget to update peertube-search-index with the same values
VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ], VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ], VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
VIDEO_PLAYLISTS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
ABUSES: [ 'id', 'createdAt', 'state' ], ABUSES: [ 'id', 'createdAt', 'state' ],

View File

@ -116,7 +116,7 @@ async function scheduleOutboxFetchIfNeeded (actor: MActor, created: boolean, ref
async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) { async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) {
// We created a new account: fetch the playlists // We created a new account: fetch the playlists
if (created === true && actor.Account && accountPlaylistsUrl) { if (created === true && actor.Account && accountPlaylistsUrl) {
const payload = { uri: accountPlaylistsUrl, accountId: actor.Account.id, type: 'account-playlists' as 'account-playlists' } const payload = { uri: accountPlaylistsUrl, type: 'account-playlists' as 'account-playlists' }
await JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload }) await JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload })
} }
} }

View File

@ -1,3 +1,5 @@
import * as Bluebird from 'bluebird'
import { getAPId } from '@server/helpers/activitypub'
import { isArray } from '@server/helpers/custom-validators/misc' import { isArray } from '@server/helpers/custom-validators/misc'
import { logger, loggerTagsFactory } from '@server/helpers/logger' import { logger, loggerTagsFactory } from '@server/helpers/logger'
import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants' import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants'
@ -6,7 +8,7 @@ import { updatePlaylistMiniatureFromUrl } from '@server/lib/thumbnail'
import { VideoPlaylistModel } from '@server/models/video/video-playlist' import { VideoPlaylistModel } from '@server/models/video/video-playlist'
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
import { FilteredModelAttributes } from '@server/types' import { FilteredModelAttributes } from '@server/types'
import { MAccountDefault, MAccountId, MThumbnail, MVideoPlaylist, MVideoPlaylistFull } from '@server/types/models' import { MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models'
import { AttributesOnly } from '@shared/core-utils' import { AttributesOnly } from '@shared/core-utils'
import { PlaylistObject } from '@shared/models' import { PlaylistObject } from '@shared/models'
import { getOrCreateAPActor } from '../actors' import { getOrCreateAPActor } from '../actors'
@ -19,11 +21,9 @@ import {
playlistObjectToDBAttributes playlistObjectToDBAttributes
} from './shared' } from './shared'
import Bluebird = require('bluebird')
const lTags = loggerTagsFactory('ap', 'video-playlist') const lTags = loggerTagsFactory('ap', 'video-playlist')
async function createAccountPlaylists (playlistUrls: string[], account: MAccountDefault) { async function createAccountPlaylists (playlistUrls: string[]) {
await Bluebird.map(playlistUrls, async playlistUrl => { await Bluebird.map(playlistUrls, async playlistUrl => {
try { try {
const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl) const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
@ -35,19 +35,19 @@ async function createAccountPlaylists (playlistUrls: string[], account: MAccount
throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`) throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`)
} }
return createOrUpdateVideoPlaylist(playlistObject, account, playlistObject.to) return createOrUpdateVideoPlaylist(playlistObject)
} catch (err) { } catch (err) {
logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) }) logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) })
} }
}, { concurrency: CRAWL_REQUEST_CONCURRENCY }) }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
} }
async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) { async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, to?: string[]) {
const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to) const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to)
await setVideoChannelIfNeeded(playlistObject, playlistAttributes) await setVideoChannel(playlistObject, playlistAttributes)
const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylist>(playlistAttributes, { returning: true }) const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylistVideosLength>(playlistAttributes, { returning: true })
const playlistElementUrls = await fetchElementUrls(playlistObject) const playlistElementUrls = await fetchElementUrls(playlistObject)
@ -56,7 +56,10 @@ async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAc
await updatePlaylistThumbnail(playlistObject, playlist) await updatePlaylistThumbnail(playlistObject, playlist)
return rebuildVideoPlaylistElements(playlistElementUrls, playlist) const elementsLength = await rebuildVideoPlaylistElements(playlistElementUrls, playlist)
playlist.setVideosLength(elementsLength)
return playlist
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -68,10 +71,12 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function setVideoChannelIfNeeded (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) { async function setVideoChannel (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) {
if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) return if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) {
throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject))
}
const actor = await getOrCreateAPActor(playlistObject.attributedTo[0]) const actor = await getOrCreateAPActor(playlistObject.attributedTo[0], 'all')
if (!actor.VideoChannel) { if (!actor.VideoChannel) {
logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) }) logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) })
@ -79,6 +84,7 @@ async function setVideoChannelIfNeeded (playlistObject: PlaylistObject, playlist
} }
playlistAttributes.videoChannelId = actor.VideoChannel.id playlistAttributes.videoChannelId = actor.VideoChannel.id
playlistAttributes.ownerAccountId = actor.VideoChannel.Account.id
} }
async function fetchElementUrls (playlistObject: PlaylistObject) { async function fetchElementUrls (playlistObject: PlaylistObject) {
@ -128,7 +134,7 @@ async function rebuildVideoPlaylistElements (elementUrls: string[], playlist: MV
logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url)) logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url))
return undefined return elementsToCreate.length
} }
async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) { async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) {

View File

@ -0,0 +1,35 @@
import { getAPId } from '@server/helpers/activitypub'
import { VideoPlaylistModel } from '@server/models/video/video-playlist'
import { MVideoPlaylistFullSummary } from '@server/types/models'
import { APObject } from '@shared/models'
import { createOrUpdateVideoPlaylist } from './create-update'
import { scheduleRefreshIfNeeded } from './refresh'
import { fetchRemoteVideoPlaylist } from './shared'
async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObject): Promise<MVideoPlaylistFullSummary> {
const playlistUrl = getAPId(playlistObjectArg)
const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl)
if (playlistFromDatabase) {
scheduleRefreshIfNeeded(playlistFromDatabase)
return playlistFromDatabase
}
const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl)
if (!playlistObject) throw new Error('Cannot fetch remote playlist with url: ' + playlistUrl)
// playlistUrl is just an alias/rediraction, so process object id instead
if (playlistObject.id !== playlistUrl) return getOrCreateAPVideoPlaylist(playlistObject)
const playlistCreated = await createOrUpdateVideoPlaylist(playlistObject)
return playlistCreated
}
// ---------------------------------------------------------------------------
export {
getOrCreateAPVideoPlaylist
}

View File

@ -1,2 +1,3 @@
export * from './get'
export * from './create-update' export * from './create-update'
export * from './refresh' export * from './refresh'

View File

@ -1,10 +1,17 @@
import { logger, loggerTagsFactory } from '@server/helpers/logger' import { logger, loggerTagsFactory } from '@server/helpers/logger'
import { PeerTubeRequestError } from '@server/helpers/requests' import { PeerTubeRequestError } from '@server/helpers/requests'
import { MVideoPlaylistOwner } from '@server/types/models' import { JobQueue } from '@server/lib/job-queue'
import { MVideoPlaylist, MVideoPlaylistOwner } from '@server/types/models'
import { HttpStatusCode } from '@shared/core-utils' import { HttpStatusCode } from '@shared/core-utils'
import { createOrUpdateVideoPlaylist } from './create-update' import { createOrUpdateVideoPlaylist } from './create-update'
import { fetchRemoteVideoPlaylist } from './shared' import { fetchRemoteVideoPlaylist } from './shared'
function scheduleRefreshIfNeeded (playlist: MVideoPlaylist) {
if (!playlist.isOutdated()) return
JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: playlist.url } })
}
async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> { async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> {
if (!videoPlaylist.isOutdated()) return videoPlaylist if (!videoPlaylist.isOutdated()) return videoPlaylist
@ -22,8 +29,7 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner)
return videoPlaylist return videoPlaylist
} }
const byAccount = videoPlaylist.OwnerAccount await createOrUpdateVideoPlaylist(playlistObject)
await createOrUpdateVideoPlaylist(playlistObject, byAccount, playlistObject.to)
return videoPlaylist return videoPlaylist
} catch (err) { } catch (err) {
@ -42,5 +48,6 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner)
} }
export { export {
scheduleRefreshIfNeeded,
refreshVideoPlaylistIfNeeded refreshVideoPlaylistIfNeeded
} }

View File

@ -1,11 +1,11 @@
import { ACTIVITY_PUB } from '@server/initializers/constants' import { ACTIVITY_PUB } from '@server/initializers/constants'
import { VideoPlaylistModel } from '@server/models/video/video-playlist' import { VideoPlaylistModel } from '@server/models/video/video-playlist'
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
import { MAccountId, MVideoId, MVideoPlaylistId } from '@server/types/models' import { MVideoId, MVideoPlaylistId } from '@server/types/models'
import { AttributesOnly } from '@shared/core-utils' import { AttributesOnly } from '@shared/core-utils'
import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models' import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models'
function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) { function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: string[]) {
const privacy = to.includes(ACTIVITY_PUB.PUBLIC) const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
? VideoPlaylistPrivacy.PUBLIC ? VideoPlaylistPrivacy.PUBLIC
: VideoPlaylistPrivacy.UNLISTED : VideoPlaylistPrivacy.UNLISTED
@ -16,7 +16,7 @@ function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount
privacy, privacy,
url: playlistObject.id, url: playlistObject.id,
uuid: playlistObject.uuid, uuid: playlistObject.uuid,
ownerAccountId: byAccount.id, ownerAccountId: null,
videoChannelId: null, videoChannelId: null,
createdAt: new Date(playlistObject.published), createdAt: new Date(playlistObject.published),
updatedAt: new Date(playlistObject.updated) updatedAt: new Date(playlistObject.updated)

View File

@ -128,5 +128,5 @@ async function processCreatePlaylist (activity: ActivityCreate, byActor: MActorS
if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url) if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url)
await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to) await createOrUpdateVideoPlaylist(playlistObject, activity.to)
} }

View File

@ -111,5 +111,5 @@ async function processUpdatePlaylist (byActor: MActorSignature, activity: Activi
if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url) if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url)
await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to) await createOrUpdateVideoPlaylist(playlistObject, activity.to)
} }

View File

@ -3,6 +3,7 @@ import { retryTransactionWrapper } from '@server/helpers/database-utils'
import { JobQueue } from '@server/lib/job-queue' import { JobQueue } from '@server/lib/job-queue'
import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders' import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders'
import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models' import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models'
import { APObject } from '@shared/models'
import { refreshVideoIfNeeded } from './refresh' import { refreshVideoIfNeeded } from './refresh'
import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared' import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared'
@ -13,21 +14,21 @@ type GetVideoResult <T> = Promise<{
}> }>
type GetVideoParamAll = { type GetVideoParamAll = {
videoObject: { id: string } | string videoObject: APObject
syncParam?: SyncParam syncParam?: SyncParam
fetchType?: 'all' fetchType?: 'all'
allowRefresh?: boolean allowRefresh?: boolean
} }
type GetVideoParamImmutable = { type GetVideoParamImmutable = {
videoObject: { id: string } | string videoObject: APObject
syncParam?: SyncParam syncParam?: SyncParam
fetchType: 'only-immutable-attributes' fetchType: 'only-immutable-attributes'
allowRefresh: false allowRefresh: false
} }
type GetVideoParamOther = { type GetVideoParamOther = {
videoObject: { id: string } | string videoObject: APObject
syncParam?: SyncParam syncParam?: SyncParam
fetchType?: 'all' | 'only-video' fetchType?: 'all' | 'only-video'
allowRefresh?: boolean allowRefresh?: boolean

View File

@ -1,12 +1,11 @@
import * as Bull from 'bull' import * as Bull from 'bull'
import { ActivitypubHttpFetcherPayload, FetchType } from '@shared/models' import { ActivitypubHttpFetcherPayload, FetchType } from '@shared/models'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { AccountModel } from '../../../models/account/account'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate' import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
import { VideoModel } from '../../../models/video/video' import { VideoModel } from '../../../models/video/video'
import { VideoCommentModel } from '../../../models/video/video-comment' import { VideoCommentModel } from '../../../models/video/video-comment'
import { VideoShareModel } from '../../../models/video/video-share' import { VideoShareModel } from '../../../models/video/video-share'
import { MAccountDefault, MVideoFullLight } from '../../../types/models' import { MVideoFullLight } from '../../../types/models'
import { crawlCollectionPage } from '../../activitypub/crawl' import { crawlCollectionPage } from '../../activitypub/crawl'
import { createAccountPlaylists } from '../../activitypub/playlists' import { createAccountPlaylists } from '../../activitypub/playlists'
import { processActivities } from '../../activitypub/process' import { processActivities } from '../../activitypub/process'
@ -22,16 +21,13 @@ async function processActivityPubHttpFetcher (job: Bull.Job) {
let video: MVideoFullLight let video: MVideoFullLight
if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId) if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId)
let account: MAccountDefault
if (payload.accountId) account = await AccountModel.load(payload.accountId)
const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = { const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = {
'activity': items => processActivities(items, { outboxUrl: payload.uri, fromFetch: true }), 'activity': items => processActivities(items, { outboxUrl: payload.uri, fromFetch: true }),
'video-likes': items => createRates(items, video, 'like'), 'video-likes': items => createRates(items, video, 'like'),
'video-dislikes': items => createRates(items, video, 'dislike'), 'video-dislikes': items => createRates(items, video, 'dislike'),
'video-shares': items => addVideoShares(items, video), 'video-shares': items => addVideoShares(items, video),
'video-comments': items => addVideoComments(items), 'video-comments': items => addVideoComments(items),
'account-playlists': items => createAccountPlaylists(items, account) 'account-playlists': items => createAccountPlaylists(items)
} }
const cleanerType: { [ id in FetchType ]?: (crawlStartDate: Date) => Promise<any> } = { const cleanerType: { [ id in FetchType ]?: (crawlStartDate: Date) => Promise<any> } = {

50
server/lib/search.ts Normal file
View File

@ -0,0 +1,50 @@
import * as express from 'express'
import { CONFIG } from '@server/initializers/config'
import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
import { getServerActor } from '@server/models/application/application'
import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
import { SearchTargetQuery } from '@shared/models'
function isSearchIndexSearch (query: SearchTargetQuery) {
if (query.searchTarget === 'search-index') return true
const searchIndexConfig = CONFIG.SEARCH.SEARCH_INDEX
if (searchIndexConfig.ENABLED !== true) return false
if (searchIndexConfig.DISABLE_LOCAL_SEARCH) return true
if (searchIndexConfig.IS_DEFAULT_SEARCH && !query.searchTarget) return true
return false
}
async function buildMutedForSearchIndex (res: express.Response) {
const serverActor = await getServerActor()
const accountIds = [ serverActor.Account.id ]
if (res.locals.oauth) {
accountIds.push(res.locals.oauth.token.User.Account.id)
}
const [ blockedHosts, blockedAccounts ] = await Promise.all([
ServerBlocklistModel.listHostsBlockedBy(accountIds),
AccountBlocklistModel.listHandlesBlockedBy(accountIds)
])
return {
blockedHosts,
blockedAccounts
}
}
function isURISearch (search: string) {
if (!search) return false
return search.startsWith('http://') || search.startsWith('https://')
}
export {
isSearchIndexSearch,
buildMutedForSearchIndex,
isURISearch
}

View File

@ -49,11 +49,12 @@ const videoChannelsListSearchValidator = [
} }
] ]
const videoChannelsOwnSearchValidator = [ const videoPlaylistsListSearchValidator = [
query('search').optional().not().isEmpty().withMessage('Should have a valid search'), query('search').not().isEmpty().withMessage('Should have a valid search'),
query('searchTarget').optional().custom(isSearchTargetValid).withMessage('Should have a valid search target'),
(req: express.Request, res: express.Response, next: express.NextFunction) => { (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking video channels search query', { parameters: req.query }) logger.debug('Checking video playlists search query', { parameters: req.query })
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
@ -66,5 +67,5 @@ const videoChannelsOwnSearchValidator = [
export { export {
videosSearchValidator, videosSearchValidator,
videoChannelsListSearchValidator, videoChannelsListSearchValidator,
videoChannelsOwnSearchValidator videoPlaylistsListSearchValidator
} }

View File

@ -9,6 +9,7 @@ const SORTABLE_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.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_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH)
const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH) const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
const SORTABLE_VIDEO_PLAYLISTS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS) const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS)
const SORTABLE_VIDEO_COMMENTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) const SORTABLE_VIDEO_COMMENTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
@ -34,6 +35,7 @@ const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS) const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS)
const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS) const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS) const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS)
const videoPlaylistsSearchSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_SEARCH_COLUMNS)
const videoCommentsValidator = checkSort(SORTABLE_VIDEO_COMMENTS_COLUMNS) const videoCommentsValidator = checkSort(SORTABLE_VIDEO_COMMENTS_COLUMNS)
const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS) const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
const videoRatesSortValidator = checkSort(SORTABLE_VIDEO_RATES_COLUMNS) const videoRatesSortValidator = checkSort(SORTABLE_VIDEO_RATES_COLUMNS)
@ -75,5 +77,6 @@ export {
userNotificationsSortValidator, userNotificationsSortValidator,
videoPlaylistsSortValidator, videoPlaylistsSortValidator,
videoRedundanciesSortValidator, videoRedundanciesSortValidator,
videoPlaylistsSearchSortValidator,
pluginsSortValidator pluginsSortValidator
} }

View File

@ -141,6 +141,18 @@ const videoChannelStatsValidator = [
} }
] ]
const videoChannelsListValidator = [
query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking video channels search query', { parameters: req.query })
if (areValidationErrors(req, res)) return
return next()
}
]
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
@ -148,6 +160,7 @@ export {
videoChannelsUpdateValidator, videoChannelsUpdateValidator,
videoChannelsRemoveValidator, videoChannelsRemoveValidator,
videoChannelsNameWithHostValidator, videoChannelsNameWithHostValidator,
videoChannelsListValidator,
localVideoChannelValidator, localVideoChannelValidator,
videoChannelStatsValidator videoChannelStatsValidator
} }

View File

@ -18,7 +18,7 @@ export class AbstractVideosQueryBuilder {
logging: options.logging, logging: options.logging,
replacements: this.replacements, replacements: this.replacements,
type: QueryTypes.SELECT as QueryTypes.SELECT, type: QueryTypes.SELECT as QueryTypes.SELECT,
next: false nest: false
} }
return this.sequelize.query<any>(this.query, queryOptions) return this.sequelize.query<any>(this.query, queryOptions)

View File

@ -434,8 +434,8 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
sort: string sort: string
}) { }) {
const attributesInclude = [] const attributesInclude = []
const escapedSearch = VideoModel.sequelize.escape(options.search) const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%') const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search)) attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
const query = { const query = {

View File

@ -1,5 +1,5 @@
import { join } from 'path' import { join } from 'path'
import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize' import { FindOptions, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
import { import {
AllowNull, AllowNull,
BelongsTo, BelongsTo,
@ -53,7 +53,15 @@ import {
} from '../../types/models/video/video-playlist' } from '../../types/models/video/video-playlist'
import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
import { ActorModel } from '../actor/actor' import { ActorModel } from '../actor/actor'
import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils' import {
buildServerIdsFollowedBy,
buildTrigramSearchIndex,
buildWhereIdOrUUID,
createSimilarityAttribute,
getPlaylistSort,
isOutdated,
throwIfNotValid
} from '../utils'
import { ThumbnailModel } from './thumbnail' import { ThumbnailModel } from './thumbnail'
import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
import { VideoPlaylistElementModel } from './video-playlist-element' import { VideoPlaylistElementModel } from './video-playlist-element'
@ -74,6 +82,11 @@ type AvailableForListOptions = {
videoChannelId?: number videoChannelId?: number
listMyPlaylists?: boolean listMyPlaylists?: boolean
search?: string search?: string
withVideos?: boolean
}
function getVideoLengthSelect () {
return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"'
} }
@Scopes(() => ({ @Scopes(() => ({
@ -89,7 +102,7 @@ type AvailableForListOptions = {
attributes: { attributes: {
include: [ include: [
[ [
literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'), literal(`(${getVideoLengthSelect()})`),
'videosLength' 'videosLength'
] ]
] ]
@ -178,11 +191,28 @@ type AvailableForListOptions = {
}) })
} }
if (options.withVideos === true) {
whereAnd.push(
literal(`(${getVideoLengthSelect()}) != 0`)
)
}
const attributesInclude = []
if (options.search) { if (options.search) {
const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search)
const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%')
attributesInclude.push(createSimilarityAttribute('VideoPlaylistModel.name', options.search))
whereAnd.push({ whereAnd.push({
name: { [Op.or]: [
[Op.iLike]: '%' + options.search + '%' Sequelize.literal(
} 'lower(immutable_unaccent("VideoPlaylistModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
),
Sequelize.literal(
'lower(immutable_unaccent("VideoPlaylistModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
)
]
}) })
} }
@ -191,6 +221,9 @@ type AvailableForListOptions = {
} }
return { return {
attributes: {
include: attributesInclude
},
where, where,
include: [ include: [
{ {
@ -211,6 +244,8 @@ type AvailableForListOptions = {
@Table({ @Table({
tableName: 'videoPlaylist', tableName: 'videoPlaylist',
indexes: [ indexes: [
buildTrigramSearchIndex('video_playlist_name_trigram', 'name'),
{ {
fields: [ 'ownerAccountId' ] fields: [ 'ownerAccountId' ]
}, },
@ -314,6 +349,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
videoChannelId?: number videoChannelId?: number
listMyPlaylists?: boolean listMyPlaylists?: boolean
search?: string search?: string
withVideos?: boolean // false by default
}) { }) {
const query = { const query = {
offset: options.start, offset: options.start,
@ -331,7 +367,8 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
accountId: options.accountId, accountId: options.accountId,
videoChannelId: options.videoChannelId, videoChannelId: options.videoChannelId,
listMyPlaylists: options.listMyPlaylists, listMyPlaylists: options.listMyPlaylists,
search: options.search search: options.search,
withVideos: options.withVideos || false
} as AvailableForListOptions } as AvailableForListOptions
] ]
}, },
@ -347,6 +384,21 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
}) })
} }
static searchForApi (options: {
followerActorId: number
start: number
count: number
sort: string
search?: string
}) {
return VideoPlaylistModel.listForApi({
...options,
type: VideoPlaylistType.REGULAR,
listMyPlaylists: false,
withVideos: true
})
}
static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) { static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) {
const where = { const where = {
privacy: VideoPlaylistPrivacy.PUBLIC privacy: VideoPlaylistPrivacy.PUBLIC
@ -445,6 +497,18 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query) return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
} }
static loadByUrlWithAccountAndChannelSummary (url: string): Promise<MVideoPlaylistFullSummary> {
const query = {
where: {
url
}
}
return VideoPlaylistModel
.scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
.findOne(query)
}
static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown' return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
} }
@ -535,6 +599,10 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
return setAsUpdated('videoPlaylist', this.id) return setAsUpdated('videoPlaylist', this.id)
} }
setVideosLength (videosLength: number) {
this.set('videosLength' as any, videosLength, { raw: true })
}
isOwned () { isOwned () {
return this.OwnerAccount.isOwned() return this.OwnerAccount.isOwned()
} }
@ -551,6 +619,8 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
uuid: this.uuid, uuid: this.uuid,
isLocal: this.isOwned(), isLocal: this.isOwned(),
url: this.url,
displayName: this.name, displayName: this.name,
description: this.description, description: this.description,
privacy: { privacy: {

View File

@ -140,6 +140,30 @@ describe('Test videos API validator', function () {
}) })
}) })
describe('When searching video playlists', function () {
const path = '/api/v1/search/video-playlists/'
const query = {
search: 'coucou'
}
it('Should fail with a bad start pagination', async function () {
await checkBadStartPagination(server.url, path, null, query)
})
it('Should fail with a bad count pagination', async function () {
await checkBadCountPagination(server.url, path, null, query)
})
it('Should fail with an incorrect sort', async function () {
await checkBadSortPagination(server.url, path, null, query)
})
it('Should success with the correct parameters', async function () {
await makeGetRequest({ url: server.url, path, query, statusCodeExpected: HttpStatusCode.OK_200 })
})
})
describe('When searching video channels', function () { describe('When searching video channels', function () {
const path = '/api/v1/search/video-channels/' const path = '/api/v1/search/video-channels/'
@ -171,6 +195,7 @@ describe('Test videos API validator', function () {
const query = { search: 'coucou' } const query = { search: 'coucou' }
const paths = [ const paths = [
'/api/v1/search/video-playlists/',
'/api/v1/search/video-channels/', '/api/v1/search/video-channels/',
'/api/v1/search/videos/' '/api/v1/search/videos/'
] ]

View File

@ -1,5 +1,7 @@
import './search-activitypub-video-playlists'
import './search-activitypub-video-channels' import './search-activitypub-video-channels'
import './search-activitypub-videos' import './search-activitypub-videos'
import './search-index'
import './search-videos'
import './search-channels' import './search-channels'
import './search-index'
import './search-playlists'
import './search-videos'

View File

@ -106,9 +106,25 @@ describe('Test ActivityPub video channels search', function () {
} }
}) })
it('Should search a local video channel with an alternative URL', async function () {
const search = 'http://localhost:' + servers[0].port + '/c/channel1_server1'
for (const token of [ undefined, servers[0].accessToken ]) {
const res = await searchVideoChannel(servers[0].url, search, token)
expect(res.body.total).to.equal(1)
expect(res.body.data).to.be.an('array')
expect(res.body.data).to.have.lengthOf(1)
expect(res.body.data[0].name).to.equal('channel1_server1')
expect(res.body.data[0].displayName).to.equal('Channel 1 server 1')
}
})
it('Should search a remote video channel with URL or handle', async function () { it('Should search a remote video channel with URL or handle', async function () {
const searches = [ const searches = [
'http://localhost:' + servers[1].port + '/video-channels/channel1_server2', 'http://localhost:' + servers[1].port + '/video-channels/channel1_server2',
'http://localhost:' + servers[1].port + '/c/channel1_server2',
'http://localhost:' + servers[1].port + '/c/channel1_server2/videos',
'channel1_server2@localhost:' + servers[1].port 'channel1_server2@localhost:' + servers[1].port
] ]

View File

@ -0,0 +1,212 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha'
import * as chai from 'chai'
import {
addVideoInPlaylist,
cleanupTests,
createVideoPlaylist,
deleteVideoPlaylist,
flushAndRunMultipleServers,
getVideoPlaylistsList,
searchVideoPlaylists,
ServerInfo,
setAccessTokensToServers,
setDefaultVideoChannel,
uploadVideoAndGetId,
wait
} from '../../../../shared/extra-utils'
import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
import { VideoPlaylist, VideoPlaylistPrivacy } from '../../../../shared/models/videos'
const expect = chai.expect
describe('Test ActivityPub playlists search', function () {
let servers: ServerInfo[]
let playlistServer1UUID: string
let playlistServer2UUID: string
let video2Server2: string
before(async function () {
this.timeout(120000)
servers = await flushAndRunMultipleServers(2)
await setAccessTokensToServers(servers)
await setDefaultVideoChannel(servers)
{
const video1 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 1' })).uuid
const video2 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 2' })).uuid
const attributes = {
displayName: 'playlist 1 on server 1',
privacy: VideoPlaylistPrivacy.PUBLIC,
videoChannelId: servers[0].videoChannel.id
}
const res = await createVideoPlaylist({ url: servers[0].url, token: servers[0].accessToken, playlistAttrs: attributes })
playlistServer1UUID = res.body.videoPlaylist.uuid
for (const videoId of [ video1, video2 ]) {
await addVideoInPlaylist({
url: servers[0].url,
token: servers[0].accessToken,
playlistId: playlistServer1UUID,
elementAttrs: { videoId }
})
}
}
{
const videoId = (await uploadVideoAndGetId({ server: servers[1], videoName: 'video 1' })).uuid
video2Server2 = (await uploadVideoAndGetId({ server: servers[1], videoName: 'video 2' })).uuid
const attributes = {
displayName: 'playlist 1 on server 2',
privacy: VideoPlaylistPrivacy.PUBLIC,
videoChannelId: servers[1].videoChannel.id
}
const res = await createVideoPlaylist({ url: servers[1].url, token: servers[1].accessToken, playlistAttrs: attributes })
playlistServer2UUID = res.body.videoPlaylist.uuid
await addVideoInPlaylist({
url: servers[1].url,
token: servers[1].accessToken,
playlistId: playlistServer2UUID,
elementAttrs: { videoId }
})
}
await waitJobs(servers)
})
it('Should not find a remote playlist', async function () {
{
const search = 'http://localhost:' + servers[1].port + '/video-playlists/43'
const res = await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
expect(res.body.total).to.equal(0)
expect(res.body.data).to.be.an('array')
expect(res.body.data).to.have.lengthOf(0)
}
{
// Without token
const search = 'http://localhost:' + servers[1].port + '/video-playlists/' + playlistServer2UUID
const res = await searchVideoPlaylists(servers[0].url, search)
expect(res.body.total).to.equal(0)
expect(res.body.data).to.be.an('array')
expect(res.body.data).to.have.lengthOf(0)
}
})
it('Should search a local playlist', async function () {
const search = 'http://localhost:' + servers[0].port + '/video-playlists/' + playlistServer1UUID
const res = await searchVideoPlaylists(servers[0].url, search)
expect(res.body.total).to.equal(1)
expect(res.body.data).to.be.an('array')
expect(res.body.data).to.have.lengthOf(1)
expect(res.body.data[0].displayName).to.equal('playlist 1 on server 1')
expect(res.body.data[0].videosLength).to.equal(2)
})
it('Should search a local playlist with an alternative URL', async function () {
const searches = [
'http://localhost:' + servers[0].port + '/videos/watch/playlist/' + playlistServer1UUID,
'http://localhost:' + servers[0].port + '/w/p/' + playlistServer1UUID
]
for (const search of searches) {
for (const token of [ undefined, servers[0].accessToken ]) {
const res = await searchVideoPlaylists(servers[0].url, search, token)
expect(res.body.total).to.equal(1)
expect(res.body.data).to.be.an('array')
expect(res.body.data).to.have.lengthOf(1)
expect(res.body.data[0].displayName).to.equal('playlist 1 on server 1')
expect(res.body.data[0].videosLength).to.equal(2)
}
}
})
it('Should search a remote playlist', async function () {
const searches = [
'http://localhost:' + servers[1].port + '/video-playlists/' + playlistServer2UUID,
'http://localhost:' + servers[1].port + '/videos/watch/playlist/' + playlistServer2UUID,
'http://localhost:' + servers[1].port + '/w/p/' + playlistServer2UUID
]
for (const search of searches) {
const res = await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
expect(res.body.total).to.equal(1)
expect(res.body.data).to.be.an('array')
expect(res.body.data).to.have.lengthOf(1)
expect(res.body.data[0].displayName).to.equal('playlist 1 on server 2')
expect(res.body.data[0].videosLength).to.equal(1)
}
})
it('Should not list this remote playlist', async function () {
const res = await getVideoPlaylistsList(servers[0].url, 0, 10)
expect(res.body.total).to.equal(1)
expect(res.body.data).to.have.lengthOf(1)
expect(res.body.data[0].displayName).to.equal('playlist 1 on server 1')
})
it('Should update the playlist of server 2, and refresh it on server 1', async function () {
this.timeout(60000)
await addVideoInPlaylist({
url: servers[1].url,
token: servers[1].accessToken,
playlistId: playlistServer2UUID,
elementAttrs: { videoId: video2Server2 }
})
await waitJobs(servers)
// Expire playlist
await wait(10000)
// Will run refresh async
const search = 'http://localhost:' + servers[1].port + '/video-playlists/' + playlistServer2UUID
await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
// Wait refresh
await wait(5000)
const res = await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
expect(res.body.total).to.equal(1)
expect(res.body.data).to.have.lengthOf(1)
const playlist: VideoPlaylist = res.body.data[0]
expect(playlist.videosLength).to.equal(2)
})
it('Should delete playlist of server 2, and delete it on server 1', async function () {
this.timeout(60000)
await deleteVideoPlaylist(servers[1].url, servers[1].accessToken, playlistServer2UUID)
await waitJobs(servers)
// Expiration
await wait(10000)
// Will run refresh async
const search = 'http://localhost:' + servers[1].port + '/video-playlists/' + playlistServer2UUID
await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
// Wait refresh
await wait(5000)
const res = await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
expect(res.body.total).to.equal(0)
expect(res.body.data).to.have.lengthOf(0)
})
after(async function () {
await cleanupTests(servers)
})
})

View File

@ -77,14 +77,33 @@ describe('Test ActivityPub videos search', function () {
expect(res.body.data[0].name).to.equal('video 1 on server 1') expect(res.body.data[0].name).to.equal('video 1 on server 1')
}) })
it('Should search a remote video', async function () { it('Should search a local video with an alternative URL', async function () {
const search = 'http://localhost:' + servers[1].port + '/videos/watch/' + videoServer2UUID const search = 'http://localhost:' + servers[0].port + '/w/' + videoServer1UUID
const res = await searchVideoWithToken(servers[0].url, search, servers[0].accessToken) const res1 = await searchVideo(servers[0].url, search)
const res2 = await searchVideoWithToken(servers[0].url, search, servers[0].accessToken)
expect(res.body.total).to.equal(1) for (const res of [ res1, res2 ]) {
expect(res.body.data).to.be.an('array') expect(res.body.total).to.equal(1)
expect(res.body.data).to.have.lengthOf(1) expect(res.body.data).to.be.an('array')
expect(res.body.data[0].name).to.equal('video 1 on server 2') expect(res.body.data).to.have.lengthOf(1)
expect(res.body.data[0].name).to.equal('video 1 on server 1')
}
})
it('Should search a remote video', async function () {
const searches = [
'http://localhost:' + servers[1].port + '/w/' + videoServer2UUID,
'http://localhost:' + servers[1].port + '/videos/watch/' + videoServer2UUID
]
for (const search of searches) {
const res = await searchVideoWithToken(servers[0].url, search, servers[0].accessToken)
expect(res.body.total).to.equal(1)
expect(res.body.data).to.be.an('array')
expect(res.body.data).to.have.lengthOf(1)
expect(res.body.data[0].name).to.equal('video 1 on server 2')
}
}) })
it('Should not list this remote video', async function () { it('Should not list this remote video', async function () {
@ -95,7 +114,7 @@ describe('Test ActivityPub videos search', function () {
}) })
it('Should update video of server 2, and refresh it on server 1', async function () { it('Should update video of server 2, and refresh it on server 1', async function () {
this.timeout(60000) this.timeout(120000)
const channelAttributes = { const channelAttributes = {
name: 'super_channel', name: 'super_channel',
@ -134,7 +153,7 @@ describe('Test ActivityPub videos search', function () {
}) })
it('Should delete video of server 2, and delete it on server 1', async function () { it('Should delete video of server 2, and delete it on server 1', async function () {
this.timeout(60000) this.timeout(120000)
await removeVideo(servers[1].url, servers[1].accessToken, videoServer2UUID) await removeVideo(servers[1].url, servers[1].accessToken, videoServer2UUID)

View File

@ -2,19 +2,21 @@
import 'mocha' import 'mocha'
import * as chai from 'chai' import * as chai from 'chai'
import { advancedVideoChannelSearch, searchVideoChannel } from '@shared/extra-utils/search/video-channels'
import { Video, VideoChannel, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType, VideosSearchQuery } from '@shared/models'
import { import {
advancedVideoPlaylistSearch,
advancedVideosSearch,
cleanupTests, cleanupTests,
flushAndRunServer, flushAndRunServer,
immutableAssign,
searchVideo, searchVideo,
searchVideoPlaylists,
ServerInfo, ServerInfo,
setAccessTokensToServers, setAccessTokensToServers,
updateCustomSubConfig, updateCustomSubConfig,
uploadVideo, uploadVideo
advancedVideosSearch,
immutableAssign
} from '../../../../shared/extra-utils' } from '../../../../shared/extra-utils'
import { searchVideoChannel, advancedVideoChannelSearch } from '@shared/extra-utils/search/video-channels'
import { VideosSearchQuery, Video, VideoChannel } from '@shared/models'
const expect = chai.expect const expect = chai.expect
@ -277,6 +279,56 @@ describe('Test videos search', function () {
}) })
}) })
describe('Playlists search', async function () {
it('Should make a simple search and not have results', async function () {
const res = await searchVideoPlaylists(server.url, 'a'.repeat(500))
expect(res.body.total).to.equal(0)
expect(res.body.data).to.have.lengthOf(0)
})
it('Should make a search and have results', async function () {
const res = await advancedVideoPlaylistSearch(server.url, { search: 'E2E playlist', sort: '-match' })
expect(res.body.total).to.be.greaterThan(0)
expect(res.body.data).to.have.length.greaterThan(0)
const videoPlaylist: VideoPlaylist = res.body.data[0]
expect(videoPlaylist.url).to.equal('https://peertube2.cpy.re/videos/watch/playlist/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a')
expect(videoPlaylist.thumbnailUrl).to.exist
expect(videoPlaylist.embedUrl).to.equal('https://peertube2.cpy.re/video-playlists/embed/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a')
expect(videoPlaylist.type.id).to.equal(VideoPlaylistType.REGULAR)
expect(videoPlaylist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC)
expect(videoPlaylist.videosLength).to.exist
expect(videoPlaylist.createdAt).to.exist
expect(videoPlaylist.updatedAt).to.exist
expect(videoPlaylist.uuid).to.equal('73804a40-da9a-40c2-b1eb-2c6d9eec8f0a')
expect(videoPlaylist.displayName).to.exist
expect(videoPlaylist.ownerAccount.url).to.equal('https://peertube2.cpy.re/accounts/chocobozzz')
expect(videoPlaylist.ownerAccount.name).to.equal('chocobozzz')
expect(videoPlaylist.ownerAccount.host).to.equal('peertube2.cpy.re')
expect(videoPlaylist.ownerAccount.avatar).to.exist
expect(videoPlaylist.videoChannel.url).to.equal('https://peertube2.cpy.re/video-channels/chocobozzz_channel')
expect(videoPlaylist.videoChannel.name).to.equal('chocobozzz_channel')
expect(videoPlaylist.videoChannel.host).to.equal('peertube2.cpy.re')
expect(videoPlaylist.videoChannel.avatar).to.exist
})
it('Should have a correct pagination', async function () {
const res = await advancedVideoChannelSearch(server.url, { search: 'root', start: 0, count: 2 })
expect(res.body.total).to.be.greaterThan(2)
expect(res.body.data).to.have.lengthOf(2)
})
})
after(async function () { after(async function () {
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View File

@ -0,0 +1,128 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha'
import * as chai from 'chai'
import { VideoPlaylist, VideoPlaylistPrivacy } from '@shared/models'
import {
addVideoInPlaylist,
advancedVideoPlaylistSearch,
cleanupTests,
createVideoPlaylist,
flushAndRunServer,
searchVideoPlaylists,
ServerInfo,
setAccessTokensToServers,
setDefaultVideoChannel,
uploadVideoAndGetId
} from '../../../../shared/extra-utils'
const expect = chai.expect
describe('Test playlists search', function () {
let server: ServerInfo = null
before(async function () {
this.timeout(30000)
server = await flushAndRunServer(1)
await setAccessTokensToServers([ server ])
await setDefaultVideoChannel([ server ])
const videoId = (await uploadVideoAndGetId({ server: server, videoName: 'video' })).uuid
{
const attributes = {
displayName: 'Dr. Kenzo Tenma hospital videos',
privacy: VideoPlaylistPrivacy.PUBLIC,
videoChannelId: server.videoChannel.id
}
const res = await createVideoPlaylist({ url: server.url, token: server.accessToken, playlistAttrs: attributes })
await addVideoInPlaylist({
url: server.url,
token: server.accessToken,
playlistId: res.body.videoPlaylist.id,
elementAttrs: { videoId }
})
}
{
const attributes = {
displayName: 'Johan & Anna Libert musics',
privacy: VideoPlaylistPrivacy.PUBLIC,
videoChannelId: server.videoChannel.id
}
const res = await createVideoPlaylist({ url: server.url, token: server.accessToken, playlistAttrs: attributes })
await addVideoInPlaylist({
url: server.url,
token: server.accessToken,
playlistId: res.body.videoPlaylist.id,
elementAttrs: { videoId }
})
}
{
const attributes = {
displayName: 'Inspector Lunge playlist',
privacy: VideoPlaylistPrivacy.PUBLIC,
videoChannelId: server.videoChannel.id
}
await createVideoPlaylist({ url: server.url, token: server.accessToken, playlistAttrs: attributes })
}
})
it('Should make a simple search and not have results', async function () {
const res = await searchVideoPlaylists(server.url, 'abc')
expect(res.body.total).to.equal(0)
expect(res.body.data).to.have.lengthOf(0)
})
it('Should make a search and have results', async function () {
{
const search = {
search: 'tenma',
start: 0,
count: 1
}
const res = await advancedVideoPlaylistSearch(server.url, search)
expect(res.body.total).to.equal(1)
expect(res.body.data).to.have.lengthOf(1)
const playlist: VideoPlaylist = res.body.data[0]
expect(playlist.displayName).to.equal('Dr. Kenzo Tenma hospital videos')
expect(playlist.url).to.equal(server.url + '/video-playlists/' + playlist.uuid)
}
{
const search = {
search: 'Anna Livert',
start: 0,
count: 1
}
const res = await advancedVideoPlaylistSearch(server.url, search)
expect(res.body.total).to.equal(1)
expect(res.body.data).to.have.lengthOf(1)
const playlist: VideoPlaylist = res.body.data[0]
expect(playlist.displayName).to.equal('Johan & Anna Libert musics')
}
})
it('Should not display playlists without videos', async function () {
const search = {
search: 'Lunge',
start: 0,
count: 1
}
const res = await advancedVideoPlaylistSearch(server.url, search)
expect(res.body.total).to.equal(0)
expect(res.body.data).to.have.lengthOf(0)
})
after(async function () {
await cleanupTests([ server ])
})
})

View File

@ -1,7 +1,7 @@
{ {
"name": "peertube-plugin-test-two", "name": "peertube-plugin-test-filter-translations",
"version": "0.0.1", "version": "0.0.1",
"description": "Plugin test 2", "description": "Plugin test filter and translations",
"engine": { "engine": {
"peertube": ">=1.3.0" "peertube": ">=1.3.0"
}, },

View File

@ -241,6 +241,10 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
'filter:api.search.video-channels.local.list.result', 'filter:api.search.video-channels.local.list.result',
'filter:api.search.video-channels.index.list.params', 'filter:api.search.video-channels.index.list.params',
'filter:api.search.video-channels.index.list.result', 'filter:api.search.video-channels.index.list.result',
'filter:api.search.video-playlists.local.list.params',
'filter:api.search.video-playlists.local.list.result',
'filter:api.search.video-playlists.index.list.params',
'filter:api.search.video-playlists.index.list.result'
] ]
for (const h of searchHooks) { for (const h of searchHooks) {

View File

@ -8,6 +8,7 @@ import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-code
import { import {
addVideoCommentReply, addVideoCommentReply,
addVideoCommentThread, addVideoCommentThread,
advancedVideoPlaylistSearch,
advancedVideosSearch, advancedVideosSearch,
createLive, createLive,
createVideoPlaylist, createVideoPlaylist,
@ -71,7 +72,7 @@ describe('Test plugin filter hooks', function () {
await installPlugin({ await installPlugin({
url: servers[0].url, url: servers[0].url,
accessToken: servers[0].accessToken, accessToken: servers[0].accessToken,
path: getPluginTestPath('-two') path: getPluginTestPath('-filter-translations')
}) })
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
@ -525,6 +526,27 @@ describe('Test plugin filter hooks', function () {
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.params', 1) await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.params', 1)
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.result', 1) await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.result', 1)
}) })
it('Should run filter:api.search.video-playlists.local.list.{params,result}', async function () {
await advancedVideoPlaylistSearch(servers[0].url, {
search: 'Sun Jian'
})
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.params', 1)
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.result', 1)
})
it('Should run filter:api.search.video-playlists.index.list.{params,result}', async function () {
await advancedVideoPlaylistSearch(servers[0].url, {
search: 'Sun Jian',
searchTarget: 'search-index'
})
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.params', 1)
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.result', 1)
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.index.list.params', 1)
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.index.list.result', 1)
})
}) })
after(async function () { after(async function () {

View File

@ -31,7 +31,7 @@ describe('Test plugin translations', function () {
await installPlugin({ await installPlugin({
url: server.url, url: server.url,
accessToken: server.accessToken, accessToken: server.accessToken,
path: getPluginTestPath('-two') path: getPluginTestPath('-filter-translations')
}) })
}) })
@ -48,7 +48,7 @@ describe('Test plugin translations', function () {
'peertube-plugin-test': { 'peertube-plugin-test': {
Hi: 'Coucou' Hi: 'Coucou'
}, },
'peertube-plugin-test-two': { 'peertube-plugin-test-filter-translations': {
'Hello world': 'Bonjour le monde' 'Hello world': 'Bonjour le monde'
} }
}) })
@ -58,14 +58,14 @@ describe('Test plugin translations', function () {
const res = await getPluginTranslations({ url: server.url, locale: 'it-IT' }) const res = await getPluginTranslations({ url: server.url, locale: 'it-IT' })
expect(res.body).to.deep.equal({ expect(res.body).to.deep.equal({
'peertube-plugin-test-two': { 'peertube-plugin-test-filter-translations': {
'Hello world': 'Ciao, mondo!' 'Hello world': 'Ciao, mondo!'
} }
}) })
}) })
it('Should remove the plugin and remove the locales', async function () { it('Should remove the plugin and remove the locales', async function () {
await uninstallPlugin({ url: server.url, accessToken: server.accessToken, npmName: 'peertube-plugin-test-two' }) await uninstallPlugin({ url: server.url, accessToken: server.accessToken, npmName: 'peertube-plugin-test-filter-translations' })
{ {
const res = await getPluginTranslations({ url: server.url, locale: 'fr-FR' }) const res = await getPluginTranslations({ url: server.url, locale: 'fr-FR' })

View File

@ -69,7 +69,7 @@ export type MVideoPlaylistAccountChannelDefault =
// With all associations // With all associations
export type MVideoPlaylistFull = export type MVideoPlaylistFull =
MVideoPlaylist & MVideoPlaylistVideosLength &
Use<'OwnerAccount', MAccountDefault> & Use<'OwnerAccount', MAccountDefault> &
Use<'VideoChannel', MChannelDefault> & Use<'VideoChannel', MChannelDefault> &
Use<'Thumbnail', MThumbnail> Use<'Thumbnail', MThumbnail>
@ -84,7 +84,7 @@ export type MVideoPlaylistAccountChannelSummary =
Use<'VideoChannel', MChannelSummary> Use<'VideoChannel', MChannelSummary>
export type MVideoPlaylistFullSummary = export type MVideoPlaylistFullSummary =
MVideoPlaylist & MVideoPlaylistVideosLength &
Use<'Thumbnail', MThumbnail> & Use<'Thumbnail', MThumbnail> &
Use<'OwnerAccount', MAccountSummary> & Use<'OwnerAccount', MAccountSummary> &
Use<'VideoChannel', MChannelSummary> Use<'VideoChannel', MChannelSummary>

View File

@ -19,6 +19,8 @@ export * from './plugins/mock-blocklist'
export * from './requests/check-api-params' export * from './requests/check-api-params'
export * from './requests/requests' export * from './requests/requests'
export * from './search/video-channels'
export * from './search/video-playlists'
export * from './search/videos' export * from './search/videos'
export * from './server/activitypub' export * from './server/activitypub'

View File

@ -0,0 +1,36 @@
import { VideoPlaylistsSearchQuery } from '@shared/models'
import { HttpStatusCode } from '../../core-utils/miscs/http-error-codes'
import { makeGetRequest } from '../requests/requests'
function searchVideoPlaylists (url: string, search: string, token?: string, statusCodeExpected = HttpStatusCode.OK_200) {
const path = '/api/v1/search/video-playlists'
return makeGetRequest({
url,
path,
query: {
sort: '-createdAt',
search
},
token,
statusCodeExpected
})
}
function advancedVideoPlaylistSearch (url: string, search: VideoPlaylistsSearchQuery) {
const path = '/api/v1/search/video-playlists'
return makeGetRequest({
url,
path,
query: search,
statusCodeExpected: HttpStatusCode.OK_200
})
}
// ---------------------------------------------------------------------------
export {
searchVideoPlaylists,
advancedVideoPlaylistSearch
}

View File

@ -37,9 +37,12 @@ export const clientFilterHookObject = {
// Filter params/result of the function that fetch videos according to the user search // Filter params/result of the function that fetch videos according to the user search
'filter:api.search.videos.list.params': true, 'filter:api.search.videos.list.params': true,
'filter:api.search.videos.list.result': true, 'filter:api.search.videos.list.result': true,
// Filter params/result of the function that fetch video-channels according to the user search // Filter params/result of the function that fetch video channels according to the user search
'filter:api.search.video-channels.list.params': true, 'filter:api.search.video-channels.list.params': true,
'filter:api.search.video-channels.list.result': true, 'filter:api.search.video-channels.list.result': true,
// Filter params/result of the function that fetch video playlists according to the user search
'filter:api.search.video-playlists.list.params': true,
'filter:api.search.video-playlists.list.result': true,
// Filter form // Filter form
'filter:api.signup.registration.create.params': true, 'filter:api.signup.registration.create.params': true,

View File

@ -27,6 +27,10 @@ export const serverFilterHookObject = {
'filter:api.search.video-channels.local.list.result': true, 'filter:api.search.video-channels.local.list.result': true,
'filter:api.search.video-channels.index.list.params': true, 'filter:api.search.video-channels.index.list.params': true,
'filter:api.search.video-channels.index.list.result': true, 'filter:api.search.video-channels.index.list.result': true,
'filter:api.search.video-playlists.local.list.params': true,
'filter:api.search.video-playlists.local.list.result': true,
'filter:api.search.video-playlists.index.list.params': true,
'filter:api.search.video-playlists.index.list.result': true,
// Filter the result of the get function // Filter the result of the get function
// Used to get detailed video information (video watch page for example) // Used to get detailed video information (video watch page for example)

View File

@ -1,5 +1,6 @@
export * from './boolean-both-query.model' export * from './boolean-both-query.model'
export * from './search-target-query.model' export * from './search-target-query.model'
export * from './videos-common-query.model' export * from './videos-common-query.model'
export * from './videos-search-query.model'
export * from './video-channels-search-query.model' export * from './video-channels-search-query.model'
export * from './video-playlists-search-query.model'
export * from './videos-search-query.model'

View File

@ -1,4 +1,4 @@
import { SearchTargetQuery } from "./search-target-query.model" import { SearchTargetQuery } from './search-target-query.model'
export interface VideoChannelsSearchQuery extends SearchTargetQuery { export interface VideoChannelsSearchQuery extends SearchTargetQuery {
search: string search: string

View File

@ -0,0 +1,9 @@
import { SearchTargetQuery } from './search-target-query.model'
export interface VideoPlaylistsSearchQuery extends SearchTargetQuery {
search: string
start?: number
count?: number
sort?: string
}

View File

@ -53,7 +53,6 @@ export type ActivitypubHttpFetcherPayload = {
uri: string uri: string
type: FetchType type: FetchType
videoId?: number videoId?: number
accountId?: number
} }
export type ActivitypubHttpUnicastPayload = { export type ActivitypubHttpUnicastPayload = {

View File

@ -1,4 +1,4 @@
import { HttpStatusCode } from '@shared/core-utils' import { HttpStatusCode } from '../../core-utils'
import { OAuth2ErrorCode, ServerErrorCode } from './server-error-code.enum' import { OAuth2ErrorCode, ServerErrorCode } from './server-error-code.enum'
export interface PeerTubeProblemDocumentData { export interface PeerTubeProblemDocumentData {

View File

@ -8,17 +8,21 @@ export interface VideoPlaylist {
uuid: string uuid: string
isLocal: boolean isLocal: boolean
url: string
displayName: string displayName: string
description: string description: string
privacy: VideoConstant<VideoPlaylistPrivacy> privacy: VideoConstant<VideoPlaylistPrivacy>
thumbnailPath: string thumbnailPath: string
thumbnailUrl?: string
videosLength: number videosLength: number
type: VideoConstant<VideoPlaylistType> type: VideoConstant<VideoPlaylistType>
embedPath: string embedPath: string
embedUrl?: string
createdAt: Date | string createdAt: Date | string
updatedAt: Date | string updatedAt: Date | string

View File

@ -3584,6 +3584,47 @@ paths:
'500': '500':
description: search index unavailable description: search index unavailable
/search/video-playlists:
get:
tags:
- Search
summary: Search playlists
operationId: searchPlaylists
parameters:
- name: search
in: query
required: true
description: >
String to search. If the user can make a remote URI search, and the string is an URI then the
PeerTube instance will fetch the remote object and add it to its database. Then,
you can use the REST API to fetch the complete playlist information and interact with it.
schema:
type: string
- $ref: '#/components/parameters/start'
- $ref: '#/components/parameters/count'
- $ref: '#/components/parameters/searchTarget'
- $ref: '#/components/parameters/sort'
callbacks:
'searchTarget === search-index':
$ref: '#/components/callbacks/searchIndex'
responses:
'200':
description: successful operation
content:
application/json:
schema:
type: object
properties:
total:
type: integer
example: 1
data:
type: array
items:
$ref: '#/components/schemas/VideoPlaylist'
'500':
description: search index unavailable
/server/blocklist/accounts: /server/blocklist/accounts:
get: get:
tags: tags: