1
0
Fork 0

Add ability to search playlists

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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