Add ability to search playlists
This commit is contained in:
parent
33eb19e519
commit
37a44fc915
|
@ -187,7 +187,7 @@ export class MyVideoPlaylistElementsComponent implements OnInit, OnDestroy {
|
||||||
// Reload playlist thumbnail if the first element changed
|
// Reload playlist thumbnail if the first element changed
|
||||||
const newFirst = this.findFirst()
|
const newFirst = this.findFirst()
|
||||||
if (oldFirst && newFirst && oldFirst.id !== newFirst.id) {
|
if (oldFirst && newFirst && oldFirst.id !== newFirst.id) {
|
||||||
this.playlist.refreshThumbnail()
|
this.loadPlaylistInfo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 { NgModule } from '@angular/core'
|
||||||
import { RouterModule, Routes } from '@angular/router'
|
import { RouterModule, Routes } from '@angular/router'
|
||||||
import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
|
|
||||||
import { SearchComponent } from './search.component'
|
import { SearchComponent } from './search.component'
|
||||||
import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
|
import { ChannelLazyLoadResolver, PlaylistLazyLoadResolver, VideoLazyLoadResolver } from './shared'
|
||||||
|
|
||||||
const searchRoutes: Routes = [
|
const searchRoutes: Routes = [
|
||||||
{
|
{
|
||||||
|
@ -27,6 +26,13 @@ const searchRoutes: Routes = [
|
||||||
resolve: {
|
resolve: {
|
||||||
data: ChannelLazyLoadResolver
|
data: ChannelLazyLoadResolver
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'lazy-load-playlist',
|
||||||
|
component: SearchComponent,
|
||||||
|
resolve: {
|
||||||
|
data: PlaylistLazyLoadResolver
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -59,10 +59,17 @@
|
||||||
<div *ngIf="isVideo(result)" class="entry video">
|
<div *ngIf="isVideo(result)" class="entry video">
|
||||||
<my-video-miniature
|
<my-video-miniature
|
||||||
[video]="result" [user]="userMiniature" [displayAsRow]="true" [displayVideoActions]="!hideActions()"
|
[video]="result" [user]="userMiniature" [displayAsRow]="true" [displayVideoActions]="!hideActions()"
|
||||||
[displayOptions]="videoDisplayOptions" [videoLinkType]="getVideoLinkType()"
|
[displayOptions]="videoDisplayOptions" [videoLinkType]="getLinkType()"
|
||||||
(videoBlocked)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)"
|
(videoBlocked)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)"
|
||||||
></my-video-miniature>
|
></my-video-miniature>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="isPlaylist(result)" class="entry video-playlist">
|
||||||
|
<my-video-playlist-miniature
|
||||||
|
[playlist]="result" [displayAsRow]="true" [displayChannel]="true"
|
||||||
|
[linkType]="getLinkType()"
|
||||||
|
></my-video-playlist-miniature>
|
||||||
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import { forkJoin, of, Subscription } from 'rxjs'
|
import { forkJoin, of, Subscription } from 'rxjs'
|
||||||
|
import { LinkType } from 'src/types/link.type'
|
||||||
import { Component, OnDestroy, OnInit } from '@angular/core'
|
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { AuthService, ComponentPagination, HooksService, MetaService, Notifier, ServerService, User, UserService } from '@app/core'
|
import { AuthService, HooksService, MetaService, Notifier, ServerService, User, UserService } from '@app/core'
|
||||||
import { immutableAssign } from '@app/helpers'
|
import { immutableAssign } from '@app/helpers'
|
||||||
import { Video, VideoChannel } from '@app/shared/shared-main'
|
import { Video, VideoChannel } from '@app/shared/shared-main'
|
||||||
import { AdvancedSearch, SearchService } from '@app/shared/shared-search'
|
import { AdvancedSearch, SearchService } from '@app/shared/shared-search'
|
||||||
import { MiniatureDisplayOptions, VideoLinkType } from '@app/shared/shared-video-miniature'
|
import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
|
||||||
|
import { VideoPlaylist } from '@app/shared/shared-video-playlist'
|
||||||
import { HTMLServerConfig, SearchTargetType } from '@shared/models'
|
import { HTMLServerConfig, SearchTargetType } from '@shared/models'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -16,10 +18,9 @@ import { HTMLServerConfig, SearchTargetType } from '@shared/models'
|
||||||
export class SearchComponent implements OnInit, OnDestroy {
|
export class SearchComponent implements OnInit, OnDestroy {
|
||||||
results: (Video | VideoChannel)[] = []
|
results: (Video | VideoChannel)[] = []
|
||||||
|
|
||||||
pagination: ComponentPagination = {
|
pagination = {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
itemsPerPage: 10, // Only for videos, use another variable for channels
|
totalItems: null as number
|
||||||
totalItems: null
|
|
||||||
}
|
}
|
||||||
advancedSearch: AdvancedSearch = new AdvancedSearch()
|
advancedSearch: AdvancedSearch = new AdvancedSearch()
|
||||||
isSearchFilterCollapsed = true
|
isSearchFilterCollapsed = true
|
||||||
|
@ -45,6 +46,11 @@ export class SearchComponent implements OnInit, OnDestroy {
|
||||||
private firstSearch = true
|
private firstSearch = true
|
||||||
|
|
||||||
private channelsPerPage = 2
|
private channelsPerPage = 2
|
||||||
|
private playlistsPerPage = 2
|
||||||
|
private videosPerPage = 10
|
||||||
|
|
||||||
|
private hasMoreResults = true
|
||||||
|
private isSearching = false
|
||||||
|
|
||||||
private lastSearchTarget: SearchTargetType
|
private lastSearchTarget: SearchTargetType
|
||||||
|
|
||||||
|
@ -104,77 +110,62 @@ export class SearchComponent implements OnInit, OnDestroy {
|
||||||
if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
|
if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
isVideoChannel (d: VideoChannel | Video): d is VideoChannel {
|
isVideoChannel (d: VideoChannel | Video | VideoPlaylist): d is VideoChannel {
|
||||||
return d instanceof VideoChannel
|
return d instanceof VideoChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
isVideo (v: VideoChannel | Video): v is Video {
|
isVideo (v: VideoChannel | Video | VideoPlaylist): v is Video {
|
||||||
return v instanceof Video
|
return v instanceof Video
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isPlaylist (v: VideoChannel | Video | VideoPlaylist): v is VideoPlaylist {
|
||||||
|
return v instanceof VideoPlaylist
|
||||||
|
}
|
||||||
|
|
||||||
isUserLoggedIn () {
|
isUserLoggedIn () {
|
||||||
return this.authService.isLoggedIn()
|
return this.authService.isLoggedIn()
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideoLinkType (): VideoLinkType {
|
|
||||||
if (this.advancedSearch.searchTarget === 'search-index') {
|
|
||||||
const remoteUriConfig = this.serverConfig.search.remoteUri
|
|
||||||
|
|
||||||
// Redirect on the external instance if not allowed to fetch remote data
|
|
||||||
if ((!this.isUserLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users) {
|
|
||||||
return 'external'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'lazy-load'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'internal'
|
|
||||||
}
|
|
||||||
|
|
||||||
search () {
|
search () {
|
||||||
|
this.isSearching = true
|
||||||
|
|
||||||
forkJoin([
|
forkJoin([
|
||||||
this.getVideosObs(),
|
this.getVideoChannelObs(),
|
||||||
this.getVideoChannelObs()
|
this.getVideoPlaylistObs(),
|
||||||
]).subscribe(
|
this.getVideosObs()
|
||||||
([videosResult, videoChannelsResult]) => {
|
]).subscribe(results => {
|
||||||
this.results = this.results
|
for (const result of results) {
|
||||||
.concat(videoChannelsResult.data)
|
this.results = this.results.concat(result.data)
|
||||||
.concat(videosResult.data)
|
|
||||||
|
|
||||||
this.pagination.totalItems = videosResult.total + videoChannelsResult.total
|
|
||||||
this.lastSearchTarget = this.advancedSearch.searchTarget
|
|
||||||
|
|
||||||
// Focus on channels if there are no enough videos
|
|
||||||
if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
|
|
||||||
this.resetPagination()
|
|
||||||
this.firstSearch = false
|
|
||||||
|
|
||||||
this.channelsPerPage = 10
|
|
||||||
this.search()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.firstSearch = false
|
|
||||||
},
|
|
||||||
|
|
||||||
err => {
|
|
||||||
if (this.advancedSearch.searchTarget !== 'search-index') {
|
|
||||||
this.notifier.error(err.message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.notifier.error(
|
|
||||||
$localize`Search index is unavailable. Retrying with instance results instead.`,
|
|
||||||
$localize`Search error`
|
|
||||||
)
|
|
||||||
this.advancedSearch.searchTarget = 'local'
|
|
||||||
this.search()
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
this.pagination.totalItems = results.reduce((p, r) => p += r.total, 0)
|
||||||
|
this.lastSearchTarget = this.advancedSearch.searchTarget
|
||||||
|
|
||||||
|
this.hasMoreResults = this.results.length < this.pagination.totalItems
|
||||||
|
},
|
||||||
|
|
||||||
|
err => {
|
||||||
|
if (this.advancedSearch.searchTarget !== 'search-index') {
|
||||||
|
this.notifier.error(err.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifier.error(
|
||||||
|
$localize`Search index is unavailable. Retrying with instance results instead.`,
|
||||||
|
$localize`Search error`
|
||||||
|
)
|
||||||
|
this.advancedSearch.searchTarget = 'local'
|
||||||
|
this.search()
|
||||||
|
},
|
||||||
|
|
||||||
|
() => {
|
||||||
|
this.isSearching = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onNearOfBottom () {
|
onNearOfBottom () {
|
||||||
// Last page
|
// Last page
|
||||||
if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
|
if (!this.hasMoreResults || this.isSearching) return
|
||||||
|
|
||||||
this.pagination.currentPage += 1
|
this.pagination.currentPage += 1
|
||||||
this.search()
|
this.search()
|
||||||
|
@ -190,18 +181,33 @@ export class SearchComponent implements OnInit, OnDestroy {
|
||||||
return this.advancedSearch.size()
|
return this.advancedSearch.size()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add VideoChannel for typings, but the template already checks "video" argument is a video
|
// Add VideoChannel/VideoPlaylist for typings, but the template already checks "video" argument is a video
|
||||||
removeVideoFromArray (video: Video | VideoChannel) {
|
removeVideoFromArray (video: Video | VideoChannel | VideoPlaylist) {
|
||||||
this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
|
this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLinkType (): LinkType {
|
||||||
|
if (this.advancedSearch.searchTarget === 'search-index') {
|
||||||
|
const remoteUriConfig = this.serverConfig.search.remoteUri
|
||||||
|
|
||||||
|
// Redirect on the external instance if not allowed to fetch remote data
|
||||||
|
if ((!this.isUserLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users) {
|
||||||
|
return 'external'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'lazy-load'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'internal'
|
||||||
|
}
|
||||||
|
|
||||||
isExternalChannelUrl () {
|
isExternalChannelUrl () {
|
||||||
return this.getVideoLinkType() === 'external'
|
return this.getLinkType() === 'external'
|
||||||
}
|
}
|
||||||
|
|
||||||
getExternalChannelUrl (channel: VideoChannel) {
|
getExternalChannelUrl (channel: VideoChannel) {
|
||||||
// Same algorithm than videos
|
// Same algorithm than videos
|
||||||
if (this.getVideoLinkType() === 'external') {
|
if (this.getLinkType() === 'external') {
|
||||||
return channel.url
|
return channel.url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -210,7 +216,7 @@ export class SearchComponent implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
getInternalChannelUrl (channel: VideoChannel) {
|
getInternalChannelUrl (channel: VideoChannel) {
|
||||||
const linkType = this.getVideoLinkType()
|
const linkType = this.getLinkType()
|
||||||
|
|
||||||
if (linkType === 'internal') {
|
if (linkType === 'internal') {
|
||||||
return [ '/c', channel.nameWithHost ]
|
return [ '/c', channel.nameWithHost ]
|
||||||
|
@ -256,7 +262,7 @@ export class SearchComponent implements OnInit, OnDestroy {
|
||||||
private getVideosObs () {
|
private getVideosObs () {
|
||||||
const params = {
|
const params = {
|
||||||
search: this.currentSearch,
|
search: this.currentSearch,
|
||||||
componentPagination: this.pagination,
|
componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.videosPerPage }),
|
||||||
advancedSearch: this.advancedSearch
|
advancedSearch: this.advancedSearch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -287,6 +293,24 @@ export class SearchComponent implements OnInit, OnDestroy {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getVideoPlaylistObs () {
|
||||||
|
if (!this.currentSearch) return of({ data: [], total: 0 })
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
search: this.currentSearch,
|
||||||
|
componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.playlistsPerPage }),
|
||||||
|
searchTarget: this.advancedSearch.searchTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.hooks.wrapObsFun(
|
||||||
|
this.searchService.searchVideoPlaylists.bind(this.searchService),
|
||||||
|
params,
|
||||||
|
'search',
|
||||||
|
'filter:api.search.video-playlists.list.params',
|
||||||
|
'filter:api.search.video-playlists.list.result'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private getDefaultSearchTarget (): SearchTargetType {
|
private getDefaultSearchTarget (): SearchTargetType {
|
||||||
const searchIndexConfig = this.serverConfig.search.searchIndex
|
const searchIndexConfig = this.serverConfig.search.searchIndex
|
||||||
|
|
||||||
|
|
|
@ -5,12 +5,12 @@ import { SharedMainModule } from '@app/shared/shared-main'
|
||||||
import { SharedSearchModule } from '@app/shared/shared-search'
|
import { SharedSearchModule } from '@app/shared/shared-search'
|
||||||
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
|
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
|
||||||
import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
|
import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
|
||||||
|
import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
|
||||||
import { SearchService } from '../shared/shared-search/search.service'
|
import { SearchService } from '../shared/shared-search/search.service'
|
||||||
import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
|
|
||||||
import { SearchFiltersComponent } from './search-filters.component'
|
import { SearchFiltersComponent } from './search-filters.component'
|
||||||
import { SearchRoutingModule } from './search-routing.module'
|
import { SearchRoutingModule } from './search-routing.module'
|
||||||
import { SearchComponent } from './search.component'
|
import { SearchComponent } from './search.component'
|
||||||
import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
|
import { ChannelLazyLoadResolver, PlaylistLazyLoadResolver, VideoLazyLoadResolver } from './shared'
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -21,7 +21,8 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
|
||||||
SharedFormModule,
|
SharedFormModule,
|
||||||
SharedActorImageModule,
|
SharedActorImageModule,
|
||||||
SharedUserSubscriptionModule,
|
SharedUserSubscriptionModule,
|
||||||
SharedVideoMiniatureModule
|
SharedVideoMiniatureModule,
|
||||||
|
SharedVideoPlaylistModule
|
||||||
],
|
],
|
||||||
|
|
||||||
declarations: [
|
declarations: [
|
||||||
|
@ -36,7 +37,8 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
|
||||||
providers: [
|
providers: [
|
||||||
SearchService,
|
SearchService,
|
||||||
VideoLazyLoadResolver,
|
VideoLazyLoadResolver,
|
||||||
ChannelLazyLoadResolver
|
ChannelLazyLoadResolver,
|
||||||
|
PlaylistLazyLoadResolver
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class SearchModule { }
|
export class SearchModule { }
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
|
import { Observable } from 'rxjs'
|
||||||
import { map } from 'rxjs/operators'
|
import { map } from 'rxjs/operators'
|
||||||
import { Injectable } from '@angular/core'
|
|
||||||
import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
|
import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
|
||||||
import { SearchService } from '@app/shared/shared-search'
|
import { ResultList } from '@shared/models/result-list.model'
|
||||||
|
|
||||||
@Injectable()
|
export abstract class AbstractLazyLoadResolver <T> implements Resolve<any> {
|
||||||
export class VideoLazyLoadResolver implements Resolve<any> {
|
protected router: Router
|
||||||
constructor (
|
|
||||||
private router: Router,
|
|
||||||
private searchService: SearchService
|
|
||||||
) { }
|
|
||||||
|
|
||||||
resolve (route: ActivatedRouteSnapshot) {
|
resolve (route: ActivatedRouteSnapshot) {
|
||||||
const url = route.params.url
|
const url = route.params.url
|
||||||
|
@ -18,7 +14,7 @@ export class VideoLazyLoadResolver implements Resolve<any> {
|
||||||
return this.router.navigateByUrl('/404')
|
return this.router.navigateByUrl('/404')
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.searchService.searchVideos({ search: url })
|
return this.finder(url)
|
||||||
.pipe(
|
.pipe(
|
||||||
map(result => {
|
map(result => {
|
||||||
if (result.data.length !== 1) {
|
if (result.data.length !== 1) {
|
||||||
|
@ -26,10 +22,13 @@ export class VideoLazyLoadResolver implements Resolve<any> {
|
||||||
return this.router.navigateByUrl('/404')
|
return this.router.navigateByUrl('/404')
|
||||||
}
|
}
|
||||||
|
|
||||||
const video = result.data[0]
|
const redirectUrl = this.buildUrl(result.data[0])
|
||||||
|
|
||||||
return this.router.navigateByUrl('/w/' + video.uuid)
|
return this.router.navigateByUrl(redirectUrl)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected abstract finder (url: string): Observable<ResultList<T>>
|
||||||
|
protected abstract buildUrl (e: T): string
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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">
|
<div class="d-inline-flex position-relative" id="typeahead-container">
|
||||||
<input
|
<input
|
||||||
type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…"
|
type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, playlists, channels…"
|
||||||
[(ngModel)]="search" (ngModelChange)="onSearchChange()" (keydown)="handleKey($event)" (keydown.enter)="doSearch()"
|
[(ngModel)]="search" (ngModelChange)="onSearchChange()" (keydown)="handleKey($event)" (keydown.enter)="doSearch()"
|
||||||
aria-label="Search" autocomplete="off"
|
aria-label="Search" autocomplete="off"
|
||||||
>
|
>
|
||||||
|
|
|
@ -3,5 +3,6 @@ export * from './bytes.pipe'
|
||||||
export * from './duration-formatter.pipe'
|
export * from './duration-formatter.pipe'
|
||||||
export * from './from-now.pipe'
|
export * from './from-now.pipe'
|
||||||
export * from './infinite-scroller.directive'
|
export * from './infinite-scroller.directive'
|
||||||
|
export * from './link.component'
|
||||||
export * from './number-formatter.pipe'
|
export * from './number-formatter.pipe'
|
||||||
export * from './peertube-template.directive'
|
export * from './peertube-template.directive'
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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 { HttpClientModule } from '@angular/common/http'
|
||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { ActivatedRouteSnapshot, RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
NgbButtonsModule,
|
NgbButtonsModule,
|
||||||
NgbCollapseModule,
|
NgbCollapseModule,
|
||||||
|
@ -24,6 +24,7 @@ import {
|
||||||
DurationFormatterPipe,
|
DurationFormatterPipe,
|
||||||
FromNowPipe,
|
FromNowPipe,
|
||||||
InfiniteScrollerDirective,
|
InfiniteScrollerDirective,
|
||||||
|
LinkComponent,
|
||||||
NumberFormatterPipe,
|
NumberFormatterPipe,
|
||||||
PeerTubeTemplateDirective
|
PeerTubeTemplateDirective
|
||||||
} from './angular'
|
} from './angular'
|
||||||
|
@ -35,11 +36,11 @@ import { FeedComponent } from './feeds'
|
||||||
import { LoaderComponent, SmallLoaderComponent } from './loaders'
|
import { LoaderComponent, SmallLoaderComponent } from './loaders'
|
||||||
import { HelpComponent, ListOverflowComponent, SimpleSearchInputComponent, TopMenuDropdownComponent } from './misc'
|
import { HelpComponent, ListOverflowComponent, SimpleSearchInputComponent, TopMenuDropdownComponent } from './misc'
|
||||||
import { PluginPlaceholderComponent } from './plugins'
|
import { PluginPlaceholderComponent } from './plugins'
|
||||||
|
import { ActorRedirectGuard } from './router'
|
||||||
import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
|
import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
|
||||||
import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
|
import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
|
||||||
import { VideoCaptionService } from './video-caption'
|
import { VideoCaptionService } from './video-caption'
|
||||||
import { VideoChannelService } from './video-channel'
|
import { VideoChannelService } from './video-channel'
|
||||||
import { ActorRedirectGuard } from './router'
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -76,6 +77,7 @@ import { ActorRedirectGuard } from './router'
|
||||||
|
|
||||||
InfiniteScrollerDirective,
|
InfiniteScrollerDirective,
|
||||||
PeerTubeTemplateDirective,
|
PeerTubeTemplateDirective,
|
||||||
|
LinkComponent,
|
||||||
|
|
||||||
ActionDropdownComponent,
|
ActionDropdownComponent,
|
||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
|
@ -130,6 +132,7 @@ import { ActorRedirectGuard } from './router'
|
||||||
|
|
||||||
InfiniteScrollerDirective,
|
InfiniteScrollerDirective,
|
||||||
PeerTubeTemplateDirective,
|
PeerTubeTemplateDirective,
|
||||||
|
LinkComponent,
|
||||||
|
|
||||||
ActionDropdownComponent,
|
ActionDropdownComponent,
|
||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
|
|
|
@ -3,10 +3,17 @@ import { catchError, map, switchMap } from 'rxjs/operators'
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core'
|
import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core'
|
||||||
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
|
|
||||||
import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
|
import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
|
||||||
import { ResultList, SearchTargetType, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '@shared/models'
|
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
|
||||||
|
import {
|
||||||
|
ResultList,
|
||||||
|
SearchTargetType,
|
||||||
|
Video as VideoServerModel,
|
||||||
|
VideoChannel as VideoChannelServerModel,
|
||||||
|
VideoPlaylist as VideoPlaylistServerModel
|
||||||
|
} from '@shared/models'
|
||||||
import { environment } from '../../../environments/environment'
|
import { environment } from '../../../environments/environment'
|
||||||
|
import { VideoPlaylist, VideoPlaylistService } from '../shared-video-playlist'
|
||||||
import { AdvancedSearch } from './advanced-search.model'
|
import { AdvancedSearch } from './advanced-search.model'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -17,7 +24,8 @@ export class SearchService {
|
||||||
private authHttp: HttpClient,
|
private authHttp: HttpClient,
|
||||||
private restExtractor: RestExtractor,
|
private restExtractor: RestExtractor,
|
||||||
private restService: RestService,
|
private restService: RestService,
|
||||||
private videoService: VideoService
|
private videoService: VideoService,
|
||||||
|
private playlistService: VideoPlaylistService
|
||||||
) {
|
) {
|
||||||
// Add ability to override search endpoint if the user updated this local storage key
|
// Add ability to override search endpoint if the user updated this local storage key
|
||||||
const searchUrl = peertubeLocalStorage.getItem('search-url')
|
const searchUrl = peertubeLocalStorage.getItem('search-url')
|
||||||
|
@ -85,4 +93,34 @@ export class SearchService {
|
||||||
catchError(err => this.restExtractor.handleError(err))
|
catchError(err => this.restExtractor.handleError(err))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
searchVideoPlaylists (parameters: {
|
||||||
|
search: string,
|
||||||
|
searchTarget?: SearchTargetType,
|
||||||
|
componentPagination?: ComponentPaginationLight
|
||||||
|
}): Observable<ResultList<VideoPlaylist>> {
|
||||||
|
const { search, componentPagination, searchTarget } = parameters
|
||||||
|
|
||||||
|
const url = SearchService.BASE_SEARCH_URL + 'video-playlists'
|
||||||
|
|
||||||
|
let pagination: RestPagination
|
||||||
|
if (componentPagination) {
|
||||||
|
pagination = this.restService.componentPaginationToRestPagination(componentPagination)
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = new HttpParams()
|
||||||
|
params = this.restService.addRestGetParams(params, pagination)
|
||||||
|
params = params.append('search', search)
|
||||||
|
|
||||||
|
if (searchTarget) {
|
||||||
|
params = params.append('searchTarget', searchTarget as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.authHttp
|
||||||
|
.get<ResultList<VideoPlaylistServerModel>>(url, { params })
|
||||||
|
.pipe(
|
||||||
|
switchMap(res => this.playlistService.extractPlaylists(res)),
|
||||||
|
catchError(err => this.restExtractor.handleError(err))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,13 +21,12 @@
|
||||||
></my-actor-avatar>
|
></my-actor-avatar>
|
||||||
|
|
||||||
<div class="w-100 d-flex flex-column">
|
<div class="w-100 d-flex flex-column">
|
||||||
<a *ngIf="!videoHref" tabindex="-1" class="video-miniature-name"
|
<my-link
|
||||||
[routerLink]="videoRouterLink" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
|
[internalLink]="videoRouterLink" [href]="videoHref" [target]="videoTarget"
|
||||||
>{{ video.name }}</a>
|
[title]="video.name"class="video-miniature-name" [ngClass]="{ 'blur-filter': isVideoBlur }" tabindex="-1"
|
||||||
|
>
|
||||||
<a *ngIf="videoHref" tabindex="-1" class="video-miniature-name"
|
{{ video.name }}
|
||||||
[href]="videoHref" [target]="videoTarget" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
|
</my-link>
|
||||||
>{{ video.name }}</a>
|
|
||||||
|
|
||||||
<span class="video-miniature-created-at-views">
|
<span class="video-miniature-created-at-views">
|
||||||
<my-date-toggle *ngIf="displayOptions.date" [date]="video.publishedAt"></my-date-toggle>
|
<my-date-toggle *ngIf="displayOptions.date" [date]="video.publishedAt"></my-date-toggle>
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { AuthService, ScreenService, ServerService, User } from '@app/core'
|
import { AuthService, ScreenService, ServerService, User } from '@app/core'
|
||||||
import { HTMLServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '@shared/models'
|
import { HTMLServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '@shared/models'
|
||||||
|
import { LinkType } from '../../../types/link.type'
|
||||||
import { ActorAvatarSize } from '../shared-actor-image/actor-avatar.component'
|
import { ActorAvatarSize } from '../shared-actor-image/actor-avatar.component'
|
||||||
import { Video } from '../shared-main'
|
import { Video } from '../shared-main'
|
||||||
import { VideoPlaylistService } from '../shared-video-playlist'
|
import { VideoPlaylistService } from '../shared-video-playlist'
|
||||||
|
@ -28,8 +29,6 @@ export type MiniatureDisplayOptions = {
|
||||||
blacklistInfo?: boolean
|
blacklistInfo?: boolean
|
||||||
nsfw?: boolean
|
nsfw?: boolean
|
||||||
}
|
}
|
||||||
export type VideoLinkType = 'internal' | 'lazy-load' | 'external'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-video-miniature',
|
selector: 'my-video-miniature',
|
||||||
styleUrls: [ './video-miniature.component.scss' ],
|
styleUrls: [ './video-miniature.component.scss' ],
|
||||||
|
@ -56,7 +55,7 @@ export class VideoMiniatureComponent implements OnInit {
|
||||||
|
|
||||||
@Input() displayAsRow = false
|
@Input() displayAsRow = false
|
||||||
|
|
||||||
@Input() videoLinkType: VideoLinkType = 'internal'
|
@Input() videoLinkType: LinkType = 'internal'
|
||||||
|
|
||||||
@Output() videoBlocked = new EventEmitter()
|
@Output() videoBlocked = new EventEmitter()
|
||||||
@Output() videoUnblocked = new EventEmitter()
|
@Output() videoUnblocked = new EventEmitter()
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage, 'display-as-row': displayAsRow }">
|
<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage, 'display-as-row': displayAsRow }">
|
||||||
<a
|
<my-link
|
||||||
[routerLink]="getPlaylistUrl()" [attr.title]="playlist.description"
|
[internalLink]="routerLink" [href]="playlistHref" [target]="playlistTarget"
|
||||||
class="miniature-thumbnail"
|
[title]="playlist.description" class="miniature-thumbnail"
|
||||||
>
|
>
|
||||||
<img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
|
<img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
|
||||||
|
|
||||||
|
@ -12,12 +12,15 @@
|
||||||
<div class="play-overlay">
|
<div class="play-overlay">
|
||||||
<div class="icon"></div>
|
<div class="icon"></div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</my-link>
|
||||||
|
|
||||||
<div class="miniature-info">
|
<div class="miniature-info">
|
||||||
<a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description">
|
<my-link
|
||||||
|
[internalLink]="routerLink" [href]="playlistHref" [target]="playlistTarget"
|
||||||
|
[title]="playlist.description" class="miniature-name" tabindex="-1"
|
||||||
|
>
|
||||||
{{ playlist.displayName }}
|
{{ playlist.displayName }}
|
||||||
</a>
|
</my-link>
|
||||||
|
|
||||||
<a i18n [routerLink]="[ '/c', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy">
|
<a i18n [routerLink]="[ '/c', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy">
|
||||||
{{ playlist.videoChannelBy }}
|
{{ playlist.videoChannelBy }}
|
||||||
|
|
|
@ -75,7 +75,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.miniature:not(.display-as-row) {
|
.miniature:not(.display-as-row) {
|
||||||
|
|
||||||
.miniature-thumbnail {
|
.miniature-thumbnail {
|
||||||
|
@include block-ratio($selector: '::ng-deep a');
|
||||||
|
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { Component, Input } from '@angular/core'
|
import { LinkType } from 'src/types/link.type'
|
||||||
|
import { Component, Input, OnInit } from '@angular/core'
|
||||||
import { VideoPlaylist } from './video-playlist.model'
|
import { VideoPlaylist } from './video-playlist.model'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -6,18 +7,52 @@ import { VideoPlaylist } from './video-playlist.model'
|
||||||
styleUrls: [ './video-playlist-miniature.component.scss' ],
|
styleUrls: [ './video-playlist-miniature.component.scss' ],
|
||||||
templateUrl: './video-playlist-miniature.component.html'
|
templateUrl: './video-playlist-miniature.component.html'
|
||||||
})
|
})
|
||||||
export class VideoPlaylistMiniatureComponent {
|
export class VideoPlaylistMiniatureComponent implements OnInit {
|
||||||
@Input() playlist: VideoPlaylist
|
@Input() playlist: VideoPlaylist
|
||||||
|
|
||||||
@Input() toManage = false
|
@Input() toManage = false
|
||||||
|
|
||||||
@Input() displayChannel = false
|
@Input() displayChannel = false
|
||||||
@Input() displayDescription = false
|
@Input() displayDescription = false
|
||||||
@Input() displayPrivacy = false
|
@Input() displayPrivacy = false
|
||||||
@Input() displayAsRow = false
|
@Input() displayAsRow = false
|
||||||
|
|
||||||
getPlaylistUrl () {
|
@Input() linkType: LinkType = 'internal'
|
||||||
if (this.toManage) return [ '/my-library/video-playlists', this.playlist.uuid ]
|
|
||||||
if (this.playlist.videosLength === 0) return null
|
|
||||||
|
|
||||||
return [ '/w/p', this.playlist.uuid ]
|
routerLink: any
|
||||||
|
playlistHref: string
|
||||||
|
playlistTarget: string
|
||||||
|
|
||||||
|
ngOnInit () {
|
||||||
|
this.buildPlaylistUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
buildPlaylistUrl () {
|
||||||
|
if (this.toManage) {
|
||||||
|
this.routerLink = [ '/my-library/video-playlists', this.playlist.uuid ]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.playlist.videosLength === 0) {
|
||||||
|
this.routerLink = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.linkType === 'internal' || !this.playlist.url) {
|
||||||
|
this.routerLink = [ '/w/p', this.playlist.uuid ]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.linkType === 'external') {
|
||||||
|
this.routerLink = null
|
||||||
|
this.playlistHref = this.playlist.url
|
||||||
|
this.playlistTarget = '_blank'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy load
|
||||||
|
this.routerLink = [ '/search/lazy-load-playlist', { url: this.playlist.url } ]
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers'
|
import { getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers'
|
||||||
import { Account, Actor, VideoChannel } from '@app/shared/shared-main'
|
import { Actor } from '@app/shared/shared-main'
|
||||||
import { peertubeTranslate } from '@shared/core-utils/i18n'
|
import { peertubeTranslate } from '@shared/core-utils/i18n'
|
||||||
import {
|
import {
|
||||||
AccountSummary,
|
AccountSummary,
|
||||||
|
@ -15,12 +15,12 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
||||||
uuid: string
|
uuid: string
|
||||||
isLocal: boolean
|
isLocal: boolean
|
||||||
|
|
||||||
|
url: string
|
||||||
|
|
||||||
displayName: string
|
displayName: string
|
||||||
description: string
|
description: string
|
||||||
privacy: VideoConstant<VideoPlaylistPrivacy>
|
privacy: VideoConstant<VideoPlaylistPrivacy>
|
||||||
|
|
||||||
thumbnailPath: string
|
|
||||||
|
|
||||||
videosLength: number
|
videosLength: number
|
||||||
|
|
||||||
type: VideoConstant<VideoPlaylistType>
|
type: VideoConstant<VideoPlaylistType>
|
||||||
|
@ -31,6 +31,7 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
||||||
ownerAccount: AccountSummary
|
ownerAccount: AccountSummary
|
||||||
videoChannel?: VideoChannelSummary
|
videoChannel?: VideoChannelSummary
|
||||||
|
|
||||||
|
thumbnailPath: string
|
||||||
thumbnailUrl: string
|
thumbnailUrl: string
|
||||||
|
|
||||||
embedPath: string
|
embedPath: string
|
||||||
|
@ -40,14 +41,12 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
||||||
|
|
||||||
videoChannelBy?: string
|
videoChannelBy?: string
|
||||||
|
|
||||||
private thumbnailVersion: number
|
|
||||||
private originThumbnailUrl: string
|
|
||||||
|
|
||||||
constructor (hash: ServerVideoPlaylist, translations: {}) {
|
constructor (hash: ServerVideoPlaylist, translations: {}) {
|
||||||
const absoluteAPIUrl = getAbsoluteAPIUrl()
|
const absoluteAPIUrl = getAbsoluteAPIUrl()
|
||||||
|
|
||||||
this.id = hash.id
|
this.id = hash.id
|
||||||
this.uuid = hash.uuid
|
this.uuid = hash.uuid
|
||||||
|
this.url = hash.url
|
||||||
this.isLocal = hash.isLocal
|
this.isLocal = hash.isLocal
|
||||||
|
|
||||||
this.displayName = hash.displayName
|
this.displayName = hash.displayName
|
||||||
|
@ -57,15 +56,12 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
||||||
|
|
||||||
this.thumbnailPath = hash.thumbnailPath
|
this.thumbnailPath = hash.thumbnailPath
|
||||||
|
|
||||||
if (this.thumbnailPath) {
|
this.thumbnailUrl = this.thumbnailPath
|
||||||
this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
|
? hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
|
||||||
this.originThumbnailUrl = this.thumbnailUrl
|
: absoluteAPIUrl + '/client/assets/images/default-playlist.jpg'
|
||||||
} else {
|
|
||||||
this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg'
|
|
||||||
}
|
|
||||||
|
|
||||||
this.embedPath = hash.embedPath
|
this.embedPath = hash.embedPath
|
||||||
this.embedUrl = getAbsoluteEmbedUrl() + hash.embedPath
|
this.embedUrl = hash.embedUrl || (getAbsoluteEmbedUrl() + hash.embedPath)
|
||||||
|
|
||||||
this.videosLength = hash.videosLength
|
this.videosLength = hash.videosLength
|
||||||
|
|
||||||
|
@ -88,13 +84,4 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
||||||
this.displayName = peertubeTranslate(this.displayName, translations)
|
this.displayName = peertubeTranslate(this.displayName, translations)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshThumbnail () {
|
|
||||||
if (!this.originThumbnailUrl) return
|
|
||||||
|
|
||||||
if (!this.thumbnailVersion) this.thumbnailVersion = 0
|
|
||||||
this.thumbnailVersion++
|
|
||||||
|
|
||||||
this.thumbnailUrl = this.originThumbnailUrl + '?v' + this.thumbnailVersion
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -880,6 +880,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
||||||
@content;
|
@content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export type LinkType = 'internal' | 'lazy-load' | 'external'
|
|
@ -155,7 +155,8 @@ activityPubClientRouter.get('/redundancy/streaming-playlists/:streamingPlaylistT
|
||||||
asyncMiddleware(videoRedundancyController)
|
asyncMiddleware(videoRedundancyController)
|
||||||
)
|
)
|
||||||
|
|
||||||
activityPubClientRouter.get('/video-playlists/:playlistId',
|
activityPubClientRouter.get(
|
||||||
|
[ '/video-playlists/:playlistId', '/videos/watch/playlist/:playlistId', '/w/p/:playlistId' ],
|
||||||
executeIfActivityPub,
|
executeIfActivityPub,
|
||||||
asyncMiddleware(videoPlaylistsGetValidator('all')),
|
asyncMiddleware(videoPlaylistsGetValidator('all')),
|
||||||
asyncMiddleware(videoPlaylistController)
|
asyncMiddleware(videoPlaylistController)
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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/')
|
||||||
|
}
|
|
@ -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/')
|
||||||
|
}
|
|
@ -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,
|
videoChannelsUpdateValidator,
|
||||||
videoPlaylistsSortValidator
|
videoPlaylistsSortValidator
|
||||||
} from '../../middlewares'
|
} from '../../middlewares'
|
||||||
import { videoChannelsNameWithHostValidator, videoChannelsOwnSearchValidator, videosSortValidator } from '../../middlewares/validators'
|
import { videoChannelsListValidator, videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators'
|
||||||
import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image'
|
import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image'
|
||||||
import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists'
|
import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists'
|
||||||
import { AccountModel } from '../../models/account/account'
|
import { AccountModel } from '../../models/account/account'
|
||||||
|
@ -51,7 +51,7 @@ videoChannelRouter.get('/',
|
||||||
videoChannelsSortValidator,
|
videoChannelsSortValidator,
|
||||||
setDefaultSort,
|
setDefaultSort,
|
||||||
setDefaultPagination,
|
setDefaultPagination,
|
||||||
videoChannelsOwnSearchValidator,
|
videoChannelsListValidator,
|
||||||
asyncMiddleware(listVideoChannels)
|
asyncMiddleware(listVideoChannels)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import * as express from 'express'
|
import * as express from 'express'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists'
|
||||||
import { getServerActor } from '@server/models/application/application'
|
import { getServerActor } from '@server/models/application/application'
|
||||||
import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models'
|
import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models'
|
||||||
|
import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
|
||||||
import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model'
|
import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model'
|
||||||
import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
|
import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
|
||||||
import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
|
import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
|
||||||
|
@ -17,7 +19,6 @@ import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constant
|
||||||
import { sequelizeTypescript } from '../../initializers/database'
|
import { sequelizeTypescript } from '../../initializers/database'
|
||||||
import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send'
|
import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send'
|
||||||
import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url'
|
import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url'
|
||||||
import { JobQueue } from '../../lib/job-queue'
|
|
||||||
import { updatePlaylistMiniatureFromExisting } from '../../lib/thumbnail'
|
import { updatePlaylistMiniatureFromExisting } from '../../lib/thumbnail'
|
||||||
import {
|
import {
|
||||||
asyncMiddleware,
|
asyncMiddleware,
|
||||||
|
@ -42,7 +43,6 @@ import {
|
||||||
import { AccountModel } from '../../models/account/account'
|
import { AccountModel } from '../../models/account/account'
|
||||||
import { VideoPlaylistModel } from '../../models/video/video-playlist'
|
import { VideoPlaylistModel } from '../../models/video/video-playlist'
|
||||||
import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
|
import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
|
||||||
import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
|
|
||||||
|
|
||||||
const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR })
|
const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR })
|
||||||
|
|
||||||
|
@ -144,9 +144,7 @@ async function listVideoPlaylists (req: express.Request, res: express.Response)
|
||||||
function getVideoPlaylist (req: express.Request, res: express.Response) {
|
function getVideoPlaylist (req: express.Request, res: express.Response) {
|
||||||
const videoPlaylist = res.locals.videoPlaylistSummary
|
const videoPlaylist = res.locals.videoPlaylistSummary
|
||||||
|
|
||||||
if (videoPlaylist.isOutdated()) {
|
scheduleRefreshIfNeeded(videoPlaylist)
|
||||||
JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: videoPlaylist.url } })
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json(videoPlaylist.toFormattedJSON())
|
return res.json(videoPlaylist.toFormattedJSON())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
import { exists, isDateValid } from '../misc'
|
|
||||||
import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
|
|
||||||
import validator from 'validator'
|
import validator from 'validator'
|
||||||
import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object'
|
import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object'
|
||||||
|
import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
|
||||||
|
import { exists, isDateValid, isUUIDValid } from '../misc'
|
||||||
|
import { isVideoPlaylistNameValid } from '../video-playlists'
|
||||||
import { isActivityPubUrlValid } from './misc'
|
import { isActivityPubUrlValid } from './misc'
|
||||||
|
|
||||||
function isPlaylistObjectValid (object: PlaylistObject) {
|
function isPlaylistObjectValid (object: PlaylistObject) {
|
||||||
return exists(object) &&
|
return exists(object) &&
|
||||||
object.type === 'Playlist' &&
|
object.type === 'Playlist' &&
|
||||||
validator.isInt(object.totalItems + '') &&
|
validator.isInt(object.totalItems + '') &&
|
||||||
|
isVideoPlaylistNameValid(object.name) &&
|
||||||
|
isUUIDValid(object.uuid) &&
|
||||||
isDateValid(object.published) &&
|
isDateValid(object.published) &&
|
||||||
isDateValid(object.updated)
|
isDateValid(object.updated)
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,6 +77,7 @@ const SORTABLE_COLUMNS = {
|
||||||
// Don't forget to update peertube-search-index with the same values
|
// Don't forget to update peertube-search-index with the same values
|
||||||
VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
|
VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
|
||||||
VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
|
VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
|
||||||
|
VIDEO_PLAYLISTS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
|
||||||
|
|
||||||
ABUSES: [ 'id', 'createdAt', 'state' ],
|
ABUSES: [ 'id', 'createdAt', 'state' ],
|
||||||
|
|
||||||
|
|
|
@ -116,7 +116,7 @@ async function scheduleOutboxFetchIfNeeded (actor: MActor, created: boolean, ref
|
||||||
async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) {
|
async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) {
|
||||||
// We created a new account: fetch the playlists
|
// We created a new account: fetch the playlists
|
||||||
if (created === true && actor.Account && accountPlaylistsUrl) {
|
if (created === true && actor.Account && accountPlaylistsUrl) {
|
||||||
const payload = { uri: accountPlaylistsUrl, accountId: actor.Account.id, type: 'account-playlists' as 'account-playlists' }
|
const payload = { uri: accountPlaylistsUrl, type: 'account-playlists' as 'account-playlists' }
|
||||||
await JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload })
|
await JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import * as Bluebird from 'bluebird'
|
||||||
|
import { getAPId } from '@server/helpers/activitypub'
|
||||||
import { isArray } from '@server/helpers/custom-validators/misc'
|
import { isArray } from '@server/helpers/custom-validators/misc'
|
||||||
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
||||||
import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants'
|
import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants'
|
||||||
|
@ -6,7 +8,7 @@ import { updatePlaylistMiniatureFromUrl } from '@server/lib/thumbnail'
|
||||||
import { VideoPlaylistModel } from '@server/models/video/video-playlist'
|
import { VideoPlaylistModel } from '@server/models/video/video-playlist'
|
||||||
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
|
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
|
||||||
import { FilteredModelAttributes } from '@server/types'
|
import { FilteredModelAttributes } from '@server/types'
|
||||||
import { MAccountDefault, MAccountId, MThumbnail, MVideoPlaylist, MVideoPlaylistFull } from '@server/types/models'
|
import { MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models'
|
||||||
import { AttributesOnly } from '@shared/core-utils'
|
import { AttributesOnly } from '@shared/core-utils'
|
||||||
import { PlaylistObject } from '@shared/models'
|
import { PlaylistObject } from '@shared/models'
|
||||||
import { getOrCreateAPActor } from '../actors'
|
import { getOrCreateAPActor } from '../actors'
|
||||||
|
@ -19,11 +21,9 @@ import {
|
||||||
playlistObjectToDBAttributes
|
playlistObjectToDBAttributes
|
||||||
} from './shared'
|
} from './shared'
|
||||||
|
|
||||||
import Bluebird = require('bluebird')
|
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('ap', 'video-playlist')
|
const lTags = loggerTagsFactory('ap', 'video-playlist')
|
||||||
|
|
||||||
async function createAccountPlaylists (playlistUrls: string[], account: MAccountDefault) {
|
async function createAccountPlaylists (playlistUrls: string[]) {
|
||||||
await Bluebird.map(playlistUrls, async playlistUrl => {
|
await Bluebird.map(playlistUrls, async playlistUrl => {
|
||||||
try {
|
try {
|
||||||
const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
|
const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
|
||||||
|
@ -35,19 +35,19 @@ async function createAccountPlaylists (playlistUrls: string[], account: MAccount
|
||||||
throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`)
|
throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return createOrUpdateVideoPlaylist(playlistObject, account, playlistObject.to)
|
return createOrUpdateVideoPlaylist(playlistObject)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) })
|
logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) })
|
||||||
}
|
}
|
||||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) {
|
async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, to?: string[]) {
|
||||||
const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to)
|
const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to)
|
||||||
|
|
||||||
await setVideoChannelIfNeeded(playlistObject, playlistAttributes)
|
await setVideoChannel(playlistObject, playlistAttributes)
|
||||||
|
|
||||||
const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylist>(playlistAttributes, { returning: true })
|
const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylistVideosLength>(playlistAttributes, { returning: true })
|
||||||
|
|
||||||
const playlistElementUrls = await fetchElementUrls(playlistObject)
|
const playlistElementUrls = await fetchElementUrls(playlistObject)
|
||||||
|
|
||||||
|
@ -56,7 +56,10 @@ async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAc
|
||||||
|
|
||||||
await updatePlaylistThumbnail(playlistObject, playlist)
|
await updatePlaylistThumbnail(playlistObject, playlist)
|
||||||
|
|
||||||
return rebuildVideoPlaylistElements(playlistElementUrls, playlist)
|
const elementsLength = await rebuildVideoPlaylistElements(playlistElementUrls, playlist)
|
||||||
|
playlist.setVideosLength(elementsLength)
|
||||||
|
|
||||||
|
return playlist
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
@ -68,10 +71,12 @@ export {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function setVideoChannelIfNeeded (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) {
|
async function setVideoChannel (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) {
|
||||||
if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) return
|
if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) {
|
||||||
|
throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject))
|
||||||
|
}
|
||||||
|
|
||||||
const actor = await getOrCreateAPActor(playlistObject.attributedTo[0])
|
const actor = await getOrCreateAPActor(playlistObject.attributedTo[0], 'all')
|
||||||
|
|
||||||
if (!actor.VideoChannel) {
|
if (!actor.VideoChannel) {
|
||||||
logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) })
|
logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) })
|
||||||
|
@ -79,6 +84,7 @@ async function setVideoChannelIfNeeded (playlistObject: PlaylistObject, playlist
|
||||||
}
|
}
|
||||||
|
|
||||||
playlistAttributes.videoChannelId = actor.VideoChannel.id
|
playlistAttributes.videoChannelId = actor.VideoChannel.id
|
||||||
|
playlistAttributes.ownerAccountId = actor.VideoChannel.Account.id
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchElementUrls (playlistObject: PlaylistObject) {
|
async function fetchElementUrls (playlistObject: PlaylistObject) {
|
||||||
|
@ -128,7 +134,7 @@ async function rebuildVideoPlaylistElements (elementUrls: string[], playlist: MV
|
||||||
|
|
||||||
logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url))
|
logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url))
|
||||||
|
|
||||||
return undefined
|
return elementsToCreate.length
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) {
|
async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) {
|
||||||
|
|
|
@ -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 './create-update'
|
||||||
export * from './refresh'
|
export * from './refresh'
|
||||||
|
|
|
@ -1,10 +1,17 @@
|
||||||
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
||||||
import { PeerTubeRequestError } from '@server/helpers/requests'
|
import { PeerTubeRequestError } from '@server/helpers/requests'
|
||||||
import { MVideoPlaylistOwner } from '@server/types/models'
|
import { JobQueue } from '@server/lib/job-queue'
|
||||||
|
import { MVideoPlaylist, MVideoPlaylistOwner } from '@server/types/models'
|
||||||
import { HttpStatusCode } from '@shared/core-utils'
|
import { HttpStatusCode } from '@shared/core-utils'
|
||||||
import { createOrUpdateVideoPlaylist } from './create-update'
|
import { createOrUpdateVideoPlaylist } from './create-update'
|
||||||
import { fetchRemoteVideoPlaylist } from './shared'
|
import { fetchRemoteVideoPlaylist } from './shared'
|
||||||
|
|
||||||
|
function scheduleRefreshIfNeeded (playlist: MVideoPlaylist) {
|
||||||
|
if (!playlist.isOutdated()) return
|
||||||
|
|
||||||
|
JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: playlist.url } })
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> {
|
async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> {
|
||||||
if (!videoPlaylist.isOutdated()) return videoPlaylist
|
if (!videoPlaylist.isOutdated()) return videoPlaylist
|
||||||
|
|
||||||
|
@ -22,8 +29,7 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner)
|
||||||
return videoPlaylist
|
return videoPlaylist
|
||||||
}
|
}
|
||||||
|
|
||||||
const byAccount = videoPlaylist.OwnerAccount
|
await createOrUpdateVideoPlaylist(playlistObject)
|
||||||
await createOrUpdateVideoPlaylist(playlistObject, byAccount, playlistObject.to)
|
|
||||||
|
|
||||||
return videoPlaylist
|
return videoPlaylist
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -42,5 +48,6 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
scheduleRefreshIfNeeded,
|
||||||
refreshVideoPlaylistIfNeeded
|
refreshVideoPlaylistIfNeeded
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { ACTIVITY_PUB } from '@server/initializers/constants'
|
import { ACTIVITY_PUB } from '@server/initializers/constants'
|
||||||
import { VideoPlaylistModel } from '@server/models/video/video-playlist'
|
import { VideoPlaylistModel } from '@server/models/video/video-playlist'
|
||||||
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
|
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
|
||||||
import { MAccountId, MVideoId, MVideoPlaylistId } from '@server/types/models'
|
import { MVideoId, MVideoPlaylistId } from '@server/types/models'
|
||||||
import { AttributesOnly } from '@shared/core-utils'
|
import { AttributesOnly } from '@shared/core-utils'
|
||||||
import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models'
|
import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models'
|
||||||
|
|
||||||
function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) {
|
function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: string[]) {
|
||||||
const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
|
const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
|
||||||
? VideoPlaylistPrivacy.PUBLIC
|
? VideoPlaylistPrivacy.PUBLIC
|
||||||
: VideoPlaylistPrivacy.UNLISTED
|
: VideoPlaylistPrivacy.UNLISTED
|
||||||
|
@ -16,7 +16,7 @@ function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount
|
||||||
privacy,
|
privacy,
|
||||||
url: playlistObject.id,
|
url: playlistObject.id,
|
||||||
uuid: playlistObject.uuid,
|
uuid: playlistObject.uuid,
|
||||||
ownerAccountId: byAccount.id,
|
ownerAccountId: null,
|
||||||
videoChannelId: null,
|
videoChannelId: null,
|
||||||
createdAt: new Date(playlistObject.published),
|
createdAt: new Date(playlistObject.published),
|
||||||
updatedAt: new Date(playlistObject.updated)
|
updatedAt: new Date(playlistObject.updated)
|
||||||
|
|
|
@ -128,5 +128,5 @@ async function processCreatePlaylist (activity: ActivityCreate, byActor: MActorS
|
||||||
|
|
||||||
if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url)
|
if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url)
|
||||||
|
|
||||||
await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to)
|
await createOrUpdateVideoPlaylist(playlistObject, activity.to)
|
||||||
}
|
}
|
||||||
|
|
|
@ -111,5 +111,5 @@ async function processUpdatePlaylist (byActor: MActorSignature, activity: Activi
|
||||||
|
|
||||||
if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url)
|
if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url)
|
||||||
|
|
||||||
await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to)
|
await createOrUpdateVideoPlaylist(playlistObject, activity.to)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { retryTransactionWrapper } from '@server/helpers/database-utils'
|
||||||
import { JobQueue } from '@server/lib/job-queue'
|
import { JobQueue } from '@server/lib/job-queue'
|
||||||
import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders'
|
import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders'
|
||||||
import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models'
|
import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models'
|
||||||
|
import { APObject } from '@shared/models'
|
||||||
import { refreshVideoIfNeeded } from './refresh'
|
import { refreshVideoIfNeeded } from './refresh'
|
||||||
import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared'
|
import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared'
|
||||||
|
|
||||||
|
@ -13,21 +14,21 @@ type GetVideoResult <T> = Promise<{
|
||||||
}>
|
}>
|
||||||
|
|
||||||
type GetVideoParamAll = {
|
type GetVideoParamAll = {
|
||||||
videoObject: { id: string } | string
|
videoObject: APObject
|
||||||
syncParam?: SyncParam
|
syncParam?: SyncParam
|
||||||
fetchType?: 'all'
|
fetchType?: 'all'
|
||||||
allowRefresh?: boolean
|
allowRefresh?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetVideoParamImmutable = {
|
type GetVideoParamImmutable = {
|
||||||
videoObject: { id: string } | string
|
videoObject: APObject
|
||||||
syncParam?: SyncParam
|
syncParam?: SyncParam
|
||||||
fetchType: 'only-immutable-attributes'
|
fetchType: 'only-immutable-attributes'
|
||||||
allowRefresh: false
|
allowRefresh: false
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetVideoParamOther = {
|
type GetVideoParamOther = {
|
||||||
videoObject: { id: string } | string
|
videoObject: APObject
|
||||||
syncParam?: SyncParam
|
syncParam?: SyncParam
|
||||||
fetchType?: 'all' | 'only-video'
|
fetchType?: 'all' | 'only-video'
|
||||||
allowRefresh?: boolean
|
allowRefresh?: boolean
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import * as Bull from 'bull'
|
import * as Bull from 'bull'
|
||||||
import { ActivitypubHttpFetcherPayload, FetchType } from '@shared/models'
|
import { ActivitypubHttpFetcherPayload, FetchType } from '@shared/models'
|
||||||
import { logger } from '../../../helpers/logger'
|
import { logger } from '../../../helpers/logger'
|
||||||
import { AccountModel } from '../../../models/account/account'
|
|
||||||
import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
|
import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
|
||||||
import { VideoModel } from '../../../models/video/video'
|
import { VideoModel } from '../../../models/video/video'
|
||||||
import { VideoCommentModel } from '../../../models/video/video-comment'
|
import { VideoCommentModel } from '../../../models/video/video-comment'
|
||||||
import { VideoShareModel } from '../../../models/video/video-share'
|
import { VideoShareModel } from '../../../models/video/video-share'
|
||||||
import { MAccountDefault, MVideoFullLight } from '../../../types/models'
|
import { MVideoFullLight } from '../../../types/models'
|
||||||
import { crawlCollectionPage } from '../../activitypub/crawl'
|
import { crawlCollectionPage } from '../../activitypub/crawl'
|
||||||
import { createAccountPlaylists } from '../../activitypub/playlists'
|
import { createAccountPlaylists } from '../../activitypub/playlists'
|
||||||
import { processActivities } from '../../activitypub/process'
|
import { processActivities } from '../../activitypub/process'
|
||||||
|
@ -22,16 +21,13 @@ async function processActivityPubHttpFetcher (job: Bull.Job) {
|
||||||
let video: MVideoFullLight
|
let video: MVideoFullLight
|
||||||
if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId)
|
if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId)
|
||||||
|
|
||||||
let account: MAccountDefault
|
|
||||||
if (payload.accountId) account = await AccountModel.load(payload.accountId)
|
|
||||||
|
|
||||||
const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = {
|
const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = {
|
||||||
'activity': items => processActivities(items, { outboxUrl: payload.uri, fromFetch: true }),
|
'activity': items => processActivities(items, { outboxUrl: payload.uri, fromFetch: true }),
|
||||||
'video-likes': items => createRates(items, video, 'like'),
|
'video-likes': items => createRates(items, video, 'like'),
|
||||||
'video-dislikes': items => createRates(items, video, 'dislike'),
|
'video-dislikes': items => createRates(items, video, 'dislike'),
|
||||||
'video-shares': items => addVideoShares(items, video),
|
'video-shares': items => addVideoShares(items, video),
|
||||||
'video-comments': items => addVideoComments(items),
|
'video-comments': items => addVideoComments(items),
|
||||||
'account-playlists': items => createAccountPlaylists(items, account)
|
'account-playlists': items => createAccountPlaylists(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanerType: { [ id in FetchType ]?: (crawlStartDate: Date) => Promise<any> } = {
|
const cleanerType: { [ id in FetchType ]?: (crawlStartDate: Date) => Promise<any> } = {
|
||||||
|
|
|
@ -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 = [
|
const videoPlaylistsListSearchValidator = [
|
||||||
query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
|
query('search').not().isEmpty().withMessage('Should have a valid search'),
|
||||||
|
query('searchTarget').optional().custom(isSearchTargetValid).withMessage('Should have a valid search target'),
|
||||||
|
|
||||||
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
logger.debug('Checking video channels search query', { parameters: req.query })
|
logger.debug('Checking video playlists search query', { parameters: req.query })
|
||||||
|
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
||||||
|
@ -66,5 +67,5 @@ const videoChannelsOwnSearchValidator = [
|
||||||
export {
|
export {
|
||||||
videosSearchValidator,
|
videosSearchValidator,
|
||||||
videoChannelsListSearchValidator,
|
videoChannelsListSearchValidator,
|
||||||
videoChannelsOwnSearchValidator
|
videoPlaylistsListSearchValidator
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ const SORTABLE_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ABUSES)
|
||||||
const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
|
const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
|
||||||
const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH)
|
const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH)
|
||||||
const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
|
const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
|
||||||
|
const SORTABLE_VIDEO_PLAYLISTS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
|
||||||
const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS)
|
const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS)
|
||||||
const SORTABLE_VIDEO_COMMENTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
|
const SORTABLE_VIDEO_COMMENTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
|
||||||
const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
|
const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
|
||||||
|
@ -34,6 +35,7 @@ const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
|
||||||
const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS)
|
const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS)
|
||||||
const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
|
const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
|
||||||
const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS)
|
const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS)
|
||||||
|
const videoPlaylistsSearchSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_SEARCH_COLUMNS)
|
||||||
const videoCommentsValidator = checkSort(SORTABLE_VIDEO_COMMENTS_COLUMNS)
|
const videoCommentsValidator = checkSort(SORTABLE_VIDEO_COMMENTS_COLUMNS)
|
||||||
const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
|
const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
|
||||||
const videoRatesSortValidator = checkSort(SORTABLE_VIDEO_RATES_COLUMNS)
|
const videoRatesSortValidator = checkSort(SORTABLE_VIDEO_RATES_COLUMNS)
|
||||||
|
@ -75,5 +77,6 @@ export {
|
||||||
userNotificationsSortValidator,
|
userNotificationsSortValidator,
|
||||||
videoPlaylistsSortValidator,
|
videoPlaylistsSortValidator,
|
||||||
videoRedundanciesSortValidator,
|
videoRedundanciesSortValidator,
|
||||||
|
videoPlaylistsSearchSortValidator,
|
||||||
pluginsSortValidator
|
pluginsSortValidator
|
||||||
}
|
}
|
||||||
|
|
|
@ -141,6 +141,18 @@ const videoChannelStatsValidator = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const videoChannelsListValidator = [
|
||||||
|
query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
|
||||||
|
|
||||||
|
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
logger.debug('Checking video channels search query', { parameters: req.query })
|
||||||
|
|
||||||
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@ -148,6 +160,7 @@ export {
|
||||||
videoChannelsUpdateValidator,
|
videoChannelsUpdateValidator,
|
||||||
videoChannelsRemoveValidator,
|
videoChannelsRemoveValidator,
|
||||||
videoChannelsNameWithHostValidator,
|
videoChannelsNameWithHostValidator,
|
||||||
|
videoChannelsListValidator,
|
||||||
localVideoChannelValidator,
|
localVideoChannelValidator,
|
||||||
videoChannelStatsValidator
|
videoChannelStatsValidator
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@ export class AbstractVideosQueryBuilder {
|
||||||
logging: options.logging,
|
logging: options.logging,
|
||||||
replacements: this.replacements,
|
replacements: this.replacements,
|
||||||
type: QueryTypes.SELECT as QueryTypes.SELECT,
|
type: QueryTypes.SELECT as QueryTypes.SELECT,
|
||||||
next: false
|
nest: false
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.sequelize.query<any>(this.query, queryOptions)
|
return this.sequelize.query<any>(this.query, queryOptions)
|
||||||
|
|
|
@ -434,8 +434,8 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
|
||||||
sort: string
|
sort: string
|
||||||
}) {
|
}) {
|
||||||
const attributesInclude = []
|
const attributesInclude = []
|
||||||
const escapedSearch = VideoModel.sequelize.escape(options.search)
|
const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
|
||||||
const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
|
const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
|
||||||
attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
|
attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
|
||||||
|
|
||||||
const query = {
|
const query = {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
|
import { FindOptions, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
|
||||||
import {
|
import {
|
||||||
AllowNull,
|
AllowNull,
|
||||||
BelongsTo,
|
BelongsTo,
|
||||||
|
@ -53,7 +53,15 @@ import {
|
||||||
} from '../../types/models/video/video-playlist'
|
} from '../../types/models/video/video-playlist'
|
||||||
import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
|
import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
|
||||||
import { ActorModel } from '../actor/actor'
|
import { ActorModel } from '../actor/actor'
|
||||||
import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils'
|
import {
|
||||||
|
buildServerIdsFollowedBy,
|
||||||
|
buildTrigramSearchIndex,
|
||||||
|
buildWhereIdOrUUID,
|
||||||
|
createSimilarityAttribute,
|
||||||
|
getPlaylistSort,
|
||||||
|
isOutdated,
|
||||||
|
throwIfNotValid
|
||||||
|
} from '../utils'
|
||||||
import { ThumbnailModel } from './thumbnail'
|
import { ThumbnailModel } from './thumbnail'
|
||||||
import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
|
import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
|
||||||
import { VideoPlaylistElementModel } from './video-playlist-element'
|
import { VideoPlaylistElementModel } from './video-playlist-element'
|
||||||
|
@ -74,6 +82,11 @@ type AvailableForListOptions = {
|
||||||
videoChannelId?: number
|
videoChannelId?: number
|
||||||
listMyPlaylists?: boolean
|
listMyPlaylists?: boolean
|
||||||
search?: string
|
search?: string
|
||||||
|
withVideos?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoLengthSelect () {
|
||||||
|
return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"'
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scopes(() => ({
|
@Scopes(() => ({
|
||||||
|
@ -89,7 +102,7 @@ type AvailableForListOptions = {
|
||||||
attributes: {
|
attributes: {
|
||||||
include: [
|
include: [
|
||||||
[
|
[
|
||||||
literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
|
literal(`(${getVideoLengthSelect()})`),
|
||||||
'videosLength'
|
'videosLength'
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
@ -178,11 +191,28 @@ type AvailableForListOptions = {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.withVideos === true) {
|
||||||
|
whereAnd.push(
|
||||||
|
literal(`(${getVideoLengthSelect()}) != 0`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const attributesInclude = []
|
||||||
|
|
||||||
if (options.search) {
|
if (options.search) {
|
||||||
|
const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search)
|
||||||
|
const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%')
|
||||||
|
attributesInclude.push(createSimilarityAttribute('VideoPlaylistModel.name', options.search))
|
||||||
|
|
||||||
whereAnd.push({
|
whereAnd.push({
|
||||||
name: {
|
[Op.or]: [
|
||||||
[Op.iLike]: '%' + options.search + '%'
|
Sequelize.literal(
|
||||||
}
|
'lower(immutable_unaccent("VideoPlaylistModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
|
||||||
|
),
|
||||||
|
Sequelize.literal(
|
||||||
|
'lower(immutable_unaccent("VideoPlaylistModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
|
||||||
|
)
|
||||||
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -191,6 +221,9 @@ type AvailableForListOptions = {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
attributes: {
|
||||||
|
include: attributesInclude
|
||||||
|
},
|
||||||
where,
|
where,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
|
@ -211,6 +244,8 @@ type AvailableForListOptions = {
|
||||||
@Table({
|
@Table({
|
||||||
tableName: 'videoPlaylist',
|
tableName: 'videoPlaylist',
|
||||||
indexes: [
|
indexes: [
|
||||||
|
buildTrigramSearchIndex('video_playlist_name_trigram', 'name'),
|
||||||
|
|
||||||
{
|
{
|
||||||
fields: [ 'ownerAccountId' ]
|
fields: [ 'ownerAccountId' ]
|
||||||
},
|
},
|
||||||
|
@ -314,6 +349,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
|
||||||
videoChannelId?: number
|
videoChannelId?: number
|
||||||
listMyPlaylists?: boolean
|
listMyPlaylists?: boolean
|
||||||
search?: string
|
search?: string
|
||||||
|
withVideos?: boolean // false by default
|
||||||
}) {
|
}) {
|
||||||
const query = {
|
const query = {
|
||||||
offset: options.start,
|
offset: options.start,
|
||||||
|
@ -331,7 +367,8 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
|
||||||
accountId: options.accountId,
|
accountId: options.accountId,
|
||||||
videoChannelId: options.videoChannelId,
|
videoChannelId: options.videoChannelId,
|
||||||
listMyPlaylists: options.listMyPlaylists,
|
listMyPlaylists: options.listMyPlaylists,
|
||||||
search: options.search
|
search: options.search,
|
||||||
|
withVideos: options.withVideos || false
|
||||||
} as AvailableForListOptions
|
} as AvailableForListOptions
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -347,6 +384,21 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static searchForApi (options: {
|
||||||
|
followerActorId: number
|
||||||
|
start: number
|
||||||
|
count: number
|
||||||
|
sort: string
|
||||||
|
search?: string
|
||||||
|
}) {
|
||||||
|
return VideoPlaylistModel.listForApi({
|
||||||
|
...options,
|
||||||
|
type: VideoPlaylistType.REGULAR,
|
||||||
|
listMyPlaylists: false,
|
||||||
|
withVideos: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) {
|
static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) {
|
||||||
const where = {
|
const where = {
|
||||||
privacy: VideoPlaylistPrivacy.PUBLIC
|
privacy: VideoPlaylistPrivacy.PUBLIC
|
||||||
|
@ -445,6 +497,18 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
|
||||||
return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
|
return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static loadByUrlWithAccountAndChannelSummary (url: string): Promise<MVideoPlaylistFullSummary> {
|
||||||
|
const query = {
|
||||||
|
where: {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return VideoPlaylistModel
|
||||||
|
.scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
|
||||||
|
.findOne(query)
|
||||||
|
}
|
||||||
|
|
||||||
static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
|
static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
|
||||||
return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
|
return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
|
||||||
}
|
}
|
||||||
|
@ -535,6 +599,10 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
|
||||||
return setAsUpdated('videoPlaylist', this.id)
|
return setAsUpdated('videoPlaylist', this.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setVideosLength (videosLength: number) {
|
||||||
|
this.set('videosLength' as any, videosLength, { raw: true })
|
||||||
|
}
|
||||||
|
|
||||||
isOwned () {
|
isOwned () {
|
||||||
return this.OwnerAccount.isOwned()
|
return this.OwnerAccount.isOwned()
|
||||||
}
|
}
|
||||||
|
@ -551,6 +619,8 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
|
||||||
uuid: this.uuid,
|
uuid: this.uuid,
|
||||||
isLocal: this.isOwned(),
|
isLocal: this.isOwned(),
|
||||||
|
|
||||||
|
url: this.url,
|
||||||
|
|
||||||
displayName: this.name,
|
displayName: this.name,
|
||||||
description: this.description,
|
description: this.description,
|
||||||
privacy: {
|
privacy: {
|
||||||
|
|
|
@ -140,6 +140,30 @@ describe('Test videos API validator', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('When searching video playlists', function () {
|
||||||
|
const path = '/api/v1/search/video-playlists/'
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
search: 'coucou'
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Should fail with a bad start pagination', async function () {
|
||||||
|
await checkBadStartPagination(server.url, path, null, query)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with a bad count pagination', async function () {
|
||||||
|
await checkBadCountPagination(server.url, path, null, query)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an incorrect sort', async function () {
|
||||||
|
await checkBadSortPagination(server.url, path, null, query)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should success with the correct parameters', async function () {
|
||||||
|
await makeGetRequest({ url: server.url, path, query, statusCodeExpected: HttpStatusCode.OK_200 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('When searching video channels', function () {
|
describe('When searching video channels', function () {
|
||||||
const path = '/api/v1/search/video-channels/'
|
const path = '/api/v1/search/video-channels/'
|
||||||
|
|
||||||
|
@ -171,6 +195,7 @@ describe('Test videos API validator', function () {
|
||||||
|
|
||||||
const query = { search: 'coucou' }
|
const query = { search: 'coucou' }
|
||||||
const paths = [
|
const paths = [
|
||||||
|
'/api/v1/search/video-playlists/',
|
||||||
'/api/v1/search/video-channels/',
|
'/api/v1/search/video-channels/',
|
||||||
'/api/v1/search/videos/'
|
'/api/v1/search/videos/'
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
|
import './search-activitypub-video-playlists'
|
||||||
import './search-activitypub-video-channels'
|
import './search-activitypub-video-channels'
|
||||||
import './search-activitypub-videos'
|
import './search-activitypub-videos'
|
||||||
import './search-index'
|
|
||||||
import './search-videos'
|
|
||||||
import './search-channels'
|
import './search-channels'
|
||||||
|
import './search-index'
|
||||||
|
import './search-playlists'
|
||||||
|
import './search-videos'
|
||||||
|
|
|
@ -106,9 +106,25 @@ describe('Test ActivityPub video channels search', function () {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should search a local video channel with an alternative URL', async function () {
|
||||||
|
const search = 'http://localhost:' + servers[0].port + '/c/channel1_server1'
|
||||||
|
|
||||||
|
for (const token of [ undefined, servers[0].accessToken ]) {
|
||||||
|
const res = await searchVideoChannel(servers[0].url, search, token)
|
||||||
|
|
||||||
|
expect(res.body.total).to.equal(1)
|
||||||
|
expect(res.body.data).to.be.an('array')
|
||||||
|
expect(res.body.data).to.have.lengthOf(1)
|
||||||
|
expect(res.body.data[0].name).to.equal('channel1_server1')
|
||||||
|
expect(res.body.data[0].displayName).to.equal('Channel 1 server 1')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('Should search a remote video channel with URL or handle', async function () {
|
it('Should search a remote video channel with URL or handle', async function () {
|
||||||
const searches = [
|
const searches = [
|
||||||
'http://localhost:' + servers[1].port + '/video-channels/channel1_server2',
|
'http://localhost:' + servers[1].port + '/video-channels/channel1_server2',
|
||||||
|
'http://localhost:' + servers[1].port + '/c/channel1_server2',
|
||||||
|
'http://localhost:' + servers[1].port + '/c/channel1_server2/videos',
|
||||||
'channel1_server2@localhost:' + servers[1].port
|
'channel1_server2@localhost:' + servers[1].port
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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')
|
expect(res.body.data[0].name).to.equal('video 1 on server 1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should search a remote video', async function () {
|
it('Should search a local video with an alternative URL', async function () {
|
||||||
const search = 'http://localhost:' + servers[1].port + '/videos/watch/' + videoServer2UUID
|
const search = 'http://localhost:' + servers[0].port + '/w/' + videoServer1UUID
|
||||||
const res = await searchVideoWithToken(servers[0].url, search, servers[0].accessToken)
|
const res1 = await searchVideo(servers[0].url, search)
|
||||||
|
const res2 = await searchVideoWithToken(servers[0].url, search, servers[0].accessToken)
|
||||||
|
|
||||||
expect(res.body.total).to.equal(1)
|
for (const res of [ res1, res2 ]) {
|
||||||
expect(res.body.data).to.be.an('array')
|
expect(res.body.total).to.equal(1)
|
||||||
expect(res.body.data).to.have.lengthOf(1)
|
expect(res.body.data).to.be.an('array')
|
||||||
expect(res.body.data[0].name).to.equal('video 1 on server 2')
|
expect(res.body.data).to.have.lengthOf(1)
|
||||||
|
expect(res.body.data[0].name).to.equal('video 1 on server 1')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should search a remote video', async function () {
|
||||||
|
const searches = [
|
||||||
|
'http://localhost:' + servers[1].port + '/w/' + videoServer2UUID,
|
||||||
|
'http://localhost:' + servers[1].port + '/videos/watch/' + videoServer2UUID
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const search of searches) {
|
||||||
|
const res = await searchVideoWithToken(servers[0].url, search, servers[0].accessToken)
|
||||||
|
|
||||||
|
expect(res.body.total).to.equal(1)
|
||||||
|
expect(res.body.data).to.be.an('array')
|
||||||
|
expect(res.body.data).to.have.lengthOf(1)
|
||||||
|
expect(res.body.data[0].name).to.equal('video 1 on server 2')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should not list this remote video', async function () {
|
it('Should not list this remote video', async function () {
|
||||||
|
@ -95,7 +114,7 @@ describe('Test ActivityPub videos search', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should update video of server 2, and refresh it on server 1', async function () {
|
it('Should update video of server 2, and refresh it on server 1', async function () {
|
||||||
this.timeout(60000)
|
this.timeout(120000)
|
||||||
|
|
||||||
const channelAttributes = {
|
const channelAttributes = {
|
||||||
name: 'super_channel',
|
name: 'super_channel',
|
||||||
|
@ -134,7 +153,7 @@ describe('Test ActivityPub videos search', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should delete video of server 2, and delete it on server 1', async function () {
|
it('Should delete video of server 2, and delete it on server 1', async function () {
|
||||||
this.timeout(60000)
|
this.timeout(120000)
|
||||||
|
|
||||||
await removeVideo(servers[1].url, servers[1].accessToken, videoServer2UUID)
|
await removeVideo(servers[1].url, servers[1].accessToken, videoServer2UUID)
|
||||||
|
|
||||||
|
|
|
@ -2,19 +2,21 @@
|
||||||
|
|
||||||
import 'mocha'
|
import 'mocha'
|
||||||
import * as chai from 'chai'
|
import * as chai from 'chai'
|
||||||
|
import { advancedVideoChannelSearch, searchVideoChannel } from '@shared/extra-utils/search/video-channels'
|
||||||
|
import { Video, VideoChannel, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType, VideosSearchQuery } from '@shared/models'
|
||||||
import {
|
import {
|
||||||
|
advancedVideoPlaylistSearch,
|
||||||
|
advancedVideosSearch,
|
||||||
cleanupTests,
|
cleanupTests,
|
||||||
flushAndRunServer,
|
flushAndRunServer,
|
||||||
|
immutableAssign,
|
||||||
searchVideo,
|
searchVideo,
|
||||||
|
searchVideoPlaylists,
|
||||||
ServerInfo,
|
ServerInfo,
|
||||||
setAccessTokensToServers,
|
setAccessTokensToServers,
|
||||||
updateCustomSubConfig,
|
updateCustomSubConfig,
|
||||||
uploadVideo,
|
uploadVideo
|
||||||
advancedVideosSearch,
|
|
||||||
immutableAssign
|
|
||||||
} from '../../../../shared/extra-utils'
|
} from '../../../../shared/extra-utils'
|
||||||
import { searchVideoChannel, advancedVideoChannelSearch } from '@shared/extra-utils/search/video-channels'
|
|
||||||
import { VideosSearchQuery, Video, VideoChannel } from '@shared/models'
|
|
||||||
|
|
||||||
const expect = chai.expect
|
const expect = chai.expect
|
||||||
|
|
||||||
|
@ -277,6 +279,56 @@ describe('Test videos search', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Playlists search', async function () {
|
||||||
|
|
||||||
|
it('Should make a simple search and not have results', async function () {
|
||||||
|
const res = await searchVideoPlaylists(server.url, 'a'.repeat(500))
|
||||||
|
|
||||||
|
expect(res.body.total).to.equal(0)
|
||||||
|
expect(res.body.data).to.have.lengthOf(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should make a search and have results', async function () {
|
||||||
|
const res = await advancedVideoPlaylistSearch(server.url, { search: 'E2E playlist', sort: '-match' })
|
||||||
|
|
||||||
|
expect(res.body.total).to.be.greaterThan(0)
|
||||||
|
expect(res.body.data).to.have.length.greaterThan(0)
|
||||||
|
|
||||||
|
const videoPlaylist: VideoPlaylist = res.body.data[0]
|
||||||
|
|
||||||
|
expect(videoPlaylist.url).to.equal('https://peertube2.cpy.re/videos/watch/playlist/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a')
|
||||||
|
expect(videoPlaylist.thumbnailUrl).to.exist
|
||||||
|
expect(videoPlaylist.embedUrl).to.equal('https://peertube2.cpy.re/video-playlists/embed/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a')
|
||||||
|
|
||||||
|
expect(videoPlaylist.type.id).to.equal(VideoPlaylistType.REGULAR)
|
||||||
|
expect(videoPlaylist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC)
|
||||||
|
expect(videoPlaylist.videosLength).to.exist
|
||||||
|
|
||||||
|
expect(videoPlaylist.createdAt).to.exist
|
||||||
|
expect(videoPlaylist.updatedAt).to.exist
|
||||||
|
|
||||||
|
expect(videoPlaylist.uuid).to.equal('73804a40-da9a-40c2-b1eb-2c6d9eec8f0a')
|
||||||
|
expect(videoPlaylist.displayName).to.exist
|
||||||
|
|
||||||
|
expect(videoPlaylist.ownerAccount.url).to.equal('https://peertube2.cpy.re/accounts/chocobozzz')
|
||||||
|
expect(videoPlaylist.ownerAccount.name).to.equal('chocobozzz')
|
||||||
|
expect(videoPlaylist.ownerAccount.host).to.equal('peertube2.cpy.re')
|
||||||
|
expect(videoPlaylist.ownerAccount.avatar).to.exist
|
||||||
|
|
||||||
|
expect(videoPlaylist.videoChannel.url).to.equal('https://peertube2.cpy.re/video-channels/chocobozzz_channel')
|
||||||
|
expect(videoPlaylist.videoChannel.name).to.equal('chocobozzz_channel')
|
||||||
|
expect(videoPlaylist.videoChannel.host).to.equal('peertube2.cpy.re')
|
||||||
|
expect(videoPlaylist.videoChannel.avatar).to.exist
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should have a correct pagination', async function () {
|
||||||
|
const res = await advancedVideoChannelSearch(server.url, { search: 'root', start: 0, count: 2 })
|
||||||
|
|
||||||
|
expect(res.body.total).to.be.greaterThan(2)
|
||||||
|
expect(res.body.data).to.have.lengthOf(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
await cleanupTests([ server ])
|
await cleanupTests([ server ])
|
||||||
})
|
})
|
||||||
|
|
|
@ -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",
|
"version": "0.0.1",
|
||||||
"description": "Plugin test 2",
|
"description": "Plugin test filter and translations",
|
||||||
"engine": {
|
"engine": {
|
||||||
"peertube": ">=1.3.0"
|
"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.local.list.result',
|
||||||
'filter:api.search.video-channels.index.list.params',
|
'filter:api.search.video-channels.index.list.params',
|
||||||
'filter:api.search.video-channels.index.list.result',
|
'filter:api.search.video-channels.index.list.result',
|
||||||
|
'filter:api.search.video-playlists.local.list.params',
|
||||||
|
'filter:api.search.video-playlists.local.list.result',
|
||||||
|
'filter:api.search.video-playlists.index.list.params',
|
||||||
|
'filter:api.search.video-playlists.index.list.result'
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const h of searchHooks) {
|
for (const h of searchHooks) {
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-code
|
||||||
import {
|
import {
|
||||||
addVideoCommentReply,
|
addVideoCommentReply,
|
||||||
addVideoCommentThread,
|
addVideoCommentThread,
|
||||||
|
advancedVideoPlaylistSearch,
|
||||||
advancedVideosSearch,
|
advancedVideosSearch,
|
||||||
createLive,
|
createLive,
|
||||||
createVideoPlaylist,
|
createVideoPlaylist,
|
||||||
|
@ -71,7 +72,7 @@ describe('Test plugin filter hooks', function () {
|
||||||
await installPlugin({
|
await installPlugin({
|
||||||
url: servers[0].url,
|
url: servers[0].url,
|
||||||
accessToken: servers[0].accessToken,
|
accessToken: servers[0].accessToken,
|
||||||
path: getPluginTestPath('-two')
|
path: getPluginTestPath('-filter-translations')
|
||||||
})
|
})
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
|
@ -525,6 +526,27 @@ describe('Test plugin filter hooks', function () {
|
||||||
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.params', 1)
|
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.params', 1)
|
||||||
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.result', 1)
|
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.result', 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should run filter:api.search.video-playlists.local.list.{params,result}', async function () {
|
||||||
|
await advancedVideoPlaylistSearch(servers[0].url, {
|
||||||
|
search: 'Sun Jian'
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.params', 1)
|
||||||
|
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.result', 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should run filter:api.search.video-playlists.index.list.{params,result}', async function () {
|
||||||
|
await advancedVideoPlaylistSearch(servers[0].url, {
|
||||||
|
search: 'Sun Jian',
|
||||||
|
searchTarget: 'search-index'
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.params', 1)
|
||||||
|
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.result', 1)
|
||||||
|
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.index.list.params', 1)
|
||||||
|
await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.index.list.result', 1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
|
|
|
@ -31,7 +31,7 @@ describe('Test plugin translations', function () {
|
||||||
await installPlugin({
|
await installPlugin({
|
||||||
url: server.url,
|
url: server.url,
|
||||||
accessToken: server.accessToken,
|
accessToken: server.accessToken,
|
||||||
path: getPluginTestPath('-two')
|
path: getPluginTestPath('-filter-translations')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ describe('Test plugin translations', function () {
|
||||||
'peertube-plugin-test': {
|
'peertube-plugin-test': {
|
||||||
Hi: 'Coucou'
|
Hi: 'Coucou'
|
||||||
},
|
},
|
||||||
'peertube-plugin-test-two': {
|
'peertube-plugin-test-filter-translations': {
|
||||||
'Hello world': 'Bonjour le monde'
|
'Hello world': 'Bonjour le monde'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -58,14 +58,14 @@ describe('Test plugin translations', function () {
|
||||||
const res = await getPluginTranslations({ url: server.url, locale: 'it-IT' })
|
const res = await getPluginTranslations({ url: server.url, locale: 'it-IT' })
|
||||||
|
|
||||||
expect(res.body).to.deep.equal({
|
expect(res.body).to.deep.equal({
|
||||||
'peertube-plugin-test-two': {
|
'peertube-plugin-test-filter-translations': {
|
||||||
'Hello world': 'Ciao, mondo!'
|
'Hello world': 'Ciao, mondo!'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should remove the plugin and remove the locales', async function () {
|
it('Should remove the plugin and remove the locales', async function () {
|
||||||
await uninstallPlugin({ url: server.url, accessToken: server.accessToken, npmName: 'peertube-plugin-test-two' })
|
await uninstallPlugin({ url: server.url, accessToken: server.accessToken, npmName: 'peertube-plugin-test-filter-translations' })
|
||||||
|
|
||||||
{
|
{
|
||||||
const res = await getPluginTranslations({ url: server.url, locale: 'fr-FR' })
|
const res = await getPluginTranslations({ url: server.url, locale: 'fr-FR' })
|
||||||
|
|
|
@ -69,7 +69,7 @@ export type MVideoPlaylistAccountChannelDefault =
|
||||||
// With all associations
|
// With all associations
|
||||||
|
|
||||||
export type MVideoPlaylistFull =
|
export type MVideoPlaylistFull =
|
||||||
MVideoPlaylist &
|
MVideoPlaylistVideosLength &
|
||||||
Use<'OwnerAccount', MAccountDefault> &
|
Use<'OwnerAccount', MAccountDefault> &
|
||||||
Use<'VideoChannel', MChannelDefault> &
|
Use<'VideoChannel', MChannelDefault> &
|
||||||
Use<'Thumbnail', MThumbnail>
|
Use<'Thumbnail', MThumbnail>
|
||||||
|
@ -84,7 +84,7 @@ export type MVideoPlaylistAccountChannelSummary =
|
||||||
Use<'VideoChannel', MChannelSummary>
|
Use<'VideoChannel', MChannelSummary>
|
||||||
|
|
||||||
export type MVideoPlaylistFullSummary =
|
export type MVideoPlaylistFullSummary =
|
||||||
MVideoPlaylist &
|
MVideoPlaylistVideosLength &
|
||||||
Use<'Thumbnail', MThumbnail> &
|
Use<'Thumbnail', MThumbnail> &
|
||||||
Use<'OwnerAccount', MAccountSummary> &
|
Use<'OwnerAccount', MAccountSummary> &
|
||||||
Use<'VideoChannel', MChannelSummary>
|
Use<'VideoChannel', MChannelSummary>
|
||||||
|
|
|
@ -19,6 +19,8 @@ export * from './plugins/mock-blocklist'
|
||||||
export * from './requests/check-api-params'
|
export * from './requests/check-api-params'
|
||||||
export * from './requests/requests'
|
export * from './requests/requests'
|
||||||
|
|
||||||
|
export * from './search/video-channels'
|
||||||
|
export * from './search/video-playlists'
|
||||||
export * from './search/videos'
|
export * from './search/videos'
|
||||||
|
|
||||||
export * from './server/activitypub'
|
export * from './server/activitypub'
|
||||||
|
|
|
@ -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 params/result of the function that fetch videos according to the user search
|
||||||
'filter:api.search.videos.list.params': true,
|
'filter:api.search.videos.list.params': true,
|
||||||
'filter:api.search.videos.list.result': true,
|
'filter:api.search.videos.list.result': true,
|
||||||
// Filter params/result of the function that fetch video-channels according to the user search
|
// Filter params/result of the function that fetch video channels according to the user search
|
||||||
'filter:api.search.video-channels.list.params': true,
|
'filter:api.search.video-channels.list.params': true,
|
||||||
'filter:api.search.video-channels.list.result': true,
|
'filter:api.search.video-channels.list.result': true,
|
||||||
|
// Filter params/result of the function that fetch video playlists according to the user search
|
||||||
|
'filter:api.search.video-playlists.list.params': true,
|
||||||
|
'filter:api.search.video-playlists.list.result': true,
|
||||||
|
|
||||||
// Filter form
|
// Filter form
|
||||||
'filter:api.signup.registration.create.params': true,
|
'filter:api.signup.registration.create.params': true,
|
||||||
|
|
|
@ -27,6 +27,10 @@ export const serverFilterHookObject = {
|
||||||
'filter:api.search.video-channels.local.list.result': true,
|
'filter:api.search.video-channels.local.list.result': true,
|
||||||
'filter:api.search.video-channels.index.list.params': true,
|
'filter:api.search.video-channels.index.list.params': true,
|
||||||
'filter:api.search.video-channels.index.list.result': true,
|
'filter:api.search.video-channels.index.list.result': true,
|
||||||
|
'filter:api.search.video-playlists.local.list.params': true,
|
||||||
|
'filter:api.search.video-playlists.local.list.result': true,
|
||||||
|
'filter:api.search.video-playlists.index.list.params': true,
|
||||||
|
'filter:api.search.video-playlists.index.list.result': true,
|
||||||
|
|
||||||
// Filter the result of the get function
|
// Filter the result of the get function
|
||||||
// Used to get detailed video information (video watch page for example)
|
// Used to get detailed video information (video watch page for example)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
export * from './boolean-both-query.model'
|
export * from './boolean-both-query.model'
|
||||||
export * from './search-target-query.model'
|
export * from './search-target-query.model'
|
||||||
export * from './videos-common-query.model'
|
export * from './videos-common-query.model'
|
||||||
export * from './videos-search-query.model'
|
|
||||||
export * from './video-channels-search-query.model'
|
export * from './video-channels-search-query.model'
|
||||||
|
export * from './video-playlists-search-query.model'
|
||||||
|
export * from './videos-search-query.model'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { SearchTargetQuery } from "./search-target-query.model"
|
import { SearchTargetQuery } from './search-target-query.model'
|
||||||
|
|
||||||
export interface VideoChannelsSearchQuery extends SearchTargetQuery {
|
export interface VideoChannelsSearchQuery extends SearchTargetQuery {
|
||||||
search: string
|
search: string
|
||||||
|
|
|
@ -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
|
uri: string
|
||||||
type: FetchType
|
type: FetchType
|
||||||
videoId?: number
|
videoId?: number
|
||||||
accountId?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ActivitypubHttpUnicastPayload = {
|
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'
|
import { OAuth2ErrorCode, ServerErrorCode } from './server-error-code.enum'
|
||||||
|
|
||||||
export interface PeerTubeProblemDocumentData {
|
export interface PeerTubeProblemDocumentData {
|
||||||
|
|
|
@ -8,17 +8,21 @@ export interface VideoPlaylist {
|
||||||
uuid: string
|
uuid: string
|
||||||
isLocal: boolean
|
isLocal: boolean
|
||||||
|
|
||||||
|
url: string
|
||||||
|
|
||||||
displayName: string
|
displayName: string
|
||||||
description: string
|
description: string
|
||||||
privacy: VideoConstant<VideoPlaylistPrivacy>
|
privacy: VideoConstant<VideoPlaylistPrivacy>
|
||||||
|
|
||||||
thumbnailPath: string
|
thumbnailPath: string
|
||||||
|
thumbnailUrl?: string
|
||||||
|
|
||||||
videosLength: number
|
videosLength: number
|
||||||
|
|
||||||
type: VideoConstant<VideoPlaylistType>
|
type: VideoConstant<VideoPlaylistType>
|
||||||
|
|
||||||
embedPath: string
|
embedPath: string
|
||||||
|
embedUrl?: string
|
||||||
|
|
||||||
createdAt: Date | string
|
createdAt: Date | string
|
||||||
updatedAt: Date | string
|
updatedAt: Date | string
|
||||||
|
|
|
@ -3584,6 +3584,47 @@ paths:
|
||||||
'500':
|
'500':
|
||||||
description: search index unavailable
|
description: search index unavailable
|
||||||
|
|
||||||
|
/search/video-playlists:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Search
|
||||||
|
summary: Search playlists
|
||||||
|
operationId: searchPlaylists
|
||||||
|
parameters:
|
||||||
|
- name: search
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: >
|
||||||
|
String to search. If the user can make a remote URI search, and the string is an URI then the
|
||||||
|
PeerTube instance will fetch the remote object and add it to its database. Then,
|
||||||
|
you can use the REST API to fetch the complete playlist information and interact with it.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- $ref: '#/components/parameters/start'
|
||||||
|
- $ref: '#/components/parameters/count'
|
||||||
|
- $ref: '#/components/parameters/searchTarget'
|
||||||
|
- $ref: '#/components/parameters/sort'
|
||||||
|
callbacks:
|
||||||
|
'searchTarget === search-index':
|
||||||
|
$ref: '#/components/callbacks/searchIndex'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: successful operation
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/VideoPlaylist'
|
||||||
|
'500':
|
||||||
|
description: search index unavailable
|
||||||
|
|
||||||
/server/blocklist/accounts:
|
/server/blocklist/accounts:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|
Loading…
Reference in New Issue