Add ability to search playlists
This commit is contained in:
parent
33eb19e519
commit
37a44fc915
79 changed files with 1652 additions and 549 deletions
|
@ -187,7 +187,7 @@ export class MyVideoPlaylistElementsComponent implements OnInit, OnDestroy {
|
|||
// Reload playlist thumbnail if the first element changed
|
||||
const newFirst = this.findFirst()
|
||||
if (oldFirst && newFirst && oldFirst.id !== newFirst.id) {
|
||||
this.playlist.refreshThumbnail()
|
||||
this.loadPlaylistInfo()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,8 +1,7 @@
|
|||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
|
||||
import { SearchComponent } from './search.component'
|
||||
import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
|
||||
import { ChannelLazyLoadResolver, PlaylistLazyLoadResolver, VideoLazyLoadResolver } from './shared'
|
||||
|
||||
const searchRoutes: Routes = [
|
||||
{
|
||||
|
@ -27,6 +26,13 @@ const searchRoutes: Routes = [
|
|||
resolve: {
|
||||
data: ChannelLazyLoadResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'lazy-load-playlist',
|
||||
component: SearchComponent,
|
||||
resolve: {
|
||||
data: PlaylistLazyLoadResolver
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
@ -59,10 +59,17 @@
|
|||
<div *ngIf="isVideo(result)" class="entry video">
|
||||
<my-video-miniature
|
||||
[video]="result" [user]="userMiniature" [displayAsRow]="true" [displayVideoActions]="!hideActions()"
|
||||
[displayOptions]="videoDisplayOptions" [videoLinkType]="getVideoLinkType()"
|
||||
[displayOptions]="videoDisplayOptions" [videoLinkType]="getLinkType()"
|
||||
(videoBlocked)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)"
|
||||
></my-video-miniature>
|
||||
</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>
|
||||
|
||||
</div>
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { forkJoin, of, Subscription } from 'rxjs'
|
||||
import { LinkType } from 'src/types/link.type'
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||
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 { Video, VideoChannel } from '@app/shared/shared-main'
|
||||
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'
|
||||
|
||||
@Component({
|
||||
|
@ -16,10 +18,9 @@ import { HTMLServerConfig, SearchTargetType } from '@shared/models'
|
|||
export class SearchComponent implements OnInit, OnDestroy {
|
||||
results: (Video | VideoChannel)[] = []
|
||||
|
||||
pagination: ComponentPagination = {
|
||||
pagination = {
|
||||
currentPage: 1,
|
||||
itemsPerPage: 10, // Only for videos, use another variable for channels
|
||||
totalItems: null
|
||||
totalItems: null as number
|
||||
}
|
||||
advancedSearch: AdvancedSearch = new AdvancedSearch()
|
||||
isSearchFilterCollapsed = true
|
||||
|
@ -45,6 +46,11 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||
private firstSearch = true
|
||||
|
||||
private channelsPerPage = 2
|
||||
private playlistsPerPage = 2
|
||||
private videosPerPage = 10
|
||||
|
||||
private hasMoreResults = true
|
||||
private isSearching = false
|
||||
|
||||
private lastSearchTarget: SearchTargetType
|
||||
|
||||
|
@ -104,77 +110,62 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||
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
|
||||
}
|
||||
|
||||
isVideo (v: VideoChannel | Video): v is Video {
|
||||
isVideo (v: VideoChannel | Video | VideoPlaylist): v is Video {
|
||||
return v instanceof Video
|
||||
}
|
||||
|
||||
isPlaylist (v: VideoChannel | Video | VideoPlaylist): v is VideoPlaylist {
|
||||
return v instanceof VideoPlaylist
|
||||
}
|
||||
|
||||
isUserLoggedIn () {
|
||||
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 () {
|
||||
this.isSearching = true
|
||||
|
||||
forkJoin([
|
||||
this.getVideosObs(),
|
||||
this.getVideoChannelObs()
|
||||
]).subscribe(
|
||||
([videosResult, videoChannelsResult]) => {
|
||||
this.results = this.results
|
||||
.concat(videoChannelsResult.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.getVideoChannelObs(),
|
||||
this.getVideoPlaylistObs(),
|
||||
this.getVideosObs()
|
||||
]).subscribe(results => {
|
||||
for (const result of results) {
|
||||
this.results = this.results.concat(result.data)
|
||||
}
|
||||
)
|
||||
|
||||
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 () {
|
||||
// Last page
|
||||
if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
|
||||
if (!this.hasMoreResults || this.isSearching) return
|
||||
|
||||
this.pagination.currentPage += 1
|
||||
this.search()
|
||||
|
@ -190,18 +181,33 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||
return this.advancedSearch.size()
|
||||
}
|
||||
|
||||
// Add VideoChannel for typings, but the template already checks "video" argument is a video
|
||||
removeVideoFromArray (video: Video | VideoChannel) {
|
||||
// Add VideoChannel/VideoPlaylist for typings, but the template already checks "video" argument is a video
|
||||
removeVideoFromArray (video: Video | VideoChannel | VideoPlaylist) {
|
||||
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 () {
|
||||
return this.getVideoLinkType() === 'external'
|
||||
return this.getLinkType() === 'external'
|
||||
}
|
||||
|
||||
getExternalChannelUrl (channel: VideoChannel) {
|
||||
// Same algorithm than videos
|
||||
if (this.getVideoLinkType() === 'external') {
|
||||
if (this.getLinkType() === 'external') {
|
||||
return channel.url
|
||||
}
|
||||
|
||||
|
@ -210,7 +216,7 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
getInternalChannelUrl (channel: VideoChannel) {
|
||||
const linkType = this.getVideoLinkType()
|
||||
const linkType = this.getLinkType()
|
||||
|
||||
if (linkType === 'internal') {
|
||||
return [ '/c', channel.nameWithHost ]
|
||||
|
@ -256,7 +262,7 @@ export class SearchComponent implements OnInit, OnDestroy {
|
|||
private getVideosObs () {
|
||||
const params = {
|
||||
search: this.currentSearch,
|
||||
componentPagination: this.pagination,
|
||||
componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.videosPerPage }),
|
||||
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 {
|
||||
const searchIndexConfig = this.serverConfig.search.searchIndex
|
||||
|
||||
|
|
|
@ -5,12 +5,12 @@ import { SharedMainModule } from '@app/shared/shared-main'
|
|||
import { SharedSearchModule } from '@app/shared/shared-search'
|
||||
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
|
||||
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 { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
|
||||
import { SearchFiltersComponent } from './search-filters.component'
|
||||
import { SearchRoutingModule } from './search-routing.module'
|
||||
import { SearchComponent } from './search.component'
|
||||
import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
|
||||
import { ChannelLazyLoadResolver, PlaylistLazyLoadResolver, VideoLazyLoadResolver } from './shared'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
@ -21,7 +21,8 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
|
|||
SharedFormModule,
|
||||
SharedActorImageModule,
|
||||
SharedUserSubscriptionModule,
|
||||
SharedVideoMiniatureModule
|
||||
SharedVideoMiniatureModule,
|
||||
SharedVideoPlaylistModule
|
||||
],
|
||||
|
||||
declarations: [
|
||||
|
@ -36,7 +37,8 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
|
|||
providers: [
|
||||
SearchService,
|
||||
VideoLazyLoadResolver,
|
||||
ChannelLazyLoadResolver
|
||||
ChannelLazyLoadResolver,
|
||||
PlaylistLazyLoadResolver
|
||||
]
|
||||
})
|
||||
export class SearchModule { }
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
import { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
|
||||
import { SearchService } from '@app/shared/shared-search'
|
||||
import { ResultList } from '@shared/models/result-list.model'
|
||||
|
||||
@Injectable()
|
||||
export class VideoLazyLoadResolver implements Resolve<any> {
|
||||
constructor (
|
||||
private router: Router,
|
||||
private searchService: SearchService
|
||||
) { }
|
||||
export abstract class AbstractLazyLoadResolver <T> implements Resolve<any> {
|
||||
protected router: Router
|
||||
|
||||
resolve (route: ActivatedRouteSnapshot) {
|
||||
const url = route.params.url
|
||||
|
@ -18,7 +14,7 @@ export class VideoLazyLoadResolver implements Resolve<any> {
|
|||
return this.router.navigateByUrl('/404')
|
||||
}
|
||||
|
||||
return this.searchService.searchVideos({ search: url })
|
||||
return this.finder(url)
|
||||
.pipe(
|
||||
map(result => {
|
||||
if (result.data.length !== 1) {
|
||||
|
@ -26,10 +22,13 @@ export class VideoLazyLoadResolver implements Resolve<any> {
|
|||
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
|
||||
}
|
24
client/src/app/+search/shared/channel-lazy-load.resolver.ts
Normal file
24
client/src/app/+search/shared/channel-lazy-load.resolver.ts
Normal 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
|
||||
}
|
||||
}
|
4
client/src/app/+search/shared/index.ts
Normal file
4
client/src/app/+search/shared/index.ts
Normal 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'
|
24
client/src/app/+search/shared/playlist-lazy-load.resolver.ts
Normal file
24
client/src/app/+search/shared/playlist-lazy-load.resolver.ts
Normal 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
|
||||
}
|
||||
}
|
24
client/src/app/+search/shared/video-lazy-load.resolver.ts
Normal file
24
client/src/app/+search/shared/video-lazy-load.resolver.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
<div class="d-inline-flex position-relative" id="typeahead-container">
|
||||
<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()"
|
||||
aria-label="Search" autocomplete="off"
|
||||
>
|
||||
|
|
|
@ -3,5 +3,6 @@ export * from './bytes.pipe'
|
|||
export * from './duration-formatter.pipe'
|
||||
export * from './from-now.pipe'
|
||||
export * from './infinite-scroller.directive'
|
||||
export * from './link.component'
|
||||
export * from './number-formatter.pipe'
|
||||
export * from './peertube-template.directive'
|
||||
|
|
|
@ -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>
|
|
@ -0,0 +1,7 @@
|
|||
a {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
position: inherit;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
}
|
17
client/src/app/shared/shared-main/angular/link.component.ts
Normal file
17
client/src/app/shared/shared-main/angular/link.component.ts
Normal 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
|
||||
}
|
|
@ -4,7 +4,7 @@ import { CommonModule, DatePipe } from '@angular/common'
|
|||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { ActivatedRouteSnapshot, RouterModule } from '@angular/router'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbButtonsModule,
|
||||
NgbCollapseModule,
|
||||
|
@ -24,6 +24,7 @@ import {
|
|||
DurationFormatterPipe,
|
||||
FromNowPipe,
|
||||
InfiniteScrollerDirective,
|
||||
LinkComponent,
|
||||
NumberFormatterPipe,
|
||||
PeerTubeTemplateDirective
|
||||
} from './angular'
|
||||
|
@ -35,11 +36,11 @@ import { FeedComponent } from './feeds'
|
|||
import { LoaderComponent, SmallLoaderComponent } from './loaders'
|
||||
import { HelpComponent, ListOverflowComponent, SimpleSearchInputComponent, TopMenuDropdownComponent } from './misc'
|
||||
import { PluginPlaceholderComponent } from './plugins'
|
||||
import { ActorRedirectGuard } from './router'
|
||||
import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
|
||||
import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
|
||||
import { VideoCaptionService } from './video-caption'
|
||||
import { VideoChannelService } from './video-channel'
|
||||
import { ActorRedirectGuard } from './router'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
@ -76,6 +77,7 @@ import { ActorRedirectGuard } from './router'
|
|||
|
||||
InfiniteScrollerDirective,
|
||||
PeerTubeTemplateDirective,
|
||||
LinkComponent,
|
||||
|
||||
ActionDropdownComponent,
|
||||
ButtonComponent,
|
||||
|
@ -130,6 +132,7 @@ import { ActorRedirectGuard } from './router'
|
|||
|
||||
InfiniteScrollerDirective,
|
||||
PeerTubeTemplateDirective,
|
||||
LinkComponent,
|
||||
|
||||
ActionDropdownComponent,
|
||||
ButtonComponent,
|
||||
|
|
|
@ -3,10 +3,17 @@ import { catchError, map, switchMap } from 'rxjs/operators'
|
|||
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/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 { 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 { VideoPlaylist, VideoPlaylistService } from '../shared-video-playlist'
|
||||
import { AdvancedSearch } from './advanced-search.model'
|
||||
|
||||
@Injectable()
|
||||
|
@ -17,7 +24,8 @@ export class SearchService {
|
|||
private authHttp: HttpClient,
|
||||
private restExtractor: RestExtractor,
|
||||
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
|
||||
const searchUrl = peertubeLocalStorage.getItem('search-url')
|
||||
|
@ -85,4 +93,34 @@ export class SearchService {
|
|||
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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,13 +21,12 @@
|
|||
></my-actor-avatar>
|
||||
|
||||
<div class="w-100 d-flex flex-column">
|
||||
<a *ngIf="!videoHref" tabindex="-1" class="video-miniature-name"
|
||||
[routerLink]="videoRouterLink" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
|
||||
>{{ video.name }}</a>
|
||||
|
||||
<a *ngIf="videoHref" tabindex="-1" class="video-miniature-name"
|
||||
[href]="videoHref" [target]="videoTarget" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
|
||||
>{{ video.name }}</a>
|
||||
<my-link
|
||||
[internalLink]="videoRouterLink" [href]="videoHref" [target]="videoTarget"
|
||||
[title]="video.name"class="video-miniature-name" [ngClass]="{ 'blur-filter': isVideoBlur }" tabindex="-1"
|
||||
>
|
||||
{{ video.name }}
|
||||
</my-link>
|
||||
|
||||
<span class="video-miniature-created-at-views">
|
||||
<my-date-toggle *ngIf="displayOptions.date" [date]="video.publishedAt"></my-date-toggle>
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '@angular/core'
|
||||
import { AuthService, ScreenService, ServerService, User } from '@app/core'
|
||||
import { HTMLServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '@shared/models'
|
||||
import { LinkType } from '../../../types/link.type'
|
||||
import { ActorAvatarSize } from '../shared-actor-image/actor-avatar.component'
|
||||
import { Video } from '../shared-main'
|
||||
import { VideoPlaylistService } from '../shared-video-playlist'
|
||||
|
@ -28,8 +29,6 @@ export type MiniatureDisplayOptions = {
|
|||
blacklistInfo?: boolean
|
||||
nsfw?: boolean
|
||||
}
|
||||
export type VideoLinkType = 'internal' | 'lazy-load' | 'external'
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-miniature',
|
||||
styleUrls: [ './video-miniature.component.scss' ],
|
||||
|
@ -56,7 +55,7 @@ export class VideoMiniatureComponent implements OnInit {
|
|||
|
||||
@Input() displayAsRow = false
|
||||
|
||||
@Input() videoLinkType: VideoLinkType = 'internal'
|
||||
@Input() videoLinkType: LinkType = 'internal'
|
||||
|
||||
@Output() videoBlocked = new EventEmitter()
|
||||
@Output() videoUnblocked = new EventEmitter()
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage, 'display-as-row': displayAsRow }">
|
||||
<a
|
||||
[routerLink]="getPlaylistUrl()" [attr.title]="playlist.description"
|
||||
class="miniature-thumbnail"
|
||||
<my-link
|
||||
[internalLink]="routerLink" [href]="playlistHref" [target]="playlistTarget"
|
||||
[title]="playlist.description" class="miniature-thumbnail"
|
||||
>
|
||||
<img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
|
||||
|
||||
|
@ -12,12 +12,15 @@
|
|||
<div class="play-overlay">
|
||||
<div class="icon"></div>
|
||||
</div>
|
||||
</a>
|
||||
</my-link>
|
||||
|
||||
<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 }}
|
||||
</a>
|
||||
</my-link>
|
||||
|
||||
<a i18n [routerLink]="[ '/c', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy">
|
||||
{{ playlist.videoChannelBy }}
|
||||
|
|
|
@ -75,7 +75,10 @@
|
|||
}
|
||||
|
||||
.miniature:not(.display-as-row) {
|
||||
|
||||
.miniature-thumbnail {
|
||||
@include block-ratio($selector: '::ng-deep a');
|
||||
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
||||
@Component({
|
||||
|
@ -6,18 +7,52 @@ import { VideoPlaylist } from './video-playlist.model'
|
|||
styleUrls: [ './video-playlist-miniature.component.scss' ],
|
||||
templateUrl: './video-playlist-miniature.component.html'
|
||||
})
|
||||
export class VideoPlaylistMiniatureComponent {
|
||||
export class VideoPlaylistMiniatureComponent implements OnInit {
|
||||
@Input() playlist: VideoPlaylist
|
||||
|
||||
@Input() toManage = false
|
||||
|
||||
@Input() displayChannel = false
|
||||
@Input() displayDescription = false
|
||||
@Input() displayPrivacy = false
|
||||
@Input() displayAsRow = false
|
||||
|
||||
getPlaylistUrl () {
|
||||
if (this.toManage) return [ '/my-library/video-playlists', this.playlist.uuid ]
|
||||
if (this.playlist.videosLength === 0) return null
|
||||
@Input() linkType: LinkType = 'internal'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 {
|
||||
AccountSummary,
|
||||
|
@ -15,12 +15,12 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
|||
uuid: string
|
||||
isLocal: boolean
|
||||
|
||||
url: string
|
||||
|
||||
displayName: string
|
||||
description: string
|
||||
privacy: VideoConstant<VideoPlaylistPrivacy>
|
||||
|
||||
thumbnailPath: string
|
||||
|
||||
videosLength: number
|
||||
|
||||
type: VideoConstant<VideoPlaylistType>
|
||||
|
@ -31,6 +31,7 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
|||
ownerAccount: AccountSummary
|
||||
videoChannel?: VideoChannelSummary
|
||||
|
||||
thumbnailPath: string
|
||||
thumbnailUrl: string
|
||||
|
||||
embedPath: string
|
||||
|
@ -40,14 +41,12 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
|||
|
||||
videoChannelBy?: string
|
||||
|
||||
private thumbnailVersion: number
|
||||
private originThumbnailUrl: string
|
||||
|
||||
constructor (hash: ServerVideoPlaylist, translations: {}) {
|
||||
const absoluteAPIUrl = getAbsoluteAPIUrl()
|
||||
|
||||
this.id = hash.id
|
||||
this.uuid = hash.uuid
|
||||
this.url = hash.url
|
||||
this.isLocal = hash.isLocal
|
||||
|
||||
this.displayName = hash.displayName
|
||||
|
@ -57,15 +56,12 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
|||
|
||||
this.thumbnailPath = hash.thumbnailPath
|
||||
|
||||
if (this.thumbnailPath) {
|
||||
this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
|
||||
this.originThumbnailUrl = this.thumbnailUrl
|
||||
} else {
|
||||
this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg'
|
||||
}
|
||||
this.thumbnailUrl = this.thumbnailPath
|
||||
? hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
|
||||
: absoluteAPIUrl + '/client/assets/images/default-playlist.jpg'
|
||||
|
||||
this.embedPath = hash.embedPath
|
||||
this.embedUrl = getAbsoluteEmbedUrl() + hash.embedPath
|
||||
this.embedUrl = hash.embedUrl || (getAbsoluteEmbedUrl() + hash.embedPath)
|
||||
|
||||
this.videosLength = hash.videosLength
|
||||
|
||||
|
@ -88,13 +84,4 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -880,6 +880,7 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
|
1
client/src/types/link.type.ts
Normal file
1
client/src/types/link.type.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type LinkType = 'internal' | 'lazy-load' | 'external'
|
|
@ -155,7 +155,8 @@ activityPubClientRouter.get('/redundancy/streaming-playlists/:streamingPlaylistT
|
|||
asyncMiddleware(videoRedundancyController)
|
||||
)
|
||||
|
||||
activityPubClientRouter.get('/video-playlists/:playlistId',
|
||||
activityPubClientRouter.get(
|
||||
[ '/video-playlists/:playlistId', '/videos/watch/playlist/:playlistId', '/w/p/:playlistId' ],
|
||||
executeIfActivityPub,
|
||||
asyncMiddleware(videoPlaylistsGetValidator('all')),
|
||||
asyncMiddleware(videoPlaylistController)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
16
server/controllers/api/search/index.ts
Normal file
16
server/controllers/api/search/index.ts
Normal 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
|
||||
}
|
150
server/controllers/api/search/search-video-channels.ts
Normal file
150
server/controllers/api/search/search-video-channels.ts
Normal 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/')
|
||||
}
|
129
server/controllers/api/search/search-video-playlists.ts
Normal file
129
server/controllers/api/search/search-video-playlists.ts
Normal 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/')
|
||||
}
|
153
server/controllers/api/search/search-videos.ts
Normal file
153
server/controllers/api/search/search-videos.ts
Normal 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/')
|
||||
}
|
|
@ -32,7 +32,7 @@ import {
|
|||
videoChannelsUpdateValidator,
|
||||
videoPlaylistsSortValidator
|
||||
} 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 { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists'
|
||||
import { AccountModel } from '../../models/account/account'
|
||||
|
@ -51,7 +51,7 @@ videoChannelRouter.get('/',
|
|||
videoChannelsSortValidator,
|
||||
setDefaultSort,
|
||||
setDefaultPagination,
|
||||
videoChannelsOwnSearchValidator,
|
||||
videoChannelsListValidator,
|
||||
asyncMiddleware(listVideoChannels)
|
||||
)
|
||||
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import * as express from 'express'
|
||||
import { join } from 'path'
|
||||
import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists'
|
||||
import { getServerActor } from '@server/models/application/application'
|
||||
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 { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.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 { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send'
|
||||
import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url'
|
||||
import { JobQueue } from '../../lib/job-queue'
|
||||
import { updatePlaylistMiniatureFromExisting } from '../../lib/thumbnail'
|
||||
import {
|
||||
asyncMiddleware,
|
||||
|
@ -42,7 +43,6 @@ import {
|
|||
import { AccountModel } from '../../models/account/account'
|
||||
import { VideoPlaylistModel } from '../../models/video/video-playlist'
|
||||
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 })
|
||||
|
||||
|
@ -144,9 +144,7 @@ async function listVideoPlaylists (req: express.Request, res: express.Response)
|
|||
function getVideoPlaylist (req: express.Request, res: express.Response) {
|
||||
const videoPlaylist = res.locals.videoPlaylistSummary
|
||||
|
||||
if (videoPlaylist.isOutdated()) {
|
||||
JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: videoPlaylist.url } })
|
||||
}
|
||||
scheduleRefreshIfNeeded(videoPlaylist)
|
||||
|
||||
return res.json(videoPlaylist.toFormattedJSON())
|
||||
}
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
import { exists, isDateValid } from '../misc'
|
||||
import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
|
||||
import validator from 'validator'
|
||||
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'
|
||||
|
||||
function isPlaylistObjectValid (object: PlaylistObject) {
|
||||
return exists(object) &&
|
||||
object.type === 'Playlist' &&
|
||||
validator.isInt(object.totalItems + '') &&
|
||||
isVideoPlaylistNameValid(object.name) &&
|
||||
isUUIDValid(object.uuid) &&
|
||||
isDateValid(object.published) &&
|
||||
isDateValid(object.updated)
|
||||
}
|
||||
|
|
|
@ -77,6 +77,7 @@ const SORTABLE_COLUMNS = {
|
|||
// Don't forget to update peertube-search-index with the same values
|
||||
VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
|
||||
VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
|
||||
VIDEO_PLAYLISTS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
|
||||
|
||||
ABUSES: [ 'id', 'createdAt', 'state' ],
|
||||
|
||||
|
|
|
@ -116,7 +116,7 @@ async function scheduleOutboxFetchIfNeeded (actor: MActor, created: boolean, ref
|
|||
async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) {
|
||||
// We created a new account: fetch the playlists
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import * as Bluebird from 'bluebird'
|
||||
import { getAPId } from '@server/helpers/activitypub'
|
||||
import { isArray } from '@server/helpers/custom-validators/misc'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
||||
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 { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
|
||||
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 { PlaylistObject } from '@shared/models'
|
||||
import { getOrCreateAPActor } from '../actors'
|
||||
|
@ -19,11 +21,9 @@ import {
|
|||
playlistObjectToDBAttributes
|
||||
} from './shared'
|
||||
|
||||
import Bluebird = require('bluebird')
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'video-playlist')
|
||||
|
||||
async function createAccountPlaylists (playlistUrls: string[], account: MAccountDefault) {
|
||||
async function createAccountPlaylists (playlistUrls: string[]) {
|
||||
await Bluebird.map(playlistUrls, async playlistUrl => {
|
||||
try {
|
||||
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.`)
|
||||
}
|
||||
|
||||
return createOrUpdateVideoPlaylist(playlistObject, account, playlistObject.to)
|
||||
return createOrUpdateVideoPlaylist(playlistObject)
|
||||
} catch (err) {
|
||||
logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) })
|
||||
}
|
||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||
}
|
||||
|
||||
async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) {
|
||||
const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to)
|
||||
async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, to?: string[]) {
|
||||
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)
|
||||
|
||||
|
@ -56,7 +56,10 @@ async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAc
|
|||
|
||||
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>) {
|
||||
if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) return
|
||||
async function setVideoChannel (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) {
|
||||
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) {
|
||||
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.ownerAccountId = actor.VideoChannel.Account.id
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
return undefined
|
||||
return elementsToCreate.length
|
||||
}
|
||||
|
||||
async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) {
|
||||
|
|
35
server/lib/activitypub/playlists/get.ts
Normal file
35
server/lib/activitypub/playlists/get.ts
Normal 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
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
export * from './get'
|
||||
export * from './create-update'
|
||||
export * from './refresh'
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
||||
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 { createOrUpdateVideoPlaylist } from './create-update'
|
||||
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> {
|
||||
if (!videoPlaylist.isOutdated()) return videoPlaylist
|
||||
|
||||
|
@ -22,8 +29,7 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner)
|
|||
return videoPlaylist
|
||||
}
|
||||
|
||||
const byAccount = videoPlaylist.OwnerAccount
|
||||
await createOrUpdateVideoPlaylist(playlistObject, byAccount, playlistObject.to)
|
||||
await createOrUpdateVideoPlaylist(playlistObject)
|
||||
|
||||
return videoPlaylist
|
||||
} catch (err) {
|
||||
|
@ -42,5 +48,6 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner)
|
|||
}
|
||||
|
||||
export {
|
||||
scheduleRefreshIfNeeded,
|
||||
refreshVideoPlaylistIfNeeded
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { ACTIVITY_PUB } from '@server/initializers/constants'
|
||||
import { VideoPlaylistModel } from '@server/models/video/video-playlist'
|
||||
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 { 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)
|
||||
? VideoPlaylistPrivacy.PUBLIC
|
||||
: VideoPlaylistPrivacy.UNLISTED
|
||||
|
@ -16,7 +16,7 @@ function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount
|
|||
privacy,
|
||||
url: playlistObject.id,
|
||||
uuid: playlistObject.uuid,
|
||||
ownerAccountId: byAccount.id,
|
||||
ownerAccountId: null,
|
||||
videoChannelId: null,
|
||||
createdAt: new Date(playlistObject.published),
|
||||
updatedAt: new Date(playlistObject.updated)
|
||||
|
|
|
@ -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)
|
||||
|
||||
await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to)
|
||||
await createOrUpdateVideoPlaylist(playlistObject, activity.to)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to)
|
||||
await createOrUpdateVideoPlaylist(playlistObject, activity.to)
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { retryTransactionWrapper } from '@server/helpers/database-utils'
|
|||
import { JobQueue } from '@server/lib/job-queue'
|
||||
import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders'
|
||||
import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models'
|
||||
import { APObject } from '@shared/models'
|
||||
import { refreshVideoIfNeeded } from './refresh'
|
||||
import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared'
|
||||
|
||||
|
@ -13,21 +14,21 @@ type GetVideoResult <T> = Promise<{
|
|||
}>
|
||||
|
||||
type GetVideoParamAll = {
|
||||
videoObject: { id: string } | string
|
||||
videoObject: APObject
|
||||
syncParam?: SyncParam
|
||||
fetchType?: 'all'
|
||||
allowRefresh?: boolean
|
||||
}
|
||||
|
||||
type GetVideoParamImmutable = {
|
||||
videoObject: { id: string } | string
|
||||
videoObject: APObject
|
||||
syncParam?: SyncParam
|
||||
fetchType: 'only-immutable-attributes'
|
||||
allowRefresh: false
|
||||
}
|
||||
|
||||
type GetVideoParamOther = {
|
||||
videoObject: { id: string } | string
|
||||
videoObject: APObject
|
||||
syncParam?: SyncParam
|
||||
fetchType?: 'all' | 'only-video'
|
||||
allowRefresh?: boolean
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
import * as Bull from 'bull'
|
||||
import { ActivitypubHttpFetcherPayload, FetchType } from '@shared/models'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
import { AccountModel } from '../../../models/account/account'
|
||||
import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
|
||||
import { VideoModel } from '../../../models/video/video'
|
||||
import { VideoCommentModel } from '../../../models/video/video-comment'
|
||||
import { VideoShareModel } from '../../../models/video/video-share'
|
||||
import { MAccountDefault, MVideoFullLight } from '../../../types/models'
|
||||
import { MVideoFullLight } from '../../../types/models'
|
||||
import { crawlCollectionPage } from '../../activitypub/crawl'
|
||||
import { createAccountPlaylists } from '../../activitypub/playlists'
|
||||
import { processActivities } from '../../activitypub/process'
|
||||
|
@ -22,16 +21,13 @@ async function processActivityPubHttpFetcher (job: Bull.Job) {
|
|||
let video: MVideoFullLight
|
||||
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> } = {
|
||||
'activity': items => processActivities(items, { outboxUrl: payload.uri, fromFetch: true }),
|
||||
'video-likes': items => createRates(items, video, 'like'),
|
||||
'video-dislikes': items => createRates(items, video, 'dislike'),
|
||||
'video-shares': items => addVideoShares(items, video),
|
||||
'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> } = {
|
||||
|
|
50
server/lib/search.ts
Normal file
50
server/lib/search.ts
Normal 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
|
||||
}
|
|
@ -49,11 +49,12 @@ const videoChannelsListSearchValidator = [
|
|||
}
|
||||
]
|
||||
|
||||
const videoChannelsOwnSearchValidator = [
|
||||
query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
|
||||
const videoPlaylistsListSearchValidator = [
|
||||
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) => {
|
||||
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
|
||||
|
||||
|
@ -66,5 +67,5 @@ const videoChannelsOwnSearchValidator = [
|
|||
export {
|
||||
videosSearchValidator,
|
||||
videoChannelsListSearchValidator,
|
||||
videoChannelsOwnSearchValidator
|
||||
videoPlaylistsListSearchValidator
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ const SORTABLE_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ABUSES)
|
|||
const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
|
||||
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_PLAYLISTS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
|
||||
const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS)
|
||||
const SORTABLE_VIDEO_COMMENTS_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 videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_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 videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
|
||||
const videoRatesSortValidator = checkSort(SORTABLE_VIDEO_RATES_COLUMNS)
|
||||
|
@ -75,5 +77,6 @@ export {
|
|||
userNotificationsSortValidator,
|
||||
videoPlaylistsSortValidator,
|
||||
videoRedundanciesSortValidator,
|
||||
videoPlaylistsSearchSortValidator,
|
||||
pluginsSortValidator
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
@ -148,6 +160,7 @@ export {
|
|||
videoChannelsUpdateValidator,
|
||||
videoChannelsRemoveValidator,
|
||||
videoChannelsNameWithHostValidator,
|
||||
videoChannelsListValidator,
|
||||
localVideoChannelValidator,
|
||||
videoChannelStatsValidator
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ export class AbstractVideosQueryBuilder {
|
|||
logging: options.logging,
|
||||
replacements: this.replacements,
|
||||
type: QueryTypes.SELECT as QueryTypes.SELECT,
|
||||
next: false
|
||||
nest: false
|
||||
}
|
||||
|
||||
return this.sequelize.query<any>(this.query, queryOptions)
|
||||
|
|
|
@ -434,8 +434,8 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
|
|||
sort: string
|
||||
}) {
|
||||
const attributesInclude = []
|
||||
const escapedSearch = VideoModel.sequelize.escape(options.search)
|
||||
const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
|
||||
const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
|
||||
const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
|
||||
attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
|
||||
|
||||
const query = {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { join } from 'path'
|
||||
import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
|
||||
import { FindOptions, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
|
||||
import {
|
||||
AllowNull,
|
||||
BelongsTo,
|
||||
|
@ -53,7 +53,15 @@ import {
|
|||
} from '../../types/models/video/video-playlist'
|
||||
import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
|
||||
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 { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
|
||||
import { VideoPlaylistElementModel } from './video-playlist-element'
|
||||
|
@ -74,6 +82,11 @@ type AvailableForListOptions = {
|
|||
videoChannelId?: number
|
||||
listMyPlaylists?: boolean
|
||||
search?: string
|
||||
withVideos?: boolean
|
||||
}
|
||||
|
||||
function getVideoLengthSelect () {
|
||||
return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"'
|
||||
}
|
||||
|
||||
@Scopes(() => ({
|
||||
|
@ -89,7 +102,7 @@ type AvailableForListOptions = {
|
|||
attributes: {
|
||||
include: [
|
||||
[
|
||||
literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
|
||||
literal(`(${getVideoLengthSelect()})`),
|
||||
'videosLength'
|
||||
]
|
||||
]
|
||||
|
@ -178,11 +191,28 @@ type AvailableForListOptions = {
|
|||
})
|
||||
}
|
||||
|
||||
if (options.withVideos === true) {
|
||||
whereAnd.push(
|
||||
literal(`(${getVideoLengthSelect()}) != 0`)
|
||||
)
|
||||
}
|
||||
|
||||
const attributesInclude = []
|
||||
|
||||
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({
|
||||
name: {
|
||||
[Op.iLike]: '%' + options.search + '%'
|
||||
}
|
||||
[Op.or]: [
|
||||
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 {
|
||||
attributes: {
|
||||
include: attributesInclude
|
||||
},
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
|
@ -211,6 +244,8 @@ type AvailableForListOptions = {
|
|||
@Table({
|
||||
tableName: 'videoPlaylist',
|
||||
indexes: [
|
||||
buildTrigramSearchIndex('video_playlist_name_trigram', 'name'),
|
||||
|
||||
{
|
||||
fields: [ 'ownerAccountId' ]
|
||||
},
|
||||
|
@ -314,6 +349,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
|
|||
videoChannelId?: number
|
||||
listMyPlaylists?: boolean
|
||||
search?: string
|
||||
withVideos?: boolean // false by default
|
||||
}) {
|
||||
const query = {
|
||||
offset: options.start,
|
||||
|
@ -331,7 +367,8 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
|
|||
accountId: options.accountId,
|
||||
videoChannelId: options.videoChannelId,
|
||||
listMyPlaylists: options.listMyPlaylists,
|
||||
search: options.search
|
||||
search: options.search,
|
||||
withVideos: options.withVideos || false
|
||||
} 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) {
|
||||
const where = {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
|
||||
}
|
||||
|
@ -535,6 +599,10 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
|
|||
return setAsUpdated('videoPlaylist', this.id)
|
||||
}
|
||||
|
||||
setVideosLength (videosLength: number) {
|
||||
this.set('videosLength' as any, videosLength, { raw: true })
|
||||
}
|
||||
|
||||
isOwned () {
|
||||
return this.OwnerAccount.isOwned()
|
||||
}
|
||||
|
@ -551,6 +619,8 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
|
|||
uuid: this.uuid,
|
||||
isLocal: this.isOwned(),
|
||||
|
||||
url: this.url,
|
||||
|
||||
displayName: this.name,
|
||||
description: this.description,
|
||||
privacy: {
|
||||
|
|
|
@ -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 () {
|
||||
const path = '/api/v1/search/video-channels/'
|
||||
|
||||
|
@ -171,6 +195,7 @@ describe('Test videos API validator', function () {
|
|||
|
||||
const query = { search: 'coucou' }
|
||||
const paths = [
|
||||
'/api/v1/search/video-playlists/',
|
||||
'/api/v1/search/video-channels/',
|
||||
'/api/v1/search/videos/'
|
||||
]
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import './search-activitypub-video-playlists'
|
||||
import './search-activitypub-video-channels'
|
||||
import './search-activitypub-videos'
|
||||
import './search-index'
|
||||
import './search-videos'
|
||||
import './search-channels'
|
||||
import './search-index'
|
||||
import './search-playlists'
|
||||
import './search-videos'
|
||||
|
|
|
@ -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 () {
|
||||
const searches = [
|
||||
'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
|
||||
]
|
||||
|
||||
|
|
212
server/tests/api/search/search-activitypub-video-playlists.ts
Normal file
212
server/tests/api/search/search-activitypub-video-playlists.ts
Normal 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)
|
||||
})
|
||||
})
|
|
@ -77,14 +77,33 @@ describe('Test ActivityPub videos search', function () {
|
|||
expect(res.body.data[0].name).to.equal('video 1 on server 1')
|
||||
})
|
||||
|
||||
it('Should search a remote video', async function () {
|
||||
const search = 'http://localhost:' + servers[1].port + '/videos/watch/' + videoServer2UUID
|
||||
const res = await searchVideoWithToken(servers[0].url, search, servers[0].accessToken)
|
||||
it('Should search a local video with an alternative URL', async function () {
|
||||
const search = 'http://localhost:' + servers[0].port + '/w/' + videoServer1UUID
|
||||
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)
|
||||
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')
|
||||
for (const res of [ res1, res2 ]) {
|
||||
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 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 () {
|
||||
|
@ -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 () {
|
||||
this.timeout(60000)
|
||||
this.timeout(120000)
|
||||
|
||||
const channelAttributes = {
|
||||
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 () {
|
||||
this.timeout(60000)
|
||||
this.timeout(120000)
|
||||
|
||||
await removeVideo(servers[1].url, servers[1].accessToken, videoServer2UUID)
|
||||
|
||||
|
|
|
@ -2,19 +2,21 @@
|
|||
|
||||
import 'mocha'
|
||||
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 {
|
||||
advancedVideoPlaylistSearch,
|
||||
advancedVideosSearch,
|
||||
cleanupTests,
|
||||
flushAndRunServer,
|
||||
immutableAssign,
|
||||
searchVideo,
|
||||
searchVideoPlaylists,
|
||||
ServerInfo,
|
||||
setAccessTokensToServers,
|
||||
updateCustomSubConfig,
|
||||
uploadVideo,
|
||||
advancedVideosSearch,
|
||||
immutableAssign
|
||||
uploadVideo
|
||||
} 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
|
||||
|
||||
|
@ -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 () {
|
||||
await cleanupTests([ server ])
|
||||
})
|
||||
|
|
128
server/tests/api/search/search-playlists.ts
Normal file
128
server/tests/api/search/search-playlists.ts
Normal 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 ])
|
||||
})
|
||||
})
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "peertube-plugin-test-two",
|
||||
"name": "peertube-plugin-test-filter-translations",
|
||||
"version": "0.0.1",
|
||||
"description": "Plugin test 2",
|
||||
"description": "Plugin test filter and translations",
|
||||
"engine": {
|
||||
"peertube": ">=1.3.0"
|
||||
},
|
|
@ -241,6 +241,10 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
|
|||
'filter:api.search.video-channels.local.list.result',
|
||||
'filter:api.search.video-channels.index.list.params',
|
||||
'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) {
|
||||
|
|
|
@ -8,6 +8,7 @@ import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-code
|
|||
import {
|
||||
addVideoCommentReply,
|
||||
addVideoCommentThread,
|
||||
advancedVideoPlaylistSearch,
|
||||
advancedVideosSearch,
|
||||
createLive,
|
||||
createVideoPlaylist,
|
||||
|
@ -71,7 +72,7 @@ describe('Test plugin filter hooks', function () {
|
|||
await installPlugin({
|
||||
url: servers[0].url,
|
||||
accessToken: servers[0].accessToken,
|
||||
path: getPluginTestPath('-two')
|
||||
path: getPluginTestPath('-filter-translations')
|
||||
})
|
||||
|
||||
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.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 () {
|
||||
|
|
|
@ -31,7 +31,7 @@ describe('Test plugin translations', function () {
|
|||
await installPlugin({
|
||||
url: server.url,
|
||||
accessToken: server.accessToken,
|
||||
path: getPluginTestPath('-two')
|
||||
path: getPluginTestPath('-filter-translations')
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -48,7 +48,7 @@ describe('Test plugin translations', function () {
|
|||
'peertube-plugin-test': {
|
||||
Hi: 'Coucou'
|
||||
},
|
||||
'peertube-plugin-test-two': {
|
||||
'peertube-plugin-test-filter-translations': {
|
||||
'Hello world': 'Bonjour le monde'
|
||||
}
|
||||
})
|
||||
|
@ -58,14 +58,14 @@ describe('Test plugin translations', function () {
|
|||
const res = await getPluginTranslations({ url: server.url, locale: 'it-IT' })
|
||||
|
||||
expect(res.body).to.deep.equal({
|
||||
'peertube-plugin-test-two': {
|
||||
'peertube-plugin-test-filter-translations': {
|
||||
'Hello world': 'Ciao, mondo!'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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' })
|
||||
|
|
|
@ -69,7 +69,7 @@ export type MVideoPlaylistAccountChannelDefault =
|
|||
// With all associations
|
||||
|
||||
export type MVideoPlaylistFull =
|
||||
MVideoPlaylist &
|
||||
MVideoPlaylistVideosLength &
|
||||
Use<'OwnerAccount', MAccountDefault> &
|
||||
Use<'VideoChannel', MChannelDefault> &
|
||||
Use<'Thumbnail', MThumbnail>
|
||||
|
@ -84,7 +84,7 @@ export type MVideoPlaylistAccountChannelSummary =
|
|||
Use<'VideoChannel', MChannelSummary>
|
||||
|
||||
export type MVideoPlaylistFullSummary =
|
||||
MVideoPlaylist &
|
||||
MVideoPlaylistVideosLength &
|
||||
Use<'Thumbnail', MThumbnail> &
|
||||
Use<'OwnerAccount', MAccountSummary> &
|
||||
Use<'VideoChannel', MChannelSummary>
|
||||
|
|
|
@ -19,6 +19,8 @@ export * from './plugins/mock-blocklist'
|
|||
export * from './requests/check-api-params'
|
||||
export * from './requests/requests'
|
||||
|
||||
export * from './search/video-channels'
|
||||
export * from './search/video-playlists'
|
||||
export * from './search/videos'
|
||||
|
||||
export * from './server/activitypub'
|
||||
|
|
36
shared/extra-utils/search/video-playlists.ts
Normal file
36
shared/extra-utils/search/video-playlists.ts
Normal 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
|
||||
}
|
|
@ -37,9 +37,12 @@ export const clientFilterHookObject = {
|
|||
// 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.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.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:api.signup.registration.create.params': true,
|
||||
|
|
|
@ -27,6 +27,10 @@ export const serverFilterHookObject = {
|
|||
'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.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
|
||||
// Used to get detailed video information (video watch page for example)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
export * from './boolean-both-query.model'
|
||||
export * from './search-target-query.model'
|
||||
export * from './videos-common-query.model'
|
||||
export * from './videos-search-query.model'
|
||||
export * from './video-channels-search-query.model'
|
||||
export * from './video-playlists-search-query.model'
|
||||
export * from './videos-search-query.model'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SearchTargetQuery } from "./search-target-query.model"
|
||||
import { SearchTargetQuery } from './search-target-query.model'
|
||||
|
||||
export interface VideoChannelsSearchQuery extends SearchTargetQuery {
|
||||
search: string
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import { SearchTargetQuery } from './search-target-query.model'
|
||||
|
||||
export interface VideoPlaylistsSearchQuery extends SearchTargetQuery {
|
||||
search: string
|
||||
|
||||
start?: number
|
||||
count?: number
|
||||
sort?: string
|
||||
}
|
|
@ -53,7 +53,6 @@ export type ActivitypubHttpFetcherPayload = {
|
|||
uri: string
|
||||
type: FetchType
|
||||
videoId?: number
|
||||
accountId?: number
|
||||
}
|
||||
|
||||
export type ActivitypubHttpUnicastPayload = {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { HttpStatusCode } from '@shared/core-utils'
|
||||
import { HttpStatusCode } from '../../core-utils'
|
||||
import { OAuth2ErrorCode, ServerErrorCode } from './server-error-code.enum'
|
||||
|
||||
export interface PeerTubeProblemDocumentData {
|
||||
|
|
|
@ -8,17 +8,21 @@ export interface VideoPlaylist {
|
|||
uuid: string
|
||||
isLocal: boolean
|
||||
|
||||
url: string
|
||||
|
||||
displayName: string
|
||||
description: string
|
||||
privacy: VideoConstant<VideoPlaylistPrivacy>
|
||||
|
||||
thumbnailPath: string
|
||||
thumbnailUrl?: string
|
||||
|
||||
videosLength: number
|
||||
|
||||
type: VideoConstant<VideoPlaylistType>
|
||||
|
||||
embedPath: string
|
||||
embedUrl?: string
|
||||
|
||||
createdAt: Date | string
|
||||
updatedAt: Date | string
|
||||
|
|
|
@ -3584,6 +3584,47 @@ paths:
|
|||
'500':
|
||||
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:
|
||||
get:
|
||||
tags:
|
||||
|
|
Loading…
Reference in a new issue