Verified Commit 0f659613 authored by Chocobozzz's avatar Chocobozzz
Browse files

Add ability to search playlists

parent 33eb19e5
......@@ -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()
}
}
......
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)
})
)
}
}
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
import { SearchComponent } from './search.component'
import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
import { ChannelLazyLoadResolver, PlaylistLazyLoadResolver, VideoLazyLoadResolver } from './shared'
const searchRoutes: Routes = [
{
......@@ -27,6 +26,13 @@ const searchRoutes: Routes = [
resolve: {
data: ChannelLazyLoadResolver
}
},
{
path: 'lazy-load-playlist',
component: SearchComponent,
resolve: {
data: PlaylistLazyLoadResolver
}
}
]
......
......@@ -59,10 +59,17 @@
<div *ngIf="isVideo(result)" class="entry video">
<my-video-miniature
[video]="result" [user]="userMiniature" [displayAsRow]="true" [displayVideoActions]="!hideActions()"
[displayOptions]="videoDisplayOptions" [videoLinkType]="getVideoLinkType()"
[displayOptions]="videoDisplayOptions" [videoLinkType]="getLinkType()"
(videoBlocked)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)"
></my-video-miniature>
</div>
<div *ngIf="isPlaylist(result)" class="entry video-playlist">
<my-video-playlist-miniature
[playlist]="result" [displayAsRow]="true" [displayChannel]="true"
[linkType]="getLinkType()"
></my-video-playlist-miniature>
</div>
</ng-container>
</div>
import { forkJoin, of, Subscription } from 'rxjs'
import { LinkType } from 'src/types/link.type'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, ComponentPagination, HooksService, MetaService, Notifier, ServerService, User, UserService } from '@app/core'
import { AuthService, HooksService, MetaService, Notifier, ServerService, User, UserService } from '@app/core'
import { immutableAssign } from '@app/helpers'
import { Video, VideoChannel } from '@app/shared/shared-main'
import { AdvancedSearch, SearchService } from '@app/shared/shared-search'
import { MiniatureDisplayOptions, VideoLinkType } from '@app/shared/shared-video-miniature'
import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
import { VideoPlaylist } from '@app/shared/shared-video-playlist'
import { HTMLServerConfig, SearchTargetType } from '@shared/models'
@Component({
......@@ -16,10 +18,9 @@ import { HTMLServerConfig, SearchTargetType } from '@shared/models'
export class SearchComponent implements OnInit, OnDestroy {
results: (Video | VideoChannel)[] = []
pagination: ComponentPagination = {
pagination = {
currentPage: 1,
itemsPerPage: 10, // Only for videos, use another variable for channels
totalItems: null
totalItems: null as number
}
advancedSearch: AdvancedSearch = new AdvancedSearch()
isSearchFilterCollapsed = true
......@@ -45,6 +46,11 @@ export class SearchComponent implements OnInit, OnDestroy {
private firstSearch = true
private channelsPerPage = 2
private playlistsPerPage = 2
private videosPerPage = 10
private hasMoreResults = true
private isSearching = false
private lastSearchTarget: SearchTargetType
......@@ -104,77 +110,62 @@ export class SearchComponent implements OnInit, OnDestroy {
if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
}
isVideoChannel (d: VideoChannel | Video): d is VideoChannel {
isVideoChannel (d: VideoChannel | Video | VideoPlaylist): d is VideoChannel {
return d instanceof VideoChannel
}
isVideo (v: VideoChannel | Video): v is Video {
isVideo (v: VideoChannel | Video | VideoPlaylist): v is Video {
return v instanceof Video
}
isPlaylist (v: VideoChannel | Video | VideoPlaylist): v is VideoPlaylist {
return v instanceof VideoPlaylist
}
isUserLoggedIn () {
return this.authService.isLoggedIn()
}
getVideoLinkType (): VideoLinkType {
if (this.advancedSearch.searchTarget === 'search-index') {
const remoteUriConfig = this.serverConfig.search.remoteUri
search () {
this.isSearching = true
// Redirect on the external instance if not allowed to fetch remote data
if ((!this.isUserLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users) {
return 'external'
forkJoin([
this.getVideoChannelObs(),
this.getVideoPlaylistObs(),
this.getVideosObs()
]).subscribe(results => {
for (const result of results) {
this.results = this.results.concat(result.data)
}
return 'lazy-load'
}
this.pagination.totalItems = results.reduce((p, r) => p += r.total, 0)
this.lastSearchTarget = this.advancedSearch.searchTarget
return 'internal'
}
this.hasMoreResults = this.results.length < this.pagination.totalItems
},
search () {
forkJoin([
this.getVideosObs(),
this.getVideoChannelObs()
]).subscribe(
([videosResult, videoChannelsResult]) => {
this.results = this.results
.concat(videoChannelsResult.data)
.concat(videosResult.data)
this.pagination.totalItems = videosResult.total + videoChannelsResult.total
this.lastSearchTarget = this.advancedSearch.searchTarget
// Focus on channels if there are no enough videos
if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
this.resetPagination()
this.firstSearch = false
this.channelsPerPage = 10
this.search()
}
this.firstSearch = false
},
err => {
if (this.advancedSearch.searchTarget !== 'search-index') {
this.notifier.error(err.message)
return
}
err => {
if (this.advancedSearch.searchTarget !== 'search-index') {
this.notifier.error(err.message)
return
}
this.notifier.error(
$localize`Search index is unavailable. Retrying with instance results instead.`,
$localize`Search error`
)
this.advancedSearch.searchTarget = 'local'
this.search()
},
this.notifier.error(
$localize`Search index is unavailable. Retrying with instance results instead.`,
$localize`Search error`
)
this.advancedSearch.searchTarget = 'local'
this.search()
}
)
() => {
this.isSearching = false
})
}
onNearOfBottom () {
// Last page
if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
if (!this.hasMoreResults || this.isSearching) return
this.pagination.currentPage += 1
this.search()
......@@ -190,18 +181,33 @@ export class SearchComponent implements OnInit, OnDestroy {
return this.advancedSearch.size()
}
// Add VideoChannel for typings, but the template already checks "video" argument is a video
removeVideoFromArray (video: Video | VideoChannel) {
// Add VideoChannel/VideoPlaylist for typings, but the template already checks "video" argument is a video
removeVideoFromArray (video: Video | VideoChannel | VideoPlaylist) {
this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
}
getLinkType (): LinkType {
if (this.advancedSearch.searchTarget === 'search-index') {
const remoteUriConfig = this.serverConfig.search.remoteUri
// Redirect on the external instance if not allowed to fetch remote data
if ((!this.isUserLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users) {
return 'external'
}
return 'lazy-load'
}
return 'internal'
}
isExternalChannelUrl () {
return this.getVideoLinkType() === 'external'
return this.getLinkType() === 'external'
}
getExternalChannelUrl (channel: VideoChannel) {
// Same algorithm than videos
if (this.getVideoLinkType() === 'external') {
if (this.getLinkType() === 'external') {
return channel.url
}
......@@ -210,7 +216,7 @@ export class SearchComponent implements OnInit, OnDestroy {
}
getInternalChannelUrl (channel: VideoChannel) {
const linkType = this.getVideoLinkType()
const linkType = this.getLinkType()
if (linkType === 'internal') {
return [ '/c', channel.nameWithHost ]
......@@ -256,7 +262,7 @@ export class SearchComponent implements OnInit, OnDestroy {
private getVideosObs () {
const params = {
search: this.currentSearch,
componentPagination: this.pagination,
componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.videosPerPage }),
advancedSearch: this.advancedSearch
}
......@@ -287,6 +293,24 @@ export class SearchComponent implements OnInit, OnDestroy {
)
}
private getVideoPlaylistObs () {
if (!this.currentSearch) return of({ data: [], total: 0 })
const params = {
search: this.currentSearch,
componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.playlistsPerPage }),
searchTarget: this.advancedSearch.searchTarget
}
return this.hooks.wrapObsFun(
this.searchService.searchVideoPlaylists.bind(this.searchService),
params,
'search',
'filter:api.search.video-playlists.list.params',
'filter:api.search.video-playlists.list.result'
)
}
private getDefaultSearchTarget (): SearchTargetType {
const searchIndexConfig = this.serverConfig.search.searchIndex
......
......@@ -5,12 +5,12 @@ import { SharedMainModule } from '@app/shared/shared-main'
import { SharedSearchModule } from '@app/shared/shared-search'
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
import { SearchService } from '../shared/shared-search/search.service'
import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
import { SearchFiltersComponent } from './search-filters.component'
import { SearchRoutingModule } from './search-routing.module'
import { SearchComponent } from './search.component'
import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
import { ChannelLazyLoadResolver, PlaylistLazyLoadResolver, VideoLazyLoadResolver } from './shared'
@NgModule({
imports: [
......@@ -21,7 +21,8 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
SharedFormModule,
SharedActorImageModule,
SharedUserSubscriptionModule,
SharedVideoMiniatureModule
SharedVideoMiniatureModule,
SharedVideoPlaylistModule
],
declarations: [
......@@ -36,7 +37,8 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
providers: [
SearchService,
VideoLazyLoadResolver,
ChannelLazyLoadResolver
ChannelLazyLoadResolver,
PlaylistLazyLoadResolver
]
})
export class SearchModule { }
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
}
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
}
}
export * from './abstract-lazy-load.resolver'
export * from './channel-lazy-load.resolver'
export * from './playlist-lazy-load.resolver'
export * from './video-lazy-load.resolver'
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
}
}
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
}
}
<div class="d-inline-flex position-relative" id="typeahead-container">
<input
type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…"
type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, playlists, channels…"
[(ngModel)]="search" (ngModelChange)="onSearchChange()" (keydown)="handleKey($event)" (keydown.enter)="doSearch()"
aria-label="Search" autocomplete="off"
>
......
......@@ -3,5 +3,6 @@ export * from './bytes.pipe'
export * from './duration-formatter.pipe'
export * from './from-now.pipe'
export * from './infinite-scroller.directive'
export * from './link.component'
export * from './number-formatter.pipe'
export * from './peertube-template.directive'
<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>
a {
color: inherit;
text-decoration: inherit;
position: inherit;
width: inherit;
height: inherit;
}
import { Component, Input, ViewEncapsulation } from '@angular/core'
@Component({
selector: 'my-link',
styleUrls: [ './link.component.scss' ],
templateUrl: './link.component.html'
})
export class LinkComponent {
@Input() internalLink?: any[]
@Input() href?: string
@Input() target?: string
@Input() title?: string
@Input() tabindex: string | number
}
......@@ -4,7 +4,7 @@ import { CommonModule, DatePipe } from '@angular/common'
import { HttpClientModule } from '@angular/common/http'
import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRouteSnapshot, RouterModule } from '@angular/router'
import { RouterModule } from '@angular/router'
import {
NgbButtonsModule,
NgbCollapseModule,
......@@ -24,6 +24,7 @@ import {
DurationFormatterPipe,
FromNowPipe,
InfiniteScrollerDirective,
LinkComponent,
NumberFormatterPipe,
PeerTubeTemplateDirective
} from './angular'
......@@ -35,11 +36,11 @@ import { FeedComponent } from './feeds'
import { LoaderComponent, SmallLoaderComponent } from './loaders'
import { HelpComponent, ListOverflowComponent, SimpleSearchInputComponent, TopMenuDropdownComponent } from './misc'
import { PluginPlaceholderComponent } from './plugins'
import { ActorRedirectGuard } from './router'
import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
import { VideoCaptionService } from './video-caption'
import { VideoChannelService } from './video-channel'
import { ActorRedirectGuard } from './router'
@NgModule({
imports: [
......@@ -76,6 +77,7 @@ import { ActorRedirectGuard } from './router'
InfiniteScrollerDirective,
PeerTubeTemplateDirective,
LinkComponent,
ActionDropdownComponent,
ButtonComponent,
......@@ -130,6 +132,7 @@ import { ActorRedirectGuard } from './router'
InfiniteScrollerDirective,
PeerTubeTemplateDirective,
LinkComponent,
ActionDropdownComponent,
ButtonComponent,
......
......@@ -3,10 +3,17 @@ import { catchError, map, switchMap } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'