aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2021-06-17 16:02:38 +0200
committerChocobozzz <chocobozzz@cpy.re>2021-06-25 14:44:01 +0200
commit37a44fc915eef2140e22ceb96aba6b6eb2509007 (patch)
treedd4a370ecc96cf38c99b940261aadc27065da7ae
parent33eb19e5199cc9fa4d73c6675c97508e3e072ef9 (diff)
downloadPeerTube-37a44fc915eef2140e22ceb96aba6b6eb2509007.tar.gz
PeerTube-37a44fc915eef2140e22ceb96aba6b6eb2509007.tar.zst
PeerTube-37a44fc915eef2140e22ceb96aba6b6eb2509007.zip
Add ability to search playlists
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.ts2
-rw-r--r--client/src/app/+search/channel-lazy-load.resolver.ts35
-rw-r--r--client/src/app/+search/search-routing.module.ts10
-rw-r--r--client/src/app/+search/search.component.html9
-rw-r--r--client/src/app/+search/search.component.ts146
-rw-r--r--client/src/app/+search/search.module.ts10
-rw-r--r--client/src/app/+search/shared/abstract-lazy-load.resolver.ts (renamed from client/src/app/+search/video-lazy-load.resolver.ts)21
-rw-r--r--client/src/app/+search/shared/channel-lazy-load.resolver.ts24
-rw-r--r--client/src/app/+search/shared/index.ts4
-rw-r--r--client/src/app/+search/shared/playlist-lazy-load.resolver.ts24
-rw-r--r--client/src/app/+search/shared/video-lazy-load.resolver.ts24
-rw-r--r--client/src/app/header/search-typeahead.component.html2
-rw-r--r--client/src/app/shared/shared-main/angular/index.ts1
-rw-r--r--client/src/app/shared/shared-main/angular/link.component.html11
-rw-r--r--client/src/app/shared/shared-main/angular/link.component.scss7
-rw-r--r--client/src/app/shared/shared-main/angular/link.component.ts17
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts7
-rw-r--r--client/src/app/shared/shared-search/search.service.ts44
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.html13
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.ts5
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html15
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss3
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts47
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist.model.ts31
-rw-r--r--client/src/sass/include/_mixins.scss1
-rw-r--r--client/src/types/link.type.ts1
-rw-r--r--server/controllers/activitypub/client.ts3
-rw-r--r--server/controllers/api/search.ts294
-rw-r--r--server/controllers/api/search/index.ts16
-rw-r--r--server/controllers/api/search/search-video-channels.ts150
-rw-r--r--server/controllers/api/search/search-video-playlists.ts129
-rw-r--r--server/controllers/api/search/search-videos.ts153
-rw-r--r--server/controllers/api/video-channel.ts4
-rw-r--r--server/controllers/api/video-playlist.ts8
-rw-r--r--server/helpers/custom-validators/activitypub/playlist.ts7
-rw-r--r--server/initializers/constants.ts1
-rw-r--r--server/lib/activitypub/actors/get.ts2
-rw-r--r--server/lib/activitypub/playlists/create-update.ts34
-rw-r--r--server/lib/activitypub/playlists/get.ts35
-rw-r--r--server/lib/activitypub/playlists/index.ts1
-rw-r--r--server/lib/activitypub/playlists/refresh.ts13
-rw-r--r--server/lib/activitypub/playlists/shared/object-to-model-attributes.ts6
-rw-r--r--server/lib/activitypub/process/process-create.ts2
-rw-r--r--server/lib/activitypub/process/process-update.ts2
-rw-r--r--server/lib/activitypub/videos/get.ts7
-rw-r--r--server/lib/job-queue/handlers/activitypub-http-fetcher.ts8
-rw-r--r--server/lib/search.ts50
-rw-r--r--server/middlewares/validators/search.ts9
-rw-r--r--server/middlewares/validators/sort.ts3
-rw-r--r--server/middlewares/validators/videos/video-channels.ts13
-rw-r--r--server/models/video/sql/shared/abstract-videos-query-builder.ts2
-rw-r--r--server/models/video/video-channel.ts4
-rw-r--r--server/models/video/video-playlist.ts84
-rw-r--r--server/tests/api/check-params/search.ts25
-rw-r--r--server/tests/api/search/index.ts4
-rw-r--r--server/tests/api/search/search-activitypub-video-channels.ts16
-rw-r--r--server/tests/api/search/search-activitypub-video-playlists.ts212
-rw-r--r--server/tests/api/search/search-activitypub-videos.ts35
-rw-r--r--server/tests/api/search/search-index.ts62
-rw-r--r--server/tests/api/search/search-playlists.ts128
-rw-r--r--server/tests/fixtures/peertube-plugin-test-filter-translations/languages/fr.json (renamed from server/tests/fixtures/peertube-plugin-test-two/languages/fr.json)0
-rw-r--r--server/tests/fixtures/peertube-plugin-test-filter-translations/languages/it.json (renamed from server/tests/fixtures/peertube-plugin-test-two/languages/it.json)0
-rw-r--r--server/tests/fixtures/peertube-plugin-test-filter-translations/main.js (renamed from server/tests/fixtures/peertube-plugin-test-two/main.js)0
-rw-r--r--server/tests/fixtures/peertube-plugin-test-filter-translations/package.json (renamed from server/tests/fixtures/peertube-plugin-test-two/package.json)4
-rw-r--r--server/tests/fixtures/peertube-plugin-test/main.js4
-rw-r--r--server/tests/plugins/filter-hooks.ts24
-rw-r--r--server/tests/plugins/translations.ts8
-rw-r--r--server/types/models/video/video-playlist.ts4
-rw-r--r--shared/extra-utils/index.ts2
-rw-r--r--shared/extra-utils/search/video-playlists.ts36
-rw-r--r--shared/models/plugins/client/client-hook.model.ts5
-rw-r--r--shared/models/plugins/server/server-hook.model.ts4
-rw-r--r--shared/models/search/index.ts3
-rw-r--r--shared/models/search/video-channels-search-query.model.ts2
-rw-r--r--shared/models/search/video-playlists-search-query.model.ts9
-rw-r--r--shared/models/server/job.model.ts1
-rw-r--r--shared/models/server/peertube-problem-document.model.ts2
-rw-r--r--shared/models/videos/playlist/video-playlist.model.ts4
-rw-r--r--support/doc/api/openapi.yaml41
79 files changed, 1646 insertions, 543 deletions
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.ts b/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.ts
index a8fdf6e29..86fe70710 100644
--- a/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.ts
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.ts
@@ -187,7 +187,7 @@ export class MyVideoPlaylistElementsComponent implements OnInit, OnDestroy {
187 // Reload playlist thumbnail if the first element changed 187 // Reload playlist thumbnail if the first element changed
188 const newFirst = this.findFirst() 188 const newFirst = this.findFirst()
189 if (oldFirst && newFirst && oldFirst.id !== newFirst.id) { 189 if (oldFirst && newFirst && oldFirst.id !== newFirst.id) {
190 this.playlist.refreshThumbnail() 190 this.loadPlaylistInfo()
191 } 191 }
192 } 192 }
193 193
diff --git a/client/src/app/+search/channel-lazy-load.resolver.ts b/client/src/app/+search/channel-lazy-load.resolver.ts
deleted file mode 100644
index d9f7ec901..000000000
--- a/client/src/app/+search/channel-lazy-load.resolver.ts
+++ /dev/null
@@ -1,35 +0,0 @@
1import { map } from 'rxjs/operators'
2import { Injectable } from '@angular/core'
3import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
4import { SearchService } from '@app/shared/shared-search'
5
6@Injectable()
7export class ChannelLazyLoadResolver implements Resolve<any> {
8 constructor (
9 private router: Router,
10 private searchService: SearchService
11 ) { }
12
13 resolve (route: ActivatedRouteSnapshot) {
14 const url = route.params.url
15
16 if (!url) {
17 console.error('Could not find url param.', { params: route.params })
18 return this.router.navigateByUrl('/404')
19 }
20
21 return this.searchService.searchVideoChannels({ search: url })
22 .pipe(
23 map(result => {
24 if (result.data.length !== 1) {
25 console.error('Cannot find result for this URL')
26 return this.router.navigateByUrl('/404')
27 }
28
29 const channel = result.data[0]
30
31 return this.router.navigateByUrl('/video-channels/' + channel.nameWithHost)
32 })
33 )
34 }
35}
diff --git a/client/src/app/+search/search-routing.module.ts b/client/src/app/+search/search-routing.module.ts
index 0d778af0d..5d00aae13 100644
--- a/client/src/app/+search/search-routing.module.ts
+++ b/client/src/app/+search/search-routing.module.ts
@@ -1,8 +1,7 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouterModule, Routes } from '@angular/router'
3import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
4import { SearchComponent } from './search.component' 3import { SearchComponent } from './search.component'
5import { VideoLazyLoadResolver } from './video-lazy-load.resolver' 4import { ChannelLazyLoadResolver, PlaylistLazyLoadResolver, VideoLazyLoadResolver } from './shared'
6 5
7const searchRoutes: Routes = [ 6const searchRoutes: Routes = [
8 { 7 {
@@ -27,6 +26,13 @@ const searchRoutes: Routes = [
27 resolve: { 26 resolve: {
28 data: ChannelLazyLoadResolver 27 data: ChannelLazyLoadResolver
29 } 28 }
29 },
30 {
31 path: 'lazy-load-playlist',
32 component: SearchComponent,
33 resolve: {
34 data: PlaylistLazyLoadResolver
35 }
30 } 36 }
31] 37]
32 38
diff --git a/client/src/app/+search/search.component.html b/client/src/app/+search/search.component.html
index 130be75fc..b28abca6a 100644
--- a/client/src/app/+search/search.component.html
+++ b/client/src/app/+search/search.component.html
@@ -59,10 +59,17 @@
59 <div *ngIf="isVideo(result)" class="entry video"> 59 <div *ngIf="isVideo(result)" class="entry video">
60 <my-video-miniature 60 <my-video-miniature
61 [video]="result" [user]="userMiniature" [displayAsRow]="true" [displayVideoActions]="!hideActions()" 61 [video]="result" [user]="userMiniature" [displayAsRow]="true" [displayVideoActions]="!hideActions()"
62 [displayOptions]="videoDisplayOptions" [videoLinkType]="getVideoLinkType()" 62 [displayOptions]="videoDisplayOptions" [videoLinkType]="getLinkType()"
63 (videoBlocked)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)" 63 (videoBlocked)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)"
64 ></my-video-miniature> 64 ></my-video-miniature>
65 </div> 65 </div>
66
67 <div *ngIf="isPlaylist(result)" class="entry video-playlist">
68 <my-video-playlist-miniature
69 [playlist]="result" [displayAsRow]="true" [displayChannel]="true"
70 [linkType]="getLinkType()"
71 ></my-video-playlist-miniature>
72 </div>
66 </ng-container> 73 </ng-container>
67 74
68</div> 75</div>
diff --git a/client/src/app/+search/search.component.ts b/client/src/app/+search/search.component.ts
index a4ab7a5b1..fdf9b7cc0 100644
--- a/client/src/app/+search/search.component.ts
+++ b/client/src/app/+search/search.component.ts
@@ -1,11 +1,13 @@
1import { forkJoin, of, Subscription } from 'rxjs' 1import { forkJoin, of, Subscription } from 'rxjs'
2import { LinkType } from 'src/types/link.type'
2import { Component, OnDestroy, OnInit } from '@angular/core' 3import { Component, OnDestroy, OnInit } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 4import { ActivatedRoute, Router } from '@angular/router'
4import { AuthService, ComponentPagination, HooksService, MetaService, Notifier, ServerService, User, UserService } from '@app/core' 5import { AuthService, HooksService, MetaService, Notifier, ServerService, User, UserService } from '@app/core'
5import { immutableAssign } from '@app/helpers' 6import { immutableAssign } from '@app/helpers'
6import { Video, VideoChannel } from '@app/shared/shared-main' 7import { Video, VideoChannel } from '@app/shared/shared-main'
7import { AdvancedSearch, SearchService } from '@app/shared/shared-search' 8import { AdvancedSearch, SearchService } from '@app/shared/shared-search'
8import { MiniatureDisplayOptions, VideoLinkType } from '@app/shared/shared-video-miniature' 9import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
10import { VideoPlaylist } from '@app/shared/shared-video-playlist'
9import { HTMLServerConfig, SearchTargetType } from '@shared/models' 11import { HTMLServerConfig, SearchTargetType } from '@shared/models'
10 12
11@Component({ 13@Component({
@@ -16,10 +18,9 @@ import { HTMLServerConfig, SearchTargetType } from '@shared/models'
16export class SearchComponent implements OnInit, OnDestroy { 18export class SearchComponent implements OnInit, OnDestroy {
17 results: (Video | VideoChannel)[] = [] 19 results: (Video | VideoChannel)[] = []
18 20
19 pagination: ComponentPagination = { 21 pagination = {
20 currentPage: 1, 22 currentPage: 1,
21 itemsPerPage: 10, // Only for videos, use another variable for channels 23 totalItems: null as number
22 totalItems: null
23 } 24 }
24 advancedSearch: AdvancedSearch = new AdvancedSearch() 25 advancedSearch: AdvancedSearch = new AdvancedSearch()
25 isSearchFilterCollapsed = true 26 isSearchFilterCollapsed = true
@@ -45,6 +46,11 @@ export class SearchComponent implements OnInit, OnDestroy {
45 private firstSearch = true 46 private firstSearch = true
46 47
47 private channelsPerPage = 2 48 private channelsPerPage = 2
49 private playlistsPerPage = 2
50 private videosPerPage = 10
51
52 private hasMoreResults = true
53 private isSearching = false
48 54
49 private lastSearchTarget: SearchTargetType 55 private lastSearchTarget: SearchTargetType
50 56
@@ -104,77 +110,62 @@ export class SearchComponent implements OnInit, OnDestroy {
104 if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe() 110 if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
105 } 111 }
106 112
107 isVideoChannel (d: VideoChannel | Video): d is VideoChannel { 113 isVideoChannel (d: VideoChannel | Video | VideoPlaylist): d is VideoChannel {
108 return d instanceof VideoChannel 114 return d instanceof VideoChannel
109 } 115 }
110 116
111 isVideo (v: VideoChannel | Video): v is Video { 117 isVideo (v: VideoChannel | Video | VideoPlaylist): v is Video {
112 return v instanceof Video 118 return v instanceof Video
113 } 119 }
114 120
121 isPlaylist (v: VideoChannel | Video | VideoPlaylist): v is VideoPlaylist {
122 return v instanceof VideoPlaylist
123 }
124
115 isUserLoggedIn () { 125 isUserLoggedIn () {
116 return this.authService.isLoggedIn() 126 return this.authService.isLoggedIn()
117 } 127 }
118 128
119 getVideoLinkType (): VideoLinkType { 129 search () {
120 if (this.advancedSearch.searchTarget === 'search-index') { 130 this.isSearching = true
121 const remoteUriConfig = this.serverConfig.search.remoteUri
122 131
123 // Redirect on the external instance if not allowed to fetch remote data 132 forkJoin([
124 if ((!this.isUserLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users) { 133 this.getVideoChannelObs(),
125 return 'external' 134 this.getVideoPlaylistObs(),
135 this.getVideosObs()
136 ]).subscribe(results => {
137 for (const result of results) {
138 this.results = this.results.concat(result.data)
126 } 139 }
127 140
128 return 'lazy-load' 141 this.pagination.totalItems = results.reduce((p, r) => p += r.total, 0)
129 } 142 this.lastSearchTarget = this.advancedSearch.searchTarget
130 143
131 return 'internal' 144 this.hasMoreResults = this.results.length < this.pagination.totalItems
132 } 145 },
133 146
134 search () { 147 err => {
135 forkJoin([ 148 if (this.advancedSearch.searchTarget !== 'search-index') {
136 this.getVideosObs(), 149 this.notifier.error(err.message)
137 this.getVideoChannelObs() 150 return
138 ]).subscribe( 151 }
139 ([videosResult, videoChannelsResult]) => {
140 this.results = this.results
141 .concat(videoChannelsResult.data)
142 .concat(videosResult.data)
143
144 this.pagination.totalItems = videosResult.total + videoChannelsResult.total
145 this.lastSearchTarget = this.advancedSearch.searchTarget
146
147 // Focus on channels if there are no enough videos
148 if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
149 this.resetPagination()
150 this.firstSearch = false
151
152 this.channelsPerPage = 10
153 this.search()
154 }
155
156 this.firstSearch = false
157 },
158 152
159 err => { 153 this.notifier.error(
160 if (this.advancedSearch.searchTarget !== 'search-index') { 154 $localize`Search index is unavailable. Retrying with instance results instead.`,
161 this.notifier.error(err.message) 155 $localize`Search error`
162 return 156 )
163 } 157 this.advancedSearch.searchTarget = 'local'
158 this.search()
159 },
164 160
165 this.notifier.error( 161 () => {
166 $localize`Search index is unavailable. Retrying with instance results instead.`, 162 this.isSearching = false
167 $localize`Search error` 163 })
168 )
169 this.advancedSearch.searchTarget = 'local'
170 this.search()
171 }
172 )
173 } 164 }
174 165
175 onNearOfBottom () { 166 onNearOfBottom () {
176 // Last page 167 // Last page
177 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return 168 if (!this.hasMoreResults || this.isSearching) return
178 169
179 this.pagination.currentPage += 1 170 this.pagination.currentPage += 1
180 this.search() 171 this.search()
@@ -190,18 +181,33 @@ export class SearchComponent implements OnInit, OnDestroy {
190 return this.advancedSearch.size() 181 return this.advancedSearch.size()
191 } 182 }
192 183
193 // Add VideoChannel for typings, but the template already checks "video" argument is a video 184 // Add VideoChannel/VideoPlaylist for typings, but the template already checks "video" argument is a video
194 removeVideoFromArray (video: Video | VideoChannel) { 185 removeVideoFromArray (video: Video | VideoChannel | VideoPlaylist) {
195 this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id) 186 this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
196 } 187 }
197 188
189 getLinkType (): LinkType {
190 if (this.advancedSearch.searchTarget === 'search-index') {
191 const remoteUriConfig = this.serverConfig.search.remoteUri
192
193 // Redirect on the external instance if not allowed to fetch remote data
194 if ((!this.isUserLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users) {
195 return 'external'
196 }
197
198 return 'lazy-load'
199 }
200
201 return 'internal'
202 }
203
198 isExternalChannelUrl () { 204 isExternalChannelUrl () {
199 return this.getVideoLinkType() === 'external' 205 return this.getLinkType() === 'external'
200 } 206 }
201 207
202 getExternalChannelUrl (channel: VideoChannel) { 208 getExternalChannelUrl (channel: VideoChannel) {
203 // Same algorithm than videos 209 // Same algorithm than videos
204 if (this.getVideoLinkType() === 'external') { 210 if (this.getLinkType() === 'external') {
205 return channel.url 211 return channel.url
206 } 212 }
207 213
@@ -210,7 +216,7 @@ export class SearchComponent implements OnInit, OnDestroy {
210 } 216 }
211 217
212 getInternalChannelUrl (channel: VideoChannel) { 218 getInternalChannelUrl (channel: VideoChannel) {
213 const linkType = this.getVideoLinkType() 219 const linkType = this.getLinkType()
214 220
215 if (linkType === 'internal') { 221 if (linkType === 'internal') {
216 return [ '/c', channel.nameWithHost ] 222 return [ '/c', channel.nameWithHost ]
@@ -256,7 +262,7 @@ export class SearchComponent implements OnInit, OnDestroy {
256 private getVideosObs () { 262 private getVideosObs () {
257 const params = { 263 const params = {
258 search: this.currentSearch, 264 search: this.currentSearch,
259 componentPagination: this.pagination, 265 componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.videosPerPage }),
260 advancedSearch: this.advancedSearch 266 advancedSearch: this.advancedSearch
261 } 267 }
262 268
@@ -287,6 +293,24 @@ export class SearchComponent implements OnInit, OnDestroy {
287 ) 293 )
288 } 294 }
289 295
296 private getVideoPlaylistObs () {
297 if (!this.currentSearch) return of({ data: [], total: 0 })
298
299 const params = {
300 search: this.currentSearch,
301 componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.playlistsPerPage }),
302 searchTarget: this.advancedSearch.searchTarget
303 }
304
305 return this.hooks.wrapObsFun(
306 this.searchService.searchVideoPlaylists.bind(this.searchService),
307 params,
308 'search',
309 'filter:api.search.video-playlists.list.params',
310 'filter:api.search.video-playlists.list.result'
311 )
312 }
313
290 private getDefaultSearchTarget (): SearchTargetType { 314 private getDefaultSearchTarget (): SearchTargetType {
291 const searchIndexConfig = this.serverConfig.search.searchIndex 315 const searchIndexConfig = this.serverConfig.search.searchIndex
292 316
diff --git a/client/src/app/+search/search.module.ts b/client/src/app/+search/search.module.ts
index 390833abc..26f1523fd 100644
--- a/client/src/app/+search/search.module.ts
+++ b/client/src/app/+search/search.module.ts
@@ -5,12 +5,12 @@ import { SharedMainModule } from '@app/shared/shared-main'
5import { SharedSearchModule } from '@app/shared/shared-search' 5import { SharedSearchModule } from '@app/shared/shared-search'
6import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' 6import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
7import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' 7import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
8import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
8import { SearchService } from '../shared/shared-search/search.service' 9import { SearchService } from '../shared/shared-search/search.service'
9import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
10import { SearchFiltersComponent } from './search-filters.component' 10import { SearchFiltersComponent } from './search-filters.component'
11import { SearchRoutingModule } from './search-routing.module' 11import { SearchRoutingModule } from './search-routing.module'
12import { SearchComponent } from './search.component' 12import { SearchComponent } from './search.component'
13import { VideoLazyLoadResolver } from './video-lazy-load.resolver' 13import { ChannelLazyLoadResolver, PlaylistLazyLoadResolver, VideoLazyLoadResolver } from './shared'
14 14
15@NgModule({ 15@NgModule({
16 imports: [ 16 imports: [
@@ -21,7 +21,8 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
21 SharedFormModule, 21 SharedFormModule,
22 SharedActorImageModule, 22 SharedActorImageModule,
23 SharedUserSubscriptionModule, 23 SharedUserSubscriptionModule,
24 SharedVideoMiniatureModule 24 SharedVideoMiniatureModule,
25 SharedVideoPlaylistModule
25 ], 26 ],
26 27
27 declarations: [ 28 declarations: [
@@ -36,7 +37,8 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
36 providers: [ 37 providers: [
37 SearchService, 38 SearchService,
38 VideoLazyLoadResolver, 39 VideoLazyLoadResolver,
39 ChannelLazyLoadResolver 40 ChannelLazyLoadResolver,
41 PlaylistLazyLoadResolver
40 ] 42 ]
41}) 43})
42export class SearchModule { } 44export class SearchModule { }
diff --git a/client/src/app/+search/video-lazy-load.resolver.ts b/client/src/app/+search/shared/abstract-lazy-load.resolver.ts
index e43e0089b..31240f451 100644
--- a/client/src/app/+search/video-lazy-load.resolver.ts
+++ b/client/src/app/+search/shared/abstract-lazy-load.resolver.ts
@@ -1,14 +1,10 @@
1import { Observable } from 'rxjs'
1import { map } from 'rxjs/operators' 2import { map } from 'rxjs/operators'
2import { Injectable } from '@angular/core'
3import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router' 3import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
4import { SearchService } from '@app/shared/shared-search' 4import { ResultList } from '@shared/models/result-list.model'
5 5
6@Injectable() 6export abstract class AbstractLazyLoadResolver <T> implements Resolve<any> {
7export class VideoLazyLoadResolver implements Resolve<any> { 7 protected router: Router
8 constructor (
9 private router: Router,
10 private searchService: SearchService
11 ) { }
12 8
13 resolve (route: ActivatedRouteSnapshot) { 9 resolve (route: ActivatedRouteSnapshot) {
14 const url = route.params.url 10 const url = route.params.url
@@ -18,7 +14,7 @@ export class VideoLazyLoadResolver implements Resolve<any> {
18 return this.router.navigateByUrl('/404') 14 return this.router.navigateByUrl('/404')
19 } 15 }
20 16
21 return this.searchService.searchVideos({ search: url }) 17 return this.finder(url)
22 .pipe( 18 .pipe(
23 map(result => { 19 map(result => {
24 if (result.data.length !== 1) { 20 if (result.data.length !== 1) {
@@ -26,10 +22,13 @@ export class VideoLazyLoadResolver implements Resolve<any> {
26 return this.router.navigateByUrl('/404') 22 return this.router.navigateByUrl('/404')
27 } 23 }
28 24
29 const video = result.data[0] 25 const redirectUrl = this.buildUrl(result.data[0])
30 26
31 return this.router.navigateByUrl('/w/' + video.uuid) 27 return this.router.navigateByUrl(redirectUrl)
32 }) 28 })
33 ) 29 )
34 } 30 }
31
32 protected abstract finder (url: string): Observable<ResultList<T>>
33 protected abstract buildUrl (e: T): string
35} 34}
diff --git a/client/src/app/+search/shared/channel-lazy-load.resolver.ts b/client/src/app/+search/shared/channel-lazy-load.resolver.ts
new file mode 100644
index 000000000..5e010f795
--- /dev/null
+++ b/client/src/app/+search/shared/channel-lazy-load.resolver.ts
@@ -0,0 +1,24 @@
1import { Injectable } from '@angular/core'
2import { Router } from '@angular/router'
3import { VideoChannel } from '@app/shared/shared-main'
4import { SearchService } from '@app/shared/shared-search'
5import { AbstractLazyLoadResolver } from './abstract-lazy-load.resolver'
6
7@Injectable()
8export class ChannelLazyLoadResolver extends AbstractLazyLoadResolver<VideoChannel> {
9
10 constructor (
11 protected router: Router,
12 private searchService: SearchService
13 ) {
14 super()
15 }
16
17 protected finder (url: string) {
18 return this.searchService.searchVideoChannels({ search: url })
19 }
20
21 protected buildUrl (channel: VideoChannel) {
22 return '/video-channels/' + channel.nameWithHost
23 }
24}
diff --git a/client/src/app/+search/shared/index.ts b/client/src/app/+search/shared/index.ts
new file mode 100644
index 000000000..1e68989ae
--- /dev/null
+++ b/client/src/app/+search/shared/index.ts
@@ -0,0 +1,4 @@
1export * from './abstract-lazy-load.resolver'
2export * from './channel-lazy-load.resolver'
3export * from './playlist-lazy-load.resolver'
4export * from './video-lazy-load.resolver'
diff --git a/client/src/app/+search/shared/playlist-lazy-load.resolver.ts b/client/src/app/+search/shared/playlist-lazy-load.resolver.ts
new file mode 100644
index 000000000..14ae798df
--- /dev/null
+++ b/client/src/app/+search/shared/playlist-lazy-load.resolver.ts
@@ -0,0 +1,24 @@
1import { Injectable } from '@angular/core'
2import { Router } from '@angular/router'
3import { SearchService } from '@app/shared/shared-search'
4import { VideoPlaylist } from '@app/shared/shared-video-playlist'
5import { AbstractLazyLoadResolver } from './abstract-lazy-load.resolver'
6
7@Injectable()
8export class PlaylistLazyLoadResolver extends AbstractLazyLoadResolver<VideoPlaylist> {
9
10 constructor (
11 protected router: Router,
12 private searchService: SearchService
13 ) {
14 super()
15 }
16
17 protected finder (url: string) {
18 return this.searchService.searchVideoPlaylists({ search: url })
19 }
20
21 protected buildUrl (playlist: VideoPlaylist) {
22 return '/w/p/' + playlist.uuid
23 }
24}
diff --git a/client/src/app/+search/shared/video-lazy-load.resolver.ts b/client/src/app/+search/shared/video-lazy-load.resolver.ts
new file mode 100644
index 000000000..12b5b2e82
--- /dev/null
+++ b/client/src/app/+search/shared/video-lazy-load.resolver.ts
@@ -0,0 +1,24 @@
1import { Injectable } from '@angular/core'
2import { Router } from '@angular/router'
3import { Video } from '@app/shared/shared-main'
4import { SearchService } from '@app/shared/shared-search'
5import { AbstractLazyLoadResolver } from './abstract-lazy-load.resolver'
6
7@Injectable()
8export class VideoLazyLoadResolver extends AbstractLazyLoadResolver<Video> {
9
10 constructor (
11 protected router: Router,
12 private searchService: SearchService
13 ) {
14 super()
15 }
16
17 protected finder (url: string) {
18 return this.searchService.searchVideos({ search: url })
19 }
20
21 protected buildUrl (video: Video) {
22 return '/w/' + video.uuid
23 }
24}
diff --git a/client/src/app/header/search-typeahead.component.html b/client/src/app/header/search-typeahead.component.html
index f84086b4a..0ced249a7 100644
--- a/client/src/app/header/search-typeahead.component.html
+++ b/client/src/app/header/search-typeahead.component.html
@@ -1,6 +1,6 @@
1<div class="d-inline-flex position-relative" id="typeahead-container"> 1<div class="d-inline-flex position-relative" id="typeahead-container">
2 <input 2 <input
3 type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…" 3 type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, playlists, channels…"
4 [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keydown)="handleKey($event)" (keydown.enter)="doSearch()" 4 [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keydown)="handleKey($event)" (keydown.enter)="doSearch()"
5 aria-label="Search" autocomplete="off" 5 aria-label="Search" autocomplete="off"
6 > 6 >
diff --git a/client/src/app/shared/shared-main/angular/index.ts b/client/src/app/shared/shared-main/angular/index.ts
index 8ea47bb33..069b7f654 100644
--- a/client/src/app/shared/shared-main/angular/index.ts
+++ b/client/src/app/shared/shared-main/angular/index.ts
@@ -3,5 +3,6 @@ export * from './bytes.pipe'
3export * from './duration-formatter.pipe' 3export * from './duration-formatter.pipe'
4export * from './from-now.pipe' 4export * from './from-now.pipe'
5export * from './infinite-scroller.directive' 5export * from './infinite-scroller.directive'
6export * from './link.component'
6export * from './number-formatter.pipe' 7export * from './number-formatter.pipe'
7export * from './peertube-template.directive' 8export * from './peertube-template.directive'
diff --git a/client/src/app/shared/shared-main/angular/link.component.html b/client/src/app/shared/shared-main/angular/link.component.html
new file mode 100644
index 000000000..e61a1e085
--- /dev/null
+++ b/client/src/app/shared/shared-main/angular/link.component.html
@@ -0,0 +1,11 @@
1<ng-template #content>
2 <ng-content></ng-content>
3</ng-template>
4
5<a *ngIf="!href" [routerLink]="internalLink" [attr.title]="title" [tabindex]="tabindex">
6 <ng-template *ngTemplateOutlet="content"></ng-template>
7</a>
8
9<a *ngIf="href" [href]="href" [target]="target" [attr.title]="title" [tabindex]="tabindex">
10 <ng-template *ngTemplateOutlet="content"></ng-template>
11</a>
diff --git a/client/src/app/shared/shared-main/angular/link.component.scss b/client/src/app/shared/shared-main/angular/link.component.scss
new file mode 100644
index 000000000..bb86d5488
--- /dev/null
+++ b/client/src/app/shared/shared-main/angular/link.component.scss
@@ -0,0 +1,7 @@
1a {
2 color: inherit;
3 text-decoration: inherit;
4 position: inherit;
5 width: inherit;
6 height: inherit;
7}
diff --git a/client/src/app/shared/shared-main/angular/link.component.ts b/client/src/app/shared/shared-main/angular/link.component.ts
new file mode 100644
index 000000000..76d1201b9
--- /dev/null
+++ b/client/src/app/shared/shared-main/angular/link.component.ts
@@ -0,0 +1,17 @@
1import { Component, Input, ViewEncapsulation } from '@angular/core'
2
3@Component({
4 selector: 'my-link',
5 styleUrls: [ './link.component.scss' ],
6 templateUrl: './link.component.html'
7})
8export class LinkComponent {
9 @Input() internalLink?: any[]
10
11 @Input() href?: string
12 @Input() target?: string
13
14 @Input() title?: string
15
16 @Input() tabindex: string | number
17}
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts
index c8dd01429..e5dfc59b2 100644
--- a/client/src/app/shared/shared-main/shared-main.module.ts
+++ b/client/src/app/shared/shared-main/shared-main.module.ts
@@ -4,7 +4,7 @@ import { CommonModule, DatePipe } from '@angular/common'
4import { HttpClientModule } from '@angular/common/http' 4import { HttpClientModule } from '@angular/common/http'
5import { NgModule } from '@angular/core' 5import { NgModule } from '@angular/core'
6import { FormsModule, ReactiveFormsModule } from '@angular/forms' 6import { FormsModule, ReactiveFormsModule } from '@angular/forms'
7import { ActivatedRouteSnapshot, RouterModule } from '@angular/router' 7import { RouterModule } from '@angular/router'
8import { 8import {
9 NgbButtonsModule, 9 NgbButtonsModule,
10 NgbCollapseModule, 10 NgbCollapseModule,
@@ -24,6 +24,7 @@ import {
24 DurationFormatterPipe, 24 DurationFormatterPipe,
25 FromNowPipe, 25 FromNowPipe,
26 InfiniteScrollerDirective, 26 InfiniteScrollerDirective,
27 LinkComponent,
27 NumberFormatterPipe, 28 NumberFormatterPipe,
28 PeerTubeTemplateDirective 29 PeerTubeTemplateDirective
29} from './angular' 30} from './angular'
@@ -35,11 +36,11 @@ import { FeedComponent } from './feeds'
35import { LoaderComponent, SmallLoaderComponent } from './loaders' 36import { LoaderComponent, SmallLoaderComponent } from './loaders'
36import { HelpComponent, ListOverflowComponent, SimpleSearchInputComponent, TopMenuDropdownComponent } from './misc' 37import { HelpComponent, ListOverflowComponent, SimpleSearchInputComponent, TopMenuDropdownComponent } from './misc'
37import { PluginPlaceholderComponent } from './plugins' 38import { PluginPlaceholderComponent } from './plugins'
39import { ActorRedirectGuard } from './router'
38import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' 40import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
39import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video' 41import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
40import { VideoCaptionService } from './video-caption' 42import { VideoCaptionService } from './video-caption'
41import { VideoChannelService } from './video-channel' 43import { VideoChannelService } from './video-channel'
42import { ActorRedirectGuard } from './router'
43 44
44@NgModule({ 45@NgModule({
45 imports: [ 46 imports: [
@@ -76,6 +77,7 @@ import { ActorRedirectGuard } from './router'
76 77
77 InfiniteScrollerDirective, 78 InfiniteScrollerDirective,
78 PeerTubeTemplateDirective, 79 PeerTubeTemplateDirective,
80 LinkComponent,
79 81
80 ActionDropdownComponent, 82 ActionDropdownComponent,
81 ButtonComponent, 83 ButtonComponent,
@@ -130,6 +132,7 @@ import { ActorRedirectGuard } from './router'
130 132
131 InfiniteScrollerDirective, 133 InfiniteScrollerDirective,
132 PeerTubeTemplateDirective, 134 PeerTubeTemplateDirective,
135 LinkComponent,
133 136
134 ActionDropdownComponent, 137 ActionDropdownComponent,
135 ButtonComponent, 138 ButtonComponent,
diff --git a/client/src/app/shared/shared-search/search.service.ts b/client/src/app/shared/shared-search/search.service.ts
index 15c4a7012..ad258f5e5 100644
--- a/client/src/app/shared/shared-search/search.service.ts
+++ b/client/src/app/shared/shared-search/search.service.ts
@@ -3,10 +3,17 @@ import { catchError, map, switchMap } from 'rxjs/operators'
3import { HttpClient, HttpParams } from '@angular/common/http' 3import { HttpClient, HttpParams } from '@angular/common/http'
4import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
5import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core' 5import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core'
6import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
7import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' 6import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
8import { ResultList, SearchTargetType, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '@shared/models' 7import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
8import {
9 ResultList,
10 SearchTargetType,
11 Video as VideoServerModel,
12 VideoChannel as VideoChannelServerModel,
13 VideoPlaylist as VideoPlaylistServerModel
14} from '@shared/models'
9import { environment } from '../../../environments/environment' 15import { environment } from '../../../environments/environment'
16import { VideoPlaylist, VideoPlaylistService } from '../shared-video-playlist'
10import { AdvancedSearch } from './advanced-search.model' 17import { AdvancedSearch } from './advanced-search.model'
11 18
12@Injectable() 19@Injectable()
@@ -17,7 +24,8 @@ export class SearchService {
17 private authHttp: HttpClient, 24 private authHttp: HttpClient,
18 private restExtractor: RestExtractor, 25 private restExtractor: RestExtractor,
19 private restService: RestService, 26 private restService: RestService,
20 private videoService: VideoService 27 private videoService: VideoService,
28 private playlistService: VideoPlaylistService
21 ) { 29 ) {
22 // Add ability to override search endpoint if the user updated this local storage key 30 // Add ability to override search endpoint if the user updated this local storage key
23 const searchUrl = peertubeLocalStorage.getItem('search-url') 31 const searchUrl = peertubeLocalStorage.getItem('search-url')
@@ -85,4 +93,34 @@ export class SearchService {
85 catchError(err => this.restExtractor.handleError(err)) 93 catchError(err => this.restExtractor.handleError(err))
86 ) 94 )
87 } 95 }
96
97 searchVideoPlaylists (parameters: {
98 search: string,
99 searchTarget?: SearchTargetType,
100 componentPagination?: ComponentPaginationLight
101 }): Observable<ResultList<VideoPlaylist>> {
102 const { search, componentPagination, searchTarget } = parameters
103
104 const url = SearchService.BASE_SEARCH_URL + 'video-playlists'
105
106 let pagination: RestPagination
107 if (componentPagination) {
108 pagination = this.restService.componentPaginationToRestPagination(componentPagination)
109 }
110
111 let params = new HttpParams()
112 params = this.restService.addRestGetParams(params, pagination)
113 params = params.append('search', search)
114
115 if (searchTarget) {
116 params = params.append('searchTarget', searchTarget as string)
117 }
118
119 return this.authHttp
120 .get<ResultList<VideoPlaylistServerModel>>(url, { params })
121 .pipe(
122 switchMap(res => this.playlistService.extractPlaylists(res)),
123 catchError(err => this.restExtractor.handleError(err))
124 )
125 }
88} 126}
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
index 6c34123ed..7765d5be7 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
@@ -21,13 +21,12 @@
21 ></my-actor-avatar> 21 ></my-actor-avatar>
22 22
23 <div class="w-100 d-flex flex-column"> 23 <div class="w-100 d-flex flex-column">
24 <a *ngIf="!videoHref" tabindex="-1" class="video-miniature-name" 24 <my-link
25 [routerLink]="videoRouterLink" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" 25 [internalLink]="videoRouterLink" [href]="videoHref" [target]="videoTarget"
26 >{{ video.name }}</a> 26 [title]="video.name"class="video-miniature-name" [ngClass]="{ 'blur-filter': isVideoBlur }" tabindex="-1"
27 27 >
28 <a *ngIf="videoHref" tabindex="-1" class="video-miniature-name" 28 {{ video.name }}
29 [href]="videoHref" [target]="videoTarget" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" 29 </my-link>
30 >{{ video.name }}</a>
31 30
32 <span class="video-miniature-created-at-views"> 31 <span class="video-miniature-created-at-views">
33 <my-date-toggle *ngIf="displayOptions.date" [date]="video.publishedAt"></my-date-toggle> 32 <my-date-toggle *ngIf="displayOptions.date" [date]="video.publishedAt"></my-date-toggle>
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
index fc066846a..fe161c977 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -12,6 +12,7 @@ import {
12} from '@angular/core' 12} from '@angular/core'
13import { AuthService, ScreenService, ServerService, User } from '@app/core' 13import { AuthService, ScreenService, ServerService, User } from '@app/core'
14import { HTMLServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '@shared/models' 14import { HTMLServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '@shared/models'
15import { LinkType } from '../../../types/link.type'
15import { ActorAvatarSize } from '../shared-actor-image/actor-avatar.component' 16import { ActorAvatarSize } from '../shared-actor-image/actor-avatar.component'
16import { Video } from '../shared-main' 17import { Video } from '../shared-main'
17import { VideoPlaylistService } from '../shared-video-playlist' 18import { VideoPlaylistService } from '../shared-video-playlist'
@@ -28,8 +29,6 @@ export type MiniatureDisplayOptions = {
28 blacklistInfo?: boolean 29 blacklistInfo?: boolean
29 nsfw?: boolean 30 nsfw?: boolean
30} 31}
31export type VideoLinkType = 'internal' | 'lazy-load' | 'external'
32
33@Component({ 32@Component({
34 selector: 'my-video-miniature', 33 selector: 'my-video-miniature',
35 styleUrls: [ './video-miniature.component.scss' ], 34 styleUrls: [ './video-miniature.component.scss' ],
@@ -56,7 +55,7 @@ export class VideoMiniatureComponent implements OnInit {
56 55
57 @Input() displayAsRow = false 56 @Input() displayAsRow = false
58 57
59 @Input() videoLinkType: VideoLinkType = 'internal' 58 @Input() videoLinkType: LinkType = 'internal'
60 59
61 @Output() videoBlocked = new EventEmitter() 60 @Output() videoBlocked = new EventEmitter()
62 @Output() videoUnblocked = new EventEmitter() 61 @Output() videoUnblocked = new EventEmitter()
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html
index 81c36e6fe..95f11f030 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html
@@ -1,7 +1,7 @@
1<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage, 'display-as-row': displayAsRow }"> 1<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage, 'display-as-row': displayAsRow }">
2 <a 2 <my-link
3 [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description" 3 [internalLink]="routerLink" [href]="playlistHref" [target]="playlistTarget"
4 class="miniature-thumbnail" 4 [title]="playlist.description" class="miniature-thumbnail"
5 > 5 >
6 <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" /> 6 <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
7 7
@@ -12,12 +12,15 @@
12 <div class="play-overlay"> 12 <div class="play-overlay">
13 <div class="icon"></div> 13 <div class="icon"></div>
14 </div> 14 </div>
15 </a> 15 </my-link>
16 16
17 <div class="miniature-info"> 17 <div class="miniature-info">
18 <a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description"> 18 <my-link
19 [internalLink]="routerLink" [href]="playlistHref" [target]="playlistTarget"
20 [title]="playlist.description" class="miniature-name" tabindex="-1"
21 >
19 {{ playlist.displayName }} 22 {{ playlist.displayName }}
20 </a> 23 </my-link>
21 24
22 <a i18n [routerLink]="[ '/c', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy"> 25 <a i18n [routerLink]="[ '/c', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy">
23 {{ playlist.videoChannelBy }} 26 {{ playlist.videoChannelBy }}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss
index 95bf469ac..cf7513984 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss
@@ -75,7 +75,10 @@
75} 75}
76 76
77.miniature:not(.display-as-row) { 77.miniature:not(.display-as-row) {
78
78 .miniature-thumbnail { 79 .miniature-thumbnail {
80 @include block-ratio($selector: '::ng-deep a');
81
79 margin-top: 10px; 82 margin-top: 10px;
80 margin-bottom: 5px; 83 margin-bottom: 5px;
81 } 84 }
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts
index 9bbec6038..8de5092a9 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts
@@ -1,4 +1,5 @@
1import { Component, Input } from '@angular/core' 1import { LinkType } from 'src/types/link.type'
2import { Component, Input, OnInit } from '@angular/core'
2import { VideoPlaylist } from './video-playlist.model' 3import { VideoPlaylist } from './video-playlist.model'
3 4
4@Component({ 5@Component({
@@ -6,18 +7,52 @@ import { VideoPlaylist } from './video-playlist.model'
6 styleUrls: [ './video-playlist-miniature.component.scss' ], 7 styleUrls: [ './video-playlist-miniature.component.scss' ],
7 templateUrl: './video-playlist-miniature.component.html' 8 templateUrl: './video-playlist-miniature.component.html'
8}) 9})
9export class VideoPlaylistMiniatureComponent { 10export class VideoPlaylistMiniatureComponent implements OnInit {
10 @Input() playlist: VideoPlaylist 11 @Input() playlist: VideoPlaylist
12
11 @Input() toManage = false 13 @Input() toManage = false
14
12 @Input() displayChannel = false 15 @Input() displayChannel = false
13 @Input() displayDescription = false 16 @Input() displayDescription = false
14 @Input() displayPrivacy = false 17 @Input() displayPrivacy = false
15 @Input() displayAsRow = false 18 @Input() displayAsRow = false
16 19
17 getPlaylistUrl () { 20 @Input() linkType: LinkType = 'internal'
18 if (this.toManage) return [ '/my-library/video-playlists', this.playlist.uuid ] 21
19 if (this.playlist.videosLength === 0) return null 22 routerLink: any
23 playlistHref: string
24 playlistTarget: string
25
26 ngOnInit () {
27 this.buildPlaylistUrl()
28 }
29
30 buildPlaylistUrl () {
31 if (this.toManage) {
32 this.routerLink = [ '/my-library/video-playlists', this.playlist.uuid ]
33 return
34 }
35
36 if (this.playlist.videosLength === 0) {
37 this.routerLink = null
38 return
39 }
40
41 if (this.linkType === 'internal' || !this.playlist.url) {
42 this.routerLink = [ '/w/p', this.playlist.uuid ]
43 return
44 }
45
46 if (this.linkType === 'external') {
47 this.routerLink = null
48 this.playlistHref = this.playlist.url
49 this.playlistTarget = '_blank'
50 return
51 }
52
53 // Lazy load
54 this.routerLink = [ '/search/lazy-load-playlist', { url: this.playlist.url } ]
20 55
21 return [ '/w/p', this.playlist.uuid ] 56 return
22 } 57 }
23} 58}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.model.ts b/client/src/app/shared/shared-video-playlist/video-playlist.model.ts
index 5b6ba9dbf..d67f372f4 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist.model.ts
+++ b/client/src/app/shared/shared-video-playlist/video-playlist.model.ts
@@ -1,5 +1,5 @@
1import { getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers' 1import { getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers'
2import { Account, Actor, VideoChannel } from '@app/shared/shared-main' 2import { Actor } from '@app/shared/shared-main'
3import { peertubeTranslate } from '@shared/core-utils/i18n' 3import { peertubeTranslate } from '@shared/core-utils/i18n'
4import { 4import {
5 AccountSummary, 5 AccountSummary,
@@ -15,12 +15,12 @@ export class VideoPlaylist implements ServerVideoPlaylist {
15 uuid: string 15 uuid: string
16 isLocal: boolean 16 isLocal: boolean
17 17
18 url: string
19
18 displayName: string 20 displayName: string
19 description: string 21 description: string
20 privacy: VideoConstant<VideoPlaylistPrivacy> 22 privacy: VideoConstant<VideoPlaylistPrivacy>
21 23
22 thumbnailPath: string
23
24 videosLength: number 24 videosLength: number
25 25
26 type: VideoConstant<VideoPlaylistType> 26 type: VideoConstant<VideoPlaylistType>
@@ -31,6 +31,7 @@ export class VideoPlaylist implements ServerVideoPlaylist {
31 ownerAccount: AccountSummary 31 ownerAccount: AccountSummary
32 videoChannel?: VideoChannelSummary 32 videoChannel?: VideoChannelSummary
33 33
34 thumbnailPath: string
34 thumbnailUrl: string 35 thumbnailUrl: string
35 36
36 embedPath: string 37 embedPath: string
@@ -40,14 +41,12 @@ export class VideoPlaylist implements ServerVideoPlaylist {
40 41
41 videoChannelBy?: string 42 videoChannelBy?: string
42 43
43 private thumbnailVersion: number
44 private originThumbnailUrl: string
45
46 constructor (hash: ServerVideoPlaylist, translations: {}) { 44 constructor (hash: ServerVideoPlaylist, translations: {}) {
47 const absoluteAPIUrl = getAbsoluteAPIUrl() 45 const absoluteAPIUrl = getAbsoluteAPIUrl()
48 46
49 this.id = hash.id 47 this.id = hash.id
50 this.uuid = hash.uuid 48 this.uuid = hash.uuid
49 this.url = hash.url
51 this.isLocal = hash.isLocal 50 this.isLocal = hash.isLocal
52 51
53 this.displayName = hash.displayName 52 this.displayName = hash.displayName
@@ -57,15 +56,12 @@ export class VideoPlaylist implements ServerVideoPlaylist {
57 56
58 this.thumbnailPath = hash.thumbnailPath 57 this.thumbnailPath = hash.thumbnailPath
59 58
60 if (this.thumbnailPath) { 59 this.thumbnailUrl = this.thumbnailPath
61 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath 60 ? hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
62 this.originThumbnailUrl = this.thumbnailUrl 61 : absoluteAPIUrl + '/client/assets/images/default-playlist.jpg'
63 } else {
64 this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg'
65 }
66 62
67 this.embedPath = hash.embedPath 63 this.embedPath = hash.embedPath
68 this.embedUrl = getAbsoluteEmbedUrl() + hash.embedPath 64 this.embedUrl = hash.embedUrl || (getAbsoluteEmbedUrl() + hash.embedPath)
69 65
70 this.videosLength = hash.videosLength 66 this.videosLength = hash.videosLength
71 67
@@ -88,13 +84,4 @@ export class VideoPlaylist implements ServerVideoPlaylist {
88 this.displayName = peertubeTranslate(this.displayName, translations) 84 this.displayName = peertubeTranslate(this.displayName, translations)
89 } 85 }
90 } 86 }
91
92 refreshThumbnail () {
93 if (!this.originThumbnailUrl) return
94
95 if (!this.thumbnailVersion) this.thumbnailVersion = 0
96 this.thumbnailVersion++
97
98 this.thumbnailUrl = this.originThumbnailUrl + '?v' + this.thumbnailVersion
99 }
100} 87}
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index a835381d6..62e1ca163 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -880,6 +880,7 @@
880 width: 100%; 880 width: 100%;
881 height: 100%; 881 height: 100%;
882 top: 0; 882 top: 0;
883
883 @content; 884 @content;
884 } 885 }
885} 886}
diff --git a/client/src/types/link.type.ts b/client/src/types/link.type.ts
new file mode 100644
index 000000000..b611ae3fa
--- /dev/null
+++ b/client/src/types/link.type.ts
@@ -0,0 +1 @@
export type LinkType = 'internal' | 'lazy-load' | 'external'
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index bf8e3160b..d7de1b9bd 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -155,7 +155,8 @@ activityPubClientRouter.get('/redundancy/streaming-playlists/:streamingPlaylistT
155 asyncMiddleware(videoRedundancyController) 155 asyncMiddleware(videoRedundancyController)
156) 156)
157 157
158activityPubClientRouter.get('/video-playlists/:playlistId', 158activityPubClientRouter.get(
159 [ '/video-playlists/:playlistId', '/videos/watch/playlist/:playlistId', '/w/p/:playlistId' ],
159 executeIfActivityPub, 160 executeIfActivityPub,
160 asyncMiddleware(videoPlaylistsGetValidator('all')), 161 asyncMiddleware(videoPlaylistsGetValidator('all')),
161 asyncMiddleware(videoPlaylistController) 162 asyncMiddleware(videoPlaylistController)
diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts
deleted file mode 100644
index c975c5c3c..000000000
--- a/server/controllers/api/search.ts
+++ /dev/null
@@ -1,294 +0,0 @@
1import * as express from 'express'
2import { sanitizeUrl } from '@server/helpers/core-utils'
3import { doJSONRequest } from '@server/helpers/requests'
4import { CONFIG } from '@server/initializers/config'
5import { getOrCreateAPVideo } from '@server/lib/activitypub/videos'
6import { Hooks } from '@server/lib/plugins/hooks'
7import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
8import { getServerActor } from '@server/models/application/application'
9import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
10import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
11import { ResultList, Video, VideoChannel } from '@shared/models'
12import { SearchTargetQuery } from '@shared/models/search/search-target-query.model'
13import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search'
14import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
15import { logger } from '../../helpers/logger'
16import { getFormattedObjects } from '../../helpers/utils'
17import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../lib/activitypub/actors'
18import {
19 asyncMiddleware,
20 commonVideosFiltersValidator,
21 openapiOperationDoc,
22 optionalAuthenticate,
23 paginationValidator,
24 setDefaultPagination,
25 setDefaultSearchSort,
26 videoChannelsListSearchValidator,
27 videoChannelsSearchSortValidator,
28 videosSearchSortValidator,
29 videosSearchValidator
30} from '../../middlewares'
31import { VideoModel } from '../../models/video/video'
32import { VideoChannelModel } from '../../models/video/video-channel'
33import { MChannelAccountDefault, MVideoAccountLightBlacklistAllFiles } from '../../types/models'
34
35const searchRouter = express.Router()
36
37searchRouter.get('/videos',
38 openapiOperationDoc({ operationId: 'searchVideos' }),
39 paginationValidator,
40 setDefaultPagination,
41 videosSearchSortValidator,
42 setDefaultSearchSort,
43 optionalAuthenticate,
44 commonVideosFiltersValidator,
45 videosSearchValidator,
46 asyncMiddleware(searchVideos)
47)
48
49searchRouter.get('/video-channels',
50 openapiOperationDoc({ operationId: 'searchChannels' }),
51 paginationValidator,
52 setDefaultPagination,
53 videoChannelsSearchSortValidator,
54 setDefaultSearchSort,
55 optionalAuthenticate,
56 videoChannelsListSearchValidator,
57 asyncMiddleware(searchVideoChannels)
58)
59
60// ---------------------------------------------------------------------------
61
62export { searchRouter }
63
64// ---------------------------------------------------------------------------
65
66function searchVideoChannels (req: express.Request, res: express.Response) {
67 const query: VideoChannelsSearchQuery = req.query
68 const search = query.search
69
70 const isURISearch = search.startsWith('http://') || search.startsWith('https://')
71
72 const parts = search.split('@')
73
74 // Handle strings like @toto@example.com
75 if (parts.length === 3 && parts[0].length === 0) parts.shift()
76 const isWebfingerSearch = parts.length === 2 && parts.every(p => p && !p.includes(' '))
77
78 if (isURISearch || isWebfingerSearch) return searchVideoChannelURI(search, isWebfingerSearch, res)
79
80 // @username -> username to search in DB
81 if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '')
82
83 if (isSearchIndexSearch(query)) {
84 return searchVideoChannelsIndex(query, res)
85 }
86
87 return searchVideoChannelsDB(query, res)
88}
89
90async function searchVideoChannelsIndex (query: VideoChannelsSearchQuery, res: express.Response) {
91 const result = await buildMutedForSearchIndex(res)
92
93 const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-channels.index.list.params')
94
95 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels'
96
97 try {
98 logger.debug('Doing video channels search index request on %s.', url, { body })
99
100 const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoChannel>>(url, { method: 'POST', json: body })
101 const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-channels.index.list.result')
102
103 return res.json(jsonResult)
104 } catch (err) {
105 logger.warn('Cannot use search index to make video channels search.', { err })
106
107 return res.fail({
108 status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
109 message: 'Cannot use search index to make video channels search'
110 })
111 }
112}
113
114async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) {
115 const serverActor = await getServerActor()
116
117 const apiOptions = await Hooks.wrapObject({
118 actorId: serverActor.id,
119 search: query.search,
120 start: query.start,
121 count: query.count,
122 sort: query.sort
123 }, 'filter:api.search.video-channels.local.list.params')
124
125 const resultList = await Hooks.wrapPromiseFun(
126 VideoChannelModel.searchForApi,
127 apiOptions,
128 'filter:api.search.video-channels.local.list.result'
129 )
130
131 return res.json(getFormattedObjects(resultList.data, resultList.total))
132}
133
134async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean, res: express.Response) {
135 let videoChannel: MChannelAccountDefault
136 let uri = search
137
138 if (isWebfingerSearch) {
139 try {
140 uri = await loadActorUrlOrGetFromWebfinger(search)
141 } catch (err) {
142 logger.warn('Cannot load actor URL or get from webfinger.', { search, err })
143
144 return res.json({ total: 0, data: [] })
145 }
146 }
147
148 if (isUserAbleToSearchRemoteURI(res)) {
149 try {
150 const actor = await getOrCreateAPActor(uri, 'all', true, true)
151 videoChannel = actor.VideoChannel
152 } catch (err) {
153 logger.info('Cannot search remote video channel %s.', uri, { err })
154 }
155 } else {
156 videoChannel = await VideoChannelModel.loadByUrlAndPopulateAccount(uri)
157 }
158
159 return res.json({
160 total: videoChannel ? 1 : 0,
161 data: videoChannel ? [ videoChannel.toFormattedJSON() ] : []
162 })
163}
164
165function searchVideos (req: express.Request, res: express.Response) {
166 const query: VideosSearchQuery = req.query
167 const search = query.search
168
169 if (search && (search.startsWith('http://') || search.startsWith('https://'))) {
170 return searchVideoURI(search, res)
171 }
172
173 if (isSearchIndexSearch(query)) {
174 return searchVideosIndex(query, res)
175 }
176
177 return searchVideosDB(query, res)
178}
179
180async function searchVideosIndex (query: VideosSearchQuery, res: express.Response) {
181 const result = await buildMutedForSearchIndex(res)
182
183 let body: VideosSearchQuery = Object.assign(query, result)
184
185 // Use the default instance NSFW policy if not specified
186 if (!body.nsfw) {
187 const nsfwPolicy = res.locals.oauth
188 ? res.locals.oauth.token.User.nsfwPolicy
189 : CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
190
191 body.nsfw = nsfwPolicy === 'do_not_list'
192 ? 'false'
193 : 'both'
194 }
195
196 body = await Hooks.wrapObject(body, 'filter:api.search.videos.index.list.params')
197
198 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos'
199
200 try {
201 logger.debug('Doing videos search index request on %s.', url, { body })
202
203 const { body: searchIndexResult } = await doJSONRequest<ResultList<Video>>(url, { method: 'POST', json: body })
204 const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.videos.index.list.result')
205
206 return res.json(jsonResult)
207 } catch (err) {
208 logger.warn('Cannot use search index to make video search.', { err })
209
210 return res.fail({
211 status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
212 message: 'Cannot use search index to make video search'
213 })
214 }
215}
216
217async function searchVideosDB (query: VideosSearchQuery, res: express.Response) {
218 const apiOptions = await Hooks.wrapObject(Object.assign(query, {
219 includeLocalVideos: true,
220 nsfw: buildNSFWFilter(res, query.nsfw),
221 filter: query.filter,
222 user: res.locals.oauth ? res.locals.oauth.token.User : undefined
223 }), 'filter:api.search.videos.local.list.params')
224
225 const resultList = await Hooks.wrapPromiseFun(
226 VideoModel.searchAndPopulateAccountAndServer,
227 apiOptions,
228 'filter:api.search.videos.local.list.result'
229 )
230
231 return res.json(getFormattedObjects(resultList.data, resultList.total))
232}
233
234async function searchVideoURI (url: string, res: express.Response) {
235 let video: MVideoAccountLightBlacklistAllFiles
236
237 // Check if we can fetch a remote video with the URL
238 if (isUserAbleToSearchRemoteURI(res)) {
239 try {
240 const syncParam = {
241 likes: false,
242 dislikes: false,
243 shares: false,
244 comments: false,
245 thumbnail: true,
246 refreshVideo: false
247 }
248
249 const result = await getOrCreateAPVideo({ videoObject: url, syncParam })
250 video = result ? result.video : undefined
251 } catch (err) {
252 logger.info('Cannot search remote video %s.', url, { err })
253 }
254 } else {
255 video = await VideoModel.loadByUrlAndPopulateAccount(url)
256 }
257
258 return res.json({
259 total: video ? 1 : 0,
260 data: video ? [ video.toFormattedJSON() ] : []
261 })
262}
263
264function isSearchIndexSearch (query: SearchTargetQuery) {
265 if (query.searchTarget === 'search-index') return true
266
267 const searchIndexConfig = CONFIG.SEARCH.SEARCH_INDEX
268
269 if (searchIndexConfig.ENABLED !== true) return false
270
271 if (searchIndexConfig.DISABLE_LOCAL_SEARCH) return true
272 if (searchIndexConfig.IS_DEFAULT_SEARCH && !query.searchTarget) return true
273
274 return false
275}
276
277async function buildMutedForSearchIndex (res: express.Response) {
278 const serverActor = await getServerActor()
279 const accountIds = [ serverActor.Account.id ]
280
281 if (res.locals.oauth) {
282 accountIds.push(res.locals.oauth.token.User.Account.id)
283 }
284
285 const [ blockedHosts, blockedAccounts ] = await Promise.all([
286 ServerBlocklistModel.listHostsBlockedBy(accountIds),
287 AccountBlocklistModel.listHandlesBlockedBy(accountIds)
288 ])
289
290 return {
291 blockedHosts,
292 blockedAccounts
293 }
294}
diff --git a/server/controllers/api/search/index.ts b/server/controllers/api/search/index.ts
new file mode 100644
index 000000000..67adbb307
--- /dev/null
+++ b/server/controllers/api/search/index.ts
@@ -0,0 +1,16 @@
1import * as express from 'express'
2import { searchChannelsRouter } from './search-video-channels'
3import { searchPlaylistsRouter } from './search-video-playlists'
4import { searchVideosRouter } from './search-videos'
5
6const searchRouter = express.Router()
7
8searchRouter.use('/', searchVideosRouter)
9searchRouter.use('/', searchChannelsRouter)
10searchRouter.use('/', searchPlaylistsRouter)
11
12// ---------------------------------------------------------------------------
13
14export {
15 searchRouter
16}
diff --git a/server/controllers/api/search/search-video-channels.ts b/server/controllers/api/search/search-video-channels.ts
new file mode 100644
index 000000000..16beeed60
--- /dev/null
+++ b/server/controllers/api/search/search-video-channels.ts
@@ -0,0 +1,150 @@
1import * as express from 'express'
2import { sanitizeUrl } from '@server/helpers/core-utils'
3import { doJSONRequest } from '@server/helpers/requests'
4import { CONFIG } from '@server/initializers/config'
5import { WEBSERVER } from '@server/initializers/constants'
6import { Hooks } from '@server/lib/plugins/hooks'
7import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search'
8import { getServerActor } from '@server/models/application/application'
9import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
10import { ResultList, VideoChannel } from '@shared/models'
11import { VideoChannelsSearchQuery } from '../../../../shared/models/search'
12import { isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils'
13import { logger } from '../../../helpers/logger'
14import { getFormattedObjects } from '../../../helpers/utils'
15import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../../lib/activitypub/actors'
16import {
17 asyncMiddleware,
18 openapiOperationDoc,
19 optionalAuthenticate,
20 paginationValidator,
21 setDefaultPagination,
22 setDefaultSearchSort,
23 videoChannelsListSearchValidator,
24 videoChannelsSearchSortValidator
25} from '../../../middlewares'
26import { VideoChannelModel } from '../../../models/video/video-channel'
27import { MChannelAccountDefault } from '../../../types/models'
28
29const searchChannelsRouter = express.Router()
30
31searchChannelsRouter.get('/video-channels',
32 openapiOperationDoc({ operationId: 'searchChannels' }),
33 paginationValidator,
34 setDefaultPagination,
35 videoChannelsSearchSortValidator,
36 setDefaultSearchSort,
37 optionalAuthenticate,
38 videoChannelsListSearchValidator,
39 asyncMiddleware(searchVideoChannels)
40)
41
42// ---------------------------------------------------------------------------
43
44export { searchChannelsRouter }
45
46// ---------------------------------------------------------------------------
47
48function searchVideoChannels (req: express.Request, res: express.Response) {
49 const query: VideoChannelsSearchQuery = req.query
50 const search = query.search
51
52 const parts = search.split('@')
53
54 // Handle strings like @toto@example.com
55 if (parts.length === 3 && parts[0].length === 0) parts.shift()
56 const isWebfingerSearch = parts.length === 2 && parts.every(p => p && !p.includes(' '))
57
58 if (isURISearch(search) || isWebfingerSearch) return searchVideoChannelURI(search, isWebfingerSearch, res)
59
60 // @username -> username to search in DB
61 if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '')
62
63 if (isSearchIndexSearch(query)) {
64 return searchVideoChannelsIndex(query, res)
65 }
66
67 return searchVideoChannelsDB(query, res)
68}
69
70async function searchVideoChannelsIndex (query: VideoChannelsSearchQuery, res: express.Response) {
71 const result = await buildMutedForSearchIndex(res)
72
73 const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-channels.index.list.params')
74
75 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels'
76
77 try {
78 logger.debug('Doing video channels search index request on %s.', url, { body })
79
80 const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoChannel>>(url, { method: 'POST', json: body })
81 const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-channels.index.list.result')
82
83 return res.json(jsonResult)
84 } catch (err) {
85 logger.warn('Cannot use search index to make video channels search.', { err })
86
87 return res.fail({
88 status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
89 message: 'Cannot use search index to make video channels search'
90 })
91 }
92}
93
94async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) {
95 const serverActor = await getServerActor()
96
97 const apiOptions = await Hooks.wrapObject({
98 actorId: serverActor.id,
99 search: query.search,
100 start: query.start,
101 count: query.count,
102 sort: query.sort
103 }, 'filter:api.search.video-channels.local.list.params')
104
105 const resultList = await Hooks.wrapPromiseFun(
106 VideoChannelModel.searchForApi,
107 apiOptions,
108 'filter:api.search.video-channels.local.list.result'
109 )
110
111 return res.json(getFormattedObjects(resultList.data, resultList.total))
112}
113
114async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean, res: express.Response) {
115 let videoChannel: MChannelAccountDefault
116 let uri = search
117
118 if (isWebfingerSearch) {
119 try {
120 uri = await loadActorUrlOrGetFromWebfinger(search)
121 } catch (err) {
122 logger.warn('Cannot load actor URL or get from webfinger.', { search, err })
123
124 return res.json({ total: 0, data: [] })
125 }
126 }
127
128 if (isUserAbleToSearchRemoteURI(res)) {
129 try {
130 const actor = await getOrCreateAPActor(uri, 'all', true, true)
131 videoChannel = actor.VideoChannel
132 } catch (err) {
133 logger.info('Cannot search remote video channel %s.', uri, { err })
134 }
135 } else {
136 videoChannel = await VideoChannelModel.loadByUrlAndPopulateAccount(sanitizeLocalUrl(uri))
137 }
138
139 return res.json({
140 total: videoChannel ? 1 : 0,
141 data: videoChannel ? [ videoChannel.toFormattedJSON() ] : []
142 })
143}
144
145function sanitizeLocalUrl (url: string) {
146 if (!url) return ''
147
148 // Handle alternative channel URLs
149 return url.replace(new RegExp('^' + WEBSERVER.URL + '/c/'), WEBSERVER.URL + '/video-channels/')
150}
diff --git a/server/controllers/api/search/search-video-playlists.ts b/server/controllers/api/search/search-video-playlists.ts
new file mode 100644
index 000000000..b231ff1e2
--- /dev/null
+++ b/server/controllers/api/search/search-video-playlists.ts
@@ -0,0 +1,129 @@
1import * as express from 'express'
2import { sanitizeUrl } from '@server/helpers/core-utils'
3import { isUserAbleToSearchRemoteURI } from '@server/helpers/express-utils'
4import { logger } from '@server/helpers/logger'
5import { doJSONRequest } from '@server/helpers/requests'
6import { getFormattedObjects } from '@server/helpers/utils'
7import { CONFIG } from '@server/initializers/config'
8import { getOrCreateAPVideoPlaylist } from '@server/lib/activitypub/playlists/get'
9import { Hooks } from '@server/lib/plugins/hooks'
10import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search'
11import { getServerActor } from '@server/models/application/application'
12import { VideoPlaylistModel } from '@server/models/video/video-playlist'
13import { MVideoPlaylistFullSummary } from '@server/types/models'
14import { HttpStatusCode } from '@shared/core-utils'
15import { ResultList, VideoPlaylist, VideoPlaylistsSearchQuery } from '@shared/models'
16import {
17 asyncMiddleware,
18 openapiOperationDoc,
19 optionalAuthenticate,
20 paginationValidator,
21 setDefaultPagination,
22 setDefaultSearchSort,
23 videoPlaylistsListSearchValidator,
24 videoPlaylistsSearchSortValidator
25} from '../../../middlewares'
26import { WEBSERVER } from '@server/initializers/constants'
27
28const searchPlaylistsRouter = express.Router()
29
30searchPlaylistsRouter.get('/video-playlists',
31 openapiOperationDoc({ operationId: 'searchPlaylists' }),
32 paginationValidator,
33 setDefaultPagination,
34 videoPlaylistsSearchSortValidator,
35 setDefaultSearchSort,
36 optionalAuthenticate,
37 videoPlaylistsListSearchValidator,
38 asyncMiddleware(searchVideoPlaylists)
39)
40
41// ---------------------------------------------------------------------------
42
43export { searchPlaylistsRouter }
44
45// ---------------------------------------------------------------------------
46
47function searchVideoPlaylists (req: express.Request, res: express.Response) {
48 const query: VideoPlaylistsSearchQuery = req.query
49 const search = query.search
50
51 if (isURISearch(search)) return searchVideoPlaylistsURI(search, res)
52
53 if (isSearchIndexSearch(query)) {
54 return searchVideoPlaylistsIndex(query, res)
55 }
56
57 return searchVideoPlaylistsDB(query, res)
58}
59
60async function searchVideoPlaylistsIndex (query: VideoPlaylistsSearchQuery, res: express.Response) {
61 const result = await buildMutedForSearchIndex(res)
62
63 const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-playlists.index.list.params')
64
65 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-playlists'
66
67 try {
68 logger.debug('Doing video playlists search index request on %s.', url, { body })
69
70 const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoPlaylist>>(url, { method: 'POST', json: body })
71 const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-playlists.index.list.result')
72
73 return res.json(jsonResult)
74 } catch (err) {
75 logger.warn('Cannot use search index to make video playlists search.', { err })
76
77 return res.fail({
78 status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
79 message: 'Cannot use search index to make video playlists search'
80 })
81 }
82}
83
84async function searchVideoPlaylistsDB (query: VideoPlaylistsSearchQuery, res: express.Response) {
85 const serverActor = await getServerActor()
86
87 const apiOptions = await Hooks.wrapObject({
88 followerActorId: serverActor.id,
89 search: query.search,
90 start: query.start,
91 count: query.count,
92 sort: query.sort
93 }, 'filter:api.search.video-playlists.local.list.params')
94
95 const resultList = await Hooks.wrapPromiseFun(
96 VideoPlaylistModel.searchForApi,
97 apiOptions,
98 'filter:api.search.video-playlists.local.list.result'
99 )
100
101 return res.json(getFormattedObjects(resultList.data, resultList.total))
102}
103
104async function searchVideoPlaylistsURI (search: string, res: express.Response) {
105 let videoPlaylist: MVideoPlaylistFullSummary
106
107 if (isUserAbleToSearchRemoteURI(res)) {
108 try {
109 videoPlaylist = await getOrCreateAPVideoPlaylist(search)
110 } catch (err) {
111 logger.info('Cannot search remote video playlist %s.', search, { err })
112 }
113 } else {
114 videoPlaylist = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(sanitizeLocalUrl(search))
115 }
116
117 return res.json({
118 total: videoPlaylist ? 1 : 0,
119 data: videoPlaylist ? [ videoPlaylist.toFormattedJSON() ] : []
120 })
121}
122
123function sanitizeLocalUrl (url: string) {
124 if (!url) return ''
125
126 // Handle alternative channel URLs
127 return url.replace(new RegExp('^' + WEBSERVER.URL + '/videos/watch/playlist/'), WEBSERVER.URL + '/video-playlists/')
128 .replace(new RegExp('^' + WEBSERVER.URL + '/w/p/'), WEBSERVER.URL + '/video-playlists/')
129}
diff --git a/server/controllers/api/search/search-videos.ts b/server/controllers/api/search/search-videos.ts
new file mode 100644
index 000000000..b626baa28
--- /dev/null
+++ b/server/controllers/api/search/search-videos.ts
@@ -0,0 +1,153 @@
1import * as express from 'express'
2import { sanitizeUrl } from '@server/helpers/core-utils'
3import { doJSONRequest } from '@server/helpers/requests'
4import { CONFIG } from '@server/initializers/config'
5import { WEBSERVER } from '@server/initializers/constants'
6import { getOrCreateAPVideo } from '@server/lib/activitypub/videos'
7import { Hooks } from '@server/lib/plugins/hooks'
8import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search'
9import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
10import { ResultList, Video } from '@shared/models'
11import { VideosSearchQuery } from '../../../../shared/models/search'
12import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils'
13import { logger } from '../../../helpers/logger'
14import { getFormattedObjects } from '../../../helpers/utils'
15import {
16 asyncMiddleware,
17 commonVideosFiltersValidator,
18 openapiOperationDoc,
19 optionalAuthenticate,
20 paginationValidator,
21 setDefaultPagination,
22 setDefaultSearchSort,
23 videosSearchSortValidator,
24 videosSearchValidator
25} from '../../../middlewares'
26import { VideoModel } from '../../../models/video/video'
27import { MVideoAccountLightBlacklistAllFiles } from '../../../types/models'
28
29const searchVideosRouter = express.Router()
30
31searchVideosRouter.get('/videos',
32 openapiOperationDoc({ operationId: 'searchVideos' }),
33 paginationValidator,
34 setDefaultPagination,
35 videosSearchSortValidator,
36 setDefaultSearchSort,
37 optionalAuthenticate,
38 commonVideosFiltersValidator,
39 videosSearchValidator,
40 asyncMiddleware(searchVideos)
41)
42
43// ---------------------------------------------------------------------------
44
45export { searchVideosRouter }
46
47// ---------------------------------------------------------------------------
48
49function searchVideos (req: express.Request, res: express.Response) {
50 const query: VideosSearchQuery = req.query
51 const search = query.search
52
53 if (isURISearch(search)) {
54 return searchVideoURI(search, res)
55 }
56
57 if (isSearchIndexSearch(query)) {
58 return searchVideosIndex(query, res)
59 }
60
61 return searchVideosDB(query, res)
62}
63
64async function searchVideosIndex (query: VideosSearchQuery, res: express.Response) {
65 const result = await buildMutedForSearchIndex(res)
66
67 let body: VideosSearchQuery = Object.assign(query, result)
68
69 // Use the default instance NSFW policy if not specified
70 if (!body.nsfw) {
71 const nsfwPolicy = res.locals.oauth
72 ? res.locals.oauth.token.User.nsfwPolicy
73 : CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
74
75 body.nsfw = nsfwPolicy === 'do_not_list'
76 ? 'false'
77 : 'both'
78 }
79
80 body = await Hooks.wrapObject(body, 'filter:api.search.videos.index.list.params')
81
82 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos'
83
84 try {
85 logger.debug('Doing videos search index request on %s.', url, { body })
86
87 const { body: searchIndexResult } = await doJSONRequest<ResultList<Video>>(url, { method: 'POST', json: body })
88 const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.videos.index.list.result')
89
90 return res.json(jsonResult)
91 } catch (err) {
92 logger.warn('Cannot use search index to make video search.', { err })
93
94 return res.fail({
95 status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
96 message: 'Cannot use search index to make video search'
97 })
98 }
99}
100
101async function searchVideosDB (query: VideosSearchQuery, res: express.Response) {
102 const apiOptions = await Hooks.wrapObject(Object.assign(query, {
103 includeLocalVideos: true,
104 nsfw: buildNSFWFilter(res, query.nsfw),
105 filter: query.filter,
106 user: res.locals.oauth ? res.locals.oauth.token.User : undefined
107 }), 'filter:api.search.videos.local.list.params')
108
109 const resultList = await Hooks.wrapPromiseFun(
110 VideoModel.searchAndPopulateAccountAndServer,
111 apiOptions,
112 'filter:api.search.videos.local.list.result'
113 )
114
115 return res.json(getFormattedObjects(resultList.data, resultList.total))
116}
117
118async function searchVideoURI (url: string, res: express.Response) {
119 let video: MVideoAccountLightBlacklistAllFiles
120
121 // Check if we can fetch a remote video with the URL
122 if (isUserAbleToSearchRemoteURI(res)) {
123 try {
124 const syncParam = {
125 likes: false,
126 dislikes: false,
127 shares: false,
128 comments: false,
129 thumbnail: true,
130 refreshVideo: false
131 }
132
133 const result = await getOrCreateAPVideo({ videoObject: url, syncParam })
134 video = result ? result.video : undefined
135 } catch (err) {
136 logger.info('Cannot search remote video %s.', url, { err })
137 }
138 } else {
139 video = await VideoModel.loadByUrlAndPopulateAccount(sanitizeLocalUrl(url))
140 }
141
142 return res.json({
143 total: video ? 1 : 0,
144 data: video ? [ video.toFormattedJSON() ] : []
145 })
146}
147
148function sanitizeLocalUrl (url: string) {
149 if (!url) return ''
150
151 // Handle alternative video URLs
152 return url.replace(new RegExp('^' + WEBSERVER.URL + '/w/'), WEBSERVER.URL + '/videos/watch/')
153}
diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts
index 03aa918d3..bc8d203b0 100644
--- a/server/controllers/api/video-channel.ts
+++ b/server/controllers/api/video-channel.ts
@@ -32,7 +32,7 @@ import {
32 videoChannelsUpdateValidator, 32 videoChannelsUpdateValidator,
33 videoPlaylistsSortValidator 33 videoPlaylistsSortValidator
34} from '../../middlewares' 34} from '../../middlewares'
35import { videoChannelsNameWithHostValidator, videoChannelsOwnSearchValidator, videosSortValidator } from '../../middlewares/validators' 35import { videoChannelsListValidator, videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators'
36import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image' 36import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image'
37import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' 37import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists'
38import { AccountModel } from '../../models/account/account' 38import { AccountModel } from '../../models/account/account'
@@ -51,7 +51,7 @@ videoChannelRouter.get('/',
51 videoChannelsSortValidator, 51 videoChannelsSortValidator,
52 setDefaultSort, 52 setDefaultSort,
53 setDefaultPagination, 53 setDefaultPagination,
54 videoChannelsOwnSearchValidator, 54 videoChannelsListValidator,
55 asyncMiddleware(listVideoChannels) 55 asyncMiddleware(listVideoChannels)
56) 56)
57 57
diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts
index 7c816b93a..c25aed20b 100644
--- a/server/controllers/api/video-playlist.ts
+++ b/server/controllers/api/video-playlist.ts
@@ -1,7 +1,9 @@
1import * as express from 'express' 1import * as express from 'express'
2import { join } from 'path' 2import { join } from 'path'
3import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists'
3import { getServerActor } from '@server/models/application/application' 4import { getServerActor } from '@server/models/application/application'
4import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models' 5import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models'
6import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
5import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model' 7import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model'
6import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model' 8import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
7import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model' 9import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
@@ -17,7 +19,6 @@ import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constant
17import { sequelizeTypescript } from '../../initializers/database' 19import { sequelizeTypescript } from '../../initializers/database'
18import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send' 20import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send'
19import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url' 21import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url'
20import { JobQueue } from '../../lib/job-queue'
21import { updatePlaylistMiniatureFromExisting } from '../../lib/thumbnail' 22import { updatePlaylistMiniatureFromExisting } from '../../lib/thumbnail'
22import { 23import {
23 asyncMiddleware, 24 asyncMiddleware,
@@ -42,7 +43,6 @@ import {
42import { AccountModel } from '../../models/account/account' 43import { AccountModel } from '../../models/account/account'
43import { VideoPlaylistModel } from '../../models/video/video-playlist' 44import { VideoPlaylistModel } from '../../models/video/video-playlist'
44import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' 45import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
45import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
46 46
47const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR }) 47const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR })
48 48
@@ -144,9 +144,7 @@ async function listVideoPlaylists (req: express.Request, res: express.Response)
144function getVideoPlaylist (req: express.Request, res: express.Response) { 144function getVideoPlaylist (req: express.Request, res: express.Response) {
145 const videoPlaylist = res.locals.videoPlaylistSummary 145 const videoPlaylist = res.locals.videoPlaylistSummary
146 146
147 if (videoPlaylist.isOutdated()) { 147 scheduleRefreshIfNeeded(videoPlaylist)
148 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: videoPlaylist.url } })
149 }
150 148
151 return res.json(videoPlaylist.toFormattedJSON()) 149 return res.json(videoPlaylist.toFormattedJSON())
152} 150}
diff --git a/server/helpers/custom-validators/activitypub/playlist.ts b/server/helpers/custom-validators/activitypub/playlist.ts
index bd0d16a4a..72c5b80e9 100644
--- a/server/helpers/custom-validators/activitypub/playlist.ts
+++ b/server/helpers/custom-validators/activitypub/playlist.ts
@@ -1,13 +1,16 @@
1import { exists, isDateValid } from '../misc'
2import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
3import validator from 'validator' 1import validator from 'validator'
4import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object' 2import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object'
3import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
4import { exists, isDateValid, isUUIDValid } from '../misc'
5import { isVideoPlaylistNameValid } from '../video-playlists'
5import { isActivityPubUrlValid } from './misc' 6import { isActivityPubUrlValid } from './misc'
6 7
7function isPlaylistObjectValid (object: PlaylistObject) { 8function isPlaylistObjectValid (object: PlaylistObject) {
8 return exists(object) && 9 return exists(object) &&
9 object.type === 'Playlist' && 10 object.type === 'Playlist' &&
10 validator.isInt(object.totalItems + '') && 11 validator.isInt(object.totalItems + '') &&
12 isVideoPlaylistNameValid(object.name) &&
13 isUUIDValid(object.uuid) &&
11 isDateValid(object.published) && 14 isDateValid(object.published) &&
12 isDateValid(object.updated) 15 isDateValid(object.updated)
13} 16}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 06b4e5a18..cd00b73d5 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -77,6 +77,7 @@ const SORTABLE_COLUMNS = {
77 // Don't forget to update peertube-search-index with the same values 77 // Don't forget to update peertube-search-index with the same values
78 VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ], 78 VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
79 VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ], 79 VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
80 VIDEO_PLAYLISTS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
80 81
81 ABUSES: [ 'id', 'createdAt', 'state' ], 82 ABUSES: [ 'id', 'createdAt', 'state' ],
82 83
diff --git a/server/lib/activitypub/actors/get.ts b/server/lib/activitypub/actors/get.ts
index d7cf2b678..8681ea02a 100644
--- a/server/lib/activitypub/actors/get.ts
+++ b/server/lib/activitypub/actors/get.ts
@@ -116,7 +116,7 @@ async function scheduleOutboxFetchIfNeeded (actor: MActor, created: boolean, ref
116async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) { 116async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) {
117 // We created a new account: fetch the playlists 117 // We created a new account: fetch the playlists
118 if (created === true && actor.Account && accountPlaylistsUrl) { 118 if (created === true && actor.Account && accountPlaylistsUrl) {
119 const payload = { uri: accountPlaylistsUrl, accountId: actor.Account.id, type: 'account-playlists' as 'account-playlists' } 119 const payload = { uri: accountPlaylistsUrl, type: 'account-playlists' as 'account-playlists' }
120 await JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload }) 120 await JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload })
121 } 121 }
122} 122}
diff --git a/server/lib/activitypub/playlists/create-update.ts b/server/lib/activitypub/playlists/create-update.ts
index 37d748de4..ea3e61ac5 100644
--- a/server/lib/activitypub/playlists/create-update.ts
+++ b/server/lib/activitypub/playlists/create-update.ts
@@ -1,3 +1,5 @@
1import * as Bluebird from 'bluebird'
2import { getAPId } from '@server/helpers/activitypub'
1import { isArray } from '@server/helpers/custom-validators/misc' 3import { isArray } from '@server/helpers/custom-validators/misc'
2import { logger, loggerTagsFactory } from '@server/helpers/logger' 4import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants' 5import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants'
@@ -6,7 +8,7 @@ import { updatePlaylistMiniatureFromUrl } from '@server/lib/thumbnail'
6import { VideoPlaylistModel } from '@server/models/video/video-playlist' 8import { VideoPlaylistModel } from '@server/models/video/video-playlist'
7import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' 9import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
8import { FilteredModelAttributes } from '@server/types' 10import { FilteredModelAttributes } from '@server/types'
9import { MAccountDefault, MAccountId, MThumbnail, MVideoPlaylist, MVideoPlaylistFull } from '@server/types/models' 11import { MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models'
10import { AttributesOnly } from '@shared/core-utils' 12import { AttributesOnly } from '@shared/core-utils'
11import { PlaylistObject } from '@shared/models' 13import { PlaylistObject } from '@shared/models'
12import { getOrCreateAPActor } from '../actors' 14import { getOrCreateAPActor } from '../actors'
@@ -19,11 +21,9 @@ import {
19 playlistObjectToDBAttributes 21 playlistObjectToDBAttributes
20} from './shared' 22} from './shared'
21 23
22import Bluebird = require('bluebird')
23
24const lTags = loggerTagsFactory('ap', 'video-playlist') 24const lTags = loggerTagsFactory('ap', 'video-playlist')
25 25
26async function createAccountPlaylists (playlistUrls: string[], account: MAccountDefault) { 26async function createAccountPlaylists (playlistUrls: string[]) {
27 await Bluebird.map(playlistUrls, async playlistUrl => { 27 await Bluebird.map(playlistUrls, async playlistUrl => {
28 try { 28 try {
29 const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl) 29 const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
@@ -35,19 +35,19 @@ async function createAccountPlaylists (playlistUrls: string[], account: MAccount
35 throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`) 35 throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`)
36 } 36 }
37 37
38 return createOrUpdateVideoPlaylist(playlistObject, account, playlistObject.to) 38 return createOrUpdateVideoPlaylist(playlistObject)
39 } catch (err) { 39 } catch (err) {
40 logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) }) 40 logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) })
41 } 41 }
42 }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) 42 }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
43} 43}
44 44
45async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) { 45async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, to?: string[]) {
46 const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to) 46 const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to)
47 47
48 await setVideoChannelIfNeeded(playlistObject, playlistAttributes) 48 await setVideoChannel(playlistObject, playlistAttributes)
49 49
50 const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylist>(playlistAttributes, { returning: true }) 50 const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylistVideosLength>(playlistAttributes, { returning: true })
51 51
52 const playlistElementUrls = await fetchElementUrls(playlistObject) 52 const playlistElementUrls = await fetchElementUrls(playlistObject)
53 53
@@ -56,7 +56,10 @@ async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAc
56 56
57 await updatePlaylistThumbnail(playlistObject, playlist) 57 await updatePlaylistThumbnail(playlistObject, playlist)
58 58
59 return rebuildVideoPlaylistElements(playlistElementUrls, playlist) 59 const elementsLength = await rebuildVideoPlaylistElements(playlistElementUrls, playlist)
60 playlist.setVideosLength(elementsLength)
61
62 return playlist
60} 63}
61 64
62// --------------------------------------------------------------------------- 65// ---------------------------------------------------------------------------
@@ -68,10 +71,12 @@ export {
68 71
69// --------------------------------------------------------------------------- 72// ---------------------------------------------------------------------------
70 73
71async function setVideoChannelIfNeeded (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) { 74async function setVideoChannel (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) {
72 if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) return 75 if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) {
76 throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject))
77 }
73 78
74 const actor = await getOrCreateAPActor(playlistObject.attributedTo[0]) 79 const actor = await getOrCreateAPActor(playlistObject.attributedTo[0], 'all')
75 80
76 if (!actor.VideoChannel) { 81 if (!actor.VideoChannel) {
77 logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) }) 82 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
79 } 84 }
80 85
81 playlistAttributes.videoChannelId = actor.VideoChannel.id 86 playlistAttributes.videoChannelId = actor.VideoChannel.id
87 playlistAttributes.ownerAccountId = actor.VideoChannel.Account.id
82} 88}
83 89
84async function fetchElementUrls (playlistObject: PlaylistObject) { 90async function fetchElementUrls (playlistObject: PlaylistObject) {
@@ -128,7 +134,7 @@ async function rebuildVideoPlaylistElements (elementUrls: string[], playlist: MV
128 134
129 logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url)) 135 logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url))
130 136
131 return undefined 137 return elementsToCreate.length
132} 138}
133 139
134async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) { 140async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) {
diff --git a/server/lib/activitypub/playlists/get.ts b/server/lib/activitypub/playlists/get.ts
new file mode 100644
index 000000000..2c19c503a
--- /dev/null
+++ b/server/lib/activitypub/playlists/get.ts
@@ -0,0 +1,35 @@
1import { getAPId } from '@server/helpers/activitypub'
2import { VideoPlaylistModel } from '@server/models/video/video-playlist'
3import { MVideoPlaylistFullSummary } from '@server/types/models'
4import { APObject } from '@shared/models'
5import { createOrUpdateVideoPlaylist } from './create-update'
6import { scheduleRefreshIfNeeded } from './refresh'
7import { fetchRemoteVideoPlaylist } from './shared'
8
9async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObject): Promise<MVideoPlaylistFullSummary> {
10 const playlistUrl = getAPId(playlistObjectArg)
11
12 const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl)
13
14 if (playlistFromDatabase) {
15 scheduleRefreshIfNeeded(playlistFromDatabase)
16
17 return playlistFromDatabase
18 }
19
20 const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl)
21 if (!playlistObject) throw new Error('Cannot fetch remote playlist with url: ' + playlistUrl)
22
23 // playlistUrl is just an alias/rediraction, so process object id instead
24 if (playlistObject.id !== playlistUrl) return getOrCreateAPVideoPlaylist(playlistObject)
25
26 const playlistCreated = await createOrUpdateVideoPlaylist(playlistObject)
27
28 return playlistCreated
29}
30
31// ---------------------------------------------------------------------------
32
33export {
34 getOrCreateAPVideoPlaylist
35}
diff --git a/server/lib/activitypub/playlists/index.ts b/server/lib/activitypub/playlists/index.ts
index 2885830b4..e2470a674 100644
--- a/server/lib/activitypub/playlists/index.ts
+++ b/server/lib/activitypub/playlists/index.ts
@@ -1,2 +1,3 @@
1export * from './get'
1export * from './create-update' 2export * from './create-update'
2export * from './refresh' 3export * from './refresh'
diff --git a/server/lib/activitypub/playlists/refresh.ts b/server/lib/activitypub/playlists/refresh.ts
index 6f3a6be37..ef3cb3fe4 100644
--- a/server/lib/activitypub/playlists/refresh.ts
+++ b/server/lib/activitypub/playlists/refresh.ts
@@ -1,10 +1,17 @@
1import { logger, loggerTagsFactory } from '@server/helpers/logger' 1import { logger, loggerTagsFactory } from '@server/helpers/logger'
2import { PeerTubeRequestError } from '@server/helpers/requests' 2import { PeerTubeRequestError } from '@server/helpers/requests'
3import { MVideoPlaylistOwner } from '@server/types/models' 3import { JobQueue } from '@server/lib/job-queue'
4import { MVideoPlaylist, MVideoPlaylistOwner } from '@server/types/models'
4import { HttpStatusCode } from '@shared/core-utils' 5import { HttpStatusCode } from '@shared/core-utils'
5import { createOrUpdateVideoPlaylist } from './create-update' 6import { createOrUpdateVideoPlaylist } from './create-update'
6import { fetchRemoteVideoPlaylist } from './shared' 7import { fetchRemoteVideoPlaylist } from './shared'
7 8
9function scheduleRefreshIfNeeded (playlist: MVideoPlaylist) {
10 if (!playlist.isOutdated()) return
11
12 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: playlist.url } })
13}
14
8async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> { 15async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> {
9 if (!videoPlaylist.isOutdated()) return videoPlaylist 16 if (!videoPlaylist.isOutdated()) return videoPlaylist
10 17
@@ -22,8 +29,7 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner)
22 return videoPlaylist 29 return videoPlaylist
23 } 30 }
24 31
25 const byAccount = videoPlaylist.OwnerAccount 32 await createOrUpdateVideoPlaylist(playlistObject)
26 await createOrUpdateVideoPlaylist(playlistObject, byAccount, playlistObject.to)
27 33
28 return videoPlaylist 34 return videoPlaylist
29 } catch (err) { 35 } catch (err) {
@@ -42,5 +48,6 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner)
42} 48}
43 49
44export { 50export {
51 scheduleRefreshIfNeeded,
45 refreshVideoPlaylistIfNeeded 52 refreshVideoPlaylistIfNeeded
46} 53}
diff --git a/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts b/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts
index 6ec44485e..70fd335bc 100644
--- a/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts
+++ b/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts
@@ -1,11 +1,11 @@
1import { ACTIVITY_PUB } from '@server/initializers/constants' 1import { ACTIVITY_PUB } from '@server/initializers/constants'
2import { VideoPlaylistModel } from '@server/models/video/video-playlist' 2import { VideoPlaylistModel } from '@server/models/video/video-playlist'
3import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' 3import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
4import { MAccountId, MVideoId, MVideoPlaylistId } from '@server/types/models' 4import { MVideoId, MVideoPlaylistId } from '@server/types/models'
5import { AttributesOnly } from '@shared/core-utils' 5import { AttributesOnly } from '@shared/core-utils'
6import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models' 6import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models'
7 7
8function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) { 8function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: string[]) {
9 const privacy = to.includes(ACTIVITY_PUB.PUBLIC) 9 const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
10 ? VideoPlaylistPrivacy.PUBLIC 10 ? VideoPlaylistPrivacy.PUBLIC
11 : VideoPlaylistPrivacy.UNLISTED 11 : VideoPlaylistPrivacy.UNLISTED
@@ -16,7 +16,7 @@ function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount
16 privacy, 16 privacy,
17 url: playlistObject.id, 17 url: playlistObject.id,
18 uuid: playlistObject.uuid, 18 uuid: playlistObject.uuid,
19 ownerAccountId: byAccount.id, 19 ownerAccountId: null,
20 videoChannelId: null, 20 videoChannelId: null,
21 createdAt: new Date(playlistObject.published), 21 createdAt: new Date(playlistObject.published),
22 updatedAt: new Date(playlistObject.updated) 22 updatedAt: new Date(playlistObject.updated)
diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts
index 6b7f5aae8..70e048d6e 100644
--- a/server/lib/activitypub/process/process-create.ts
+++ b/server/lib/activitypub/process/process-create.ts
@@ -128,5 +128,5 @@ async function processCreatePlaylist (activity: ActivityCreate, byActor: MActorS
128 128
129 if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url) 129 if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url)
130 130
131 await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to) 131 await createOrUpdateVideoPlaylist(playlistObject, activity.to)
132} 132}
diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts
index aa80d5d09..f40008a6b 100644
--- a/server/lib/activitypub/process/process-update.ts
+++ b/server/lib/activitypub/process/process-update.ts
@@ -111,5 +111,5 @@ async function processUpdatePlaylist (byActor: MActorSignature, activity: Activi
111 111
112 if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url) 112 if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url)
113 113
114 await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to) 114 await createOrUpdateVideoPlaylist(playlistObject, activity.to)
115} 115}
diff --git a/server/lib/activitypub/videos/get.ts b/server/lib/activitypub/videos/get.ts
index 7bb14adc4..f3e2f0625 100644
--- a/server/lib/activitypub/videos/get.ts
+++ b/server/lib/activitypub/videos/get.ts
@@ -3,6 +3,7 @@ import { retryTransactionWrapper } from '@server/helpers/database-utils'
3import { JobQueue } from '@server/lib/job-queue' 3import { JobQueue } from '@server/lib/job-queue'
4import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders' 4import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders'
5import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models' 5import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models'
6import { APObject } from '@shared/models'
6import { refreshVideoIfNeeded } from './refresh' 7import { refreshVideoIfNeeded } from './refresh'
7import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared' 8import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared'
8 9
@@ -13,21 +14,21 @@ type GetVideoResult <T> = Promise<{
13}> 14}>
14 15
15type GetVideoParamAll = { 16type GetVideoParamAll = {
16 videoObject: { id: string } | string 17 videoObject: APObject
17 syncParam?: SyncParam 18 syncParam?: SyncParam
18 fetchType?: 'all' 19 fetchType?: 'all'
19 allowRefresh?: boolean 20 allowRefresh?: boolean
20} 21}
21 22
22type GetVideoParamImmutable = { 23type GetVideoParamImmutable = {
23 videoObject: { id: string } | string 24 videoObject: APObject
24 syncParam?: SyncParam 25 syncParam?: SyncParam
25 fetchType: 'only-immutable-attributes' 26 fetchType: 'only-immutable-attributes'
26 allowRefresh: false 27 allowRefresh: false
27} 28}
28 29
29type GetVideoParamOther = { 30type GetVideoParamOther = {
30 videoObject: { id: string } | string 31 videoObject: APObject
31 syncParam?: SyncParam 32 syncParam?: SyncParam
32 fetchType?: 'all' | 'only-video' 33 fetchType?: 'all' | 'only-video'
33 allowRefresh?: boolean 34 allowRefresh?: boolean
diff --git a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts
index 04b25f955..ab9675cae 100644
--- a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts
+++ b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts
@@ -1,12 +1,11 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { ActivitypubHttpFetcherPayload, FetchType } from '@shared/models' 2import { ActivitypubHttpFetcherPayload, FetchType } from '@shared/models'
3import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
4import { AccountModel } from '../../../models/account/account'
5import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 4import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
6import { VideoModel } from '../../../models/video/video' 5import { VideoModel } from '../../../models/video/video'
7import { VideoCommentModel } from '../../../models/video/video-comment' 6import { VideoCommentModel } from '../../../models/video/video-comment'
8import { VideoShareModel } from '../../../models/video/video-share' 7import { VideoShareModel } from '../../../models/video/video-share'
9import { MAccountDefault, MVideoFullLight } from '../../../types/models' 8import { MVideoFullLight } from '../../../types/models'
10import { crawlCollectionPage } from '../../activitypub/crawl' 9import { crawlCollectionPage } from '../../activitypub/crawl'
11import { createAccountPlaylists } from '../../activitypub/playlists' 10import { createAccountPlaylists } from '../../activitypub/playlists'
12import { processActivities } from '../../activitypub/process' 11import { processActivities } from '../../activitypub/process'
@@ -22,16 +21,13 @@ async function processActivityPubHttpFetcher (job: Bull.Job) {
22 let video: MVideoFullLight 21 let video: MVideoFullLight
23 if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId) 22 if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId)
24 23
25 let account: MAccountDefault
26 if (payload.accountId) account = await AccountModel.load(payload.accountId)
27
28 const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = { 24 const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = {
29 'activity': items => processActivities(items, { outboxUrl: payload.uri, fromFetch: true }), 25 'activity': items => processActivities(items, { outboxUrl: payload.uri, fromFetch: true }),
30 'video-likes': items => createRates(items, video, 'like'), 26 'video-likes': items => createRates(items, video, 'like'),
31 'video-dislikes': items => createRates(items, video, 'dislike'), 27 'video-dislikes': items => createRates(items, video, 'dislike'),
32 'video-shares': items => addVideoShares(items, video), 28 'video-shares': items => addVideoShares(items, video),
33 'video-comments': items => addVideoComments(items), 29 'video-comments': items => addVideoComments(items),
34 'account-playlists': items => createAccountPlaylists(items, account) 30 'account-playlists': items => createAccountPlaylists(items)
35 } 31 }
36 32
37 const cleanerType: { [ id in FetchType ]?: (crawlStartDate: Date) => Promise<any> } = { 33 const cleanerType: { [ id in FetchType ]?: (crawlStartDate: Date) => Promise<any> } = {
diff --git a/server/lib/search.ts b/server/lib/search.ts
new file mode 100644
index 000000000..b643a4055
--- /dev/null
+++ b/server/lib/search.ts
@@ -0,0 +1,50 @@
1import * as express from 'express'
2import { CONFIG } from '@server/initializers/config'
3import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
4import { getServerActor } from '@server/models/application/application'
5import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
6import { SearchTargetQuery } from '@shared/models'
7
8function isSearchIndexSearch (query: SearchTargetQuery) {
9 if (query.searchTarget === 'search-index') return true
10
11 const searchIndexConfig = CONFIG.SEARCH.SEARCH_INDEX
12
13 if (searchIndexConfig.ENABLED !== true) return false
14
15 if (searchIndexConfig.DISABLE_LOCAL_SEARCH) return true
16 if (searchIndexConfig.IS_DEFAULT_SEARCH && !query.searchTarget) return true
17
18 return false
19}
20
21async function buildMutedForSearchIndex (res: express.Response) {
22 const serverActor = await getServerActor()
23 const accountIds = [ serverActor.Account.id ]
24
25 if (res.locals.oauth) {
26 accountIds.push(res.locals.oauth.token.User.Account.id)
27 }
28
29 const [ blockedHosts, blockedAccounts ] = await Promise.all([
30 ServerBlocklistModel.listHostsBlockedBy(accountIds),
31 AccountBlocklistModel.listHandlesBlockedBy(accountIds)
32 ])
33
34 return {
35 blockedHosts,
36 blockedAccounts
37 }
38}
39
40function isURISearch (search: string) {
41 if (!search) return false
42
43 return search.startsWith('http://') || search.startsWith('https://')
44}
45
46export {
47 isSearchIndexSearch,
48 buildMutedForSearchIndex,
49 isURISearch
50}
diff --git a/server/middlewares/validators/search.ts b/server/middlewares/validators/search.ts
index e2e1c6aae..7bbf81048 100644
--- a/server/middlewares/validators/search.ts
+++ b/server/middlewares/validators/search.ts
@@ -49,11 +49,12 @@ const videoChannelsListSearchValidator = [
49 } 49 }
50] 50]
51 51
52const videoChannelsOwnSearchValidator = [ 52const videoPlaylistsListSearchValidator = [
53 query('search').optional().not().isEmpty().withMessage('Should have a valid search'), 53 query('search').not().isEmpty().withMessage('Should have a valid search'),
54 query('searchTarget').optional().custom(isSearchTargetValid).withMessage('Should have a valid search target'),
54 55
55 (req: express.Request, res: express.Response, next: express.NextFunction) => { 56 (req: express.Request, res: express.Response, next: express.NextFunction) => {
56 logger.debug('Checking video channels search query', { parameters: req.query }) 57 logger.debug('Checking video playlists search query', { parameters: req.query })
57 58
58 if (areValidationErrors(req, res)) return 59 if (areValidationErrors(req, res)) return
59 60
@@ -66,5 +67,5 @@ const videoChannelsOwnSearchValidator = [
66export { 67export {
67 videosSearchValidator, 68 videosSearchValidator,
68 videoChannelsListSearchValidator, 69 videoChannelsListSearchValidator,
69 videoChannelsOwnSearchValidator 70 videoPlaylistsListSearchValidator
70} 71}
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index d67b6f3ba..473010460 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -9,6 +9,7 @@ const SORTABLE_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ABUSES)
9const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS) 9const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
10const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH) 10const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH)
11const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH) 11const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
12const SORTABLE_VIDEO_PLAYLISTS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
12const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS) 13const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS)
13const SORTABLE_VIDEO_COMMENTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) 14const SORTABLE_VIDEO_COMMENTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
14const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) 15const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
@@ -34,6 +35,7 @@ const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
34const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS) 35const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS)
35const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS) 36const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
36const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS) 37const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS)
38const videoPlaylistsSearchSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_SEARCH_COLUMNS)
37const videoCommentsValidator = checkSort(SORTABLE_VIDEO_COMMENTS_COLUMNS) 39const videoCommentsValidator = checkSort(SORTABLE_VIDEO_COMMENTS_COLUMNS)
38const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS) 40const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
39const videoRatesSortValidator = checkSort(SORTABLE_VIDEO_RATES_COLUMNS) 41const videoRatesSortValidator = checkSort(SORTABLE_VIDEO_RATES_COLUMNS)
@@ -75,5 +77,6 @@ export {
75 userNotificationsSortValidator, 77 userNotificationsSortValidator,
76 videoPlaylistsSortValidator, 78 videoPlaylistsSortValidator,
77 videoRedundanciesSortValidator, 79 videoRedundanciesSortValidator,
80 videoPlaylistsSearchSortValidator,
78 pluginsSortValidator 81 pluginsSortValidator
79} 82}
diff --git a/server/middlewares/validators/videos/video-channels.ts b/server/middlewares/validators/videos/video-channels.ts
index 911a25bfb..e7df185e4 100644
--- a/server/middlewares/validators/videos/video-channels.ts
+++ b/server/middlewares/validators/videos/video-channels.ts
@@ -141,6 +141,18 @@ const videoChannelStatsValidator = [
141 } 141 }
142] 142]
143 143
144const videoChannelsListValidator = [
145 query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
146
147 (req: express.Request, res: express.Response, next: express.NextFunction) => {
148 logger.debug('Checking video channels search query', { parameters: req.query })
149
150 if (areValidationErrors(req, res)) return
151
152 return next()
153 }
154]
155
144// --------------------------------------------------------------------------- 156// ---------------------------------------------------------------------------
145 157
146export { 158export {
@@ -148,6 +160,7 @@ export {
148 videoChannelsUpdateValidator, 160 videoChannelsUpdateValidator,
149 videoChannelsRemoveValidator, 161 videoChannelsRemoveValidator,
150 videoChannelsNameWithHostValidator, 162 videoChannelsNameWithHostValidator,
163 videoChannelsListValidator,
151 localVideoChannelValidator, 164 localVideoChannelValidator,
152 videoChannelStatsValidator 165 videoChannelStatsValidator
153} 166}
diff --git a/server/models/video/sql/shared/abstract-videos-query-builder.ts b/server/models/video/sql/shared/abstract-videos-query-builder.ts
index 3a1ee5b1f..09776bcb0 100644
--- a/server/models/video/sql/shared/abstract-videos-query-builder.ts
+++ b/server/models/video/sql/shared/abstract-videos-query-builder.ts
@@ -18,7 +18,7 @@ export class AbstractVideosQueryBuilder {
18 logging: options.logging, 18 logging: options.logging,
19 replacements: this.replacements, 19 replacements: this.replacements,
20 type: QueryTypes.SELECT as QueryTypes.SELECT, 20 type: QueryTypes.SELECT as QueryTypes.SELECT,
21 next: false 21 nest: false
22 } 22 }
23 23
24 return this.sequelize.query<any>(this.query, queryOptions) 24 return this.sequelize.query<any>(this.query, queryOptions)
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 33749ea70..f84b85290 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -434,8 +434,8 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
434 sort: string 434 sort: string
435 }) { 435 }) {
436 const attributesInclude = [] 436 const attributesInclude = []
437 const escapedSearch = VideoModel.sequelize.escape(options.search) 437 const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
438 const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%') 438 const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
439 attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search)) 439 attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
440 440
441 const query = { 441 const query = {
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index 1a05f8d42..7aa6b6c6e 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -1,5 +1,5 @@
1import { join } from 'path' 1import { join } from 'path'
2import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize' 2import { FindOptions, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
3import { 3import {
4 AllowNull, 4 AllowNull,
5 BelongsTo, 5 BelongsTo,
@@ -53,7 +53,15 @@ import {
53} from '../../types/models/video/video-playlist' 53} from '../../types/models/video/video-playlist'
54import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' 54import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
55import { ActorModel } from '../actor/actor' 55import { ActorModel } from '../actor/actor'
56import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils' 56import {
57 buildServerIdsFollowedBy,
58 buildTrigramSearchIndex,
59 buildWhereIdOrUUID,
60 createSimilarityAttribute,
61 getPlaylistSort,
62 isOutdated,
63 throwIfNotValid
64} from '../utils'
57import { ThumbnailModel } from './thumbnail' 65import { ThumbnailModel } from './thumbnail'
58import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' 66import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
59import { VideoPlaylistElementModel } from './video-playlist-element' 67import { VideoPlaylistElementModel } from './video-playlist-element'
@@ -74,6 +82,11 @@ type AvailableForListOptions = {
74 videoChannelId?: number 82 videoChannelId?: number
75 listMyPlaylists?: boolean 83 listMyPlaylists?: boolean
76 search?: string 84 search?: string
85 withVideos?: boolean
86}
87
88function getVideoLengthSelect () {
89 return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"'
77} 90}
78 91
79@Scopes(() => ({ 92@Scopes(() => ({
@@ -89,7 +102,7 @@ type AvailableForListOptions = {
89 attributes: { 102 attributes: {
90 include: [ 103 include: [
91 [ 104 [
92 literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'), 105 literal(`(${getVideoLengthSelect()})`),
93 'videosLength' 106 'videosLength'
94 ] 107 ]
95 ] 108 ]
@@ -178,11 +191,28 @@ type AvailableForListOptions = {
178 }) 191 })
179 } 192 }
180 193
194 if (options.withVideos === true) {
195 whereAnd.push(
196 literal(`(${getVideoLengthSelect()}) != 0`)
197 )
198 }
199
200 const attributesInclude = []
201
181 if (options.search) { 202 if (options.search) {
203 const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search)
204 const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%')
205 attributesInclude.push(createSimilarityAttribute('VideoPlaylistModel.name', options.search))
206
182 whereAnd.push({ 207 whereAnd.push({
183 name: { 208 [Op.or]: [
184 [Op.iLike]: '%' + options.search + '%' 209 Sequelize.literal(
185 } 210 'lower(immutable_unaccent("VideoPlaylistModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
211 ),
212 Sequelize.literal(
213 'lower(immutable_unaccent("VideoPlaylistModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
214 )
215 ]
186 }) 216 })
187 } 217 }
188 218
@@ -191,6 +221,9 @@ type AvailableForListOptions = {
191 } 221 }
192 222
193 return { 223 return {
224 attributes: {
225 include: attributesInclude
226 },
194 where, 227 where,
195 include: [ 228 include: [
196 { 229 {
@@ -211,6 +244,8 @@ type AvailableForListOptions = {
211@Table({ 244@Table({
212 tableName: 'videoPlaylist', 245 tableName: 'videoPlaylist',
213 indexes: [ 246 indexes: [
247 buildTrigramSearchIndex('video_playlist_name_trigram', 'name'),
248
214 { 249 {
215 fields: [ 'ownerAccountId' ] 250 fields: [ 'ownerAccountId' ]
216 }, 251 },
@@ -314,6 +349,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
314 videoChannelId?: number 349 videoChannelId?: number
315 listMyPlaylists?: boolean 350 listMyPlaylists?: boolean
316 search?: string 351 search?: string
352 withVideos?: boolean // false by default
317 }) { 353 }) {
318 const query = { 354 const query = {
319 offset: options.start, 355 offset: options.start,
@@ -331,7 +367,8 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
331 accountId: options.accountId, 367 accountId: options.accountId,
332 videoChannelId: options.videoChannelId, 368 videoChannelId: options.videoChannelId,
333 listMyPlaylists: options.listMyPlaylists, 369 listMyPlaylists: options.listMyPlaylists,
334 search: options.search 370 search: options.search,
371 withVideos: options.withVideos || false
335 } as AvailableForListOptions 372 } as AvailableForListOptions
336 ] 373 ]
337 }, 374 },
@@ -347,6 +384,21 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
347 }) 384 })
348 } 385 }
349 386
387 static searchForApi (options: {
388 followerActorId: number
389 start: number
390 count: number
391 sort: string
392 search?: string
393 }) {
394 return VideoPlaylistModel.listForApi({
395 ...options,
396 type: VideoPlaylistType.REGULAR,
397 listMyPlaylists: false,
398 withVideos: true
399 })
400 }
401
350 static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) { 402 static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) {
351 const where = { 403 const where = {
352 privacy: VideoPlaylistPrivacy.PUBLIC 404 privacy: VideoPlaylistPrivacy.PUBLIC
@@ -445,6 +497,18 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
445 return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query) 497 return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
446 } 498 }
447 499
500 static loadByUrlWithAccountAndChannelSummary (url: string): Promise<MVideoPlaylistFullSummary> {
501 const query = {
502 where: {
503 url
504 }
505 }
506
507 return VideoPlaylistModel
508 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
509 .findOne(query)
510 }
511
448 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { 512 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
449 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown' 513 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
450 } 514 }
@@ -535,6 +599,10 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
535 return setAsUpdated('videoPlaylist', this.id) 599 return setAsUpdated('videoPlaylist', this.id)
536 } 600 }
537 601
602 setVideosLength (videosLength: number) {
603 this.set('videosLength' as any, videosLength, { raw: true })
604 }
605
538 isOwned () { 606 isOwned () {
539 return this.OwnerAccount.isOwned() 607 return this.OwnerAccount.isOwned()
540 } 608 }
@@ -551,6 +619,8 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
551 uuid: this.uuid, 619 uuid: this.uuid,
552 isLocal: this.isOwned(), 620 isLocal: this.isOwned(),
553 621
622 url: this.url,
623
554 displayName: this.name, 624 displayName: this.name,
555 description: this.description, 625 description: this.description,
556 privacy: { 626 privacy: {
diff --git a/server/tests/api/check-params/search.ts b/server/tests/api/check-params/search.ts
index 8378c3a89..20ad46cff 100644
--- a/server/tests/api/check-params/search.ts
+++ b/server/tests/api/check-params/search.ts
@@ -140,6 +140,30 @@ describe('Test videos API validator', function () {
140 }) 140 })
141 }) 141 })
142 142
143 describe('When searching video playlists', function () {
144 const path = '/api/v1/search/video-playlists/'
145
146 const query = {
147 search: 'coucou'
148 }
149
150 it('Should fail with a bad start pagination', async function () {
151 await checkBadStartPagination(server.url, path, null, query)
152 })
153
154 it('Should fail with a bad count pagination', async function () {
155 await checkBadCountPagination(server.url, path, null, query)
156 })
157
158 it('Should fail with an incorrect sort', async function () {
159 await checkBadSortPagination(server.url, path, null, query)
160 })
161
162 it('Should success with the correct parameters', async function () {
163 await makeGetRequest({ url: server.url, path, query, statusCodeExpected: HttpStatusCode.OK_200 })
164 })
165 })
166
143 describe('When searching video channels', function () { 167 describe('When searching video channels', function () {
144 const path = '/api/v1/search/video-channels/' 168 const path = '/api/v1/search/video-channels/'
145 169
@@ -171,6 +195,7 @@ describe('Test videos API validator', function () {
171 195
172 const query = { search: 'coucou' } 196 const query = { search: 'coucou' }
173 const paths = [ 197 const paths = [
198 '/api/v1/search/video-playlists/',
174 '/api/v1/search/video-channels/', 199 '/api/v1/search/video-channels/',
175 '/api/v1/search/videos/' 200 '/api/v1/search/videos/'
176 ] 201 ]
diff --git a/server/tests/api/search/index.ts b/server/tests/api/search/index.ts
index 232c1f2a4..a976d210d 100644
--- a/server/tests/api/search/index.ts
+++ b/server/tests/api/search/index.ts
@@ -1,5 +1,7 @@
1import './search-activitypub-video-playlists'
1import './search-activitypub-video-channels' 2import './search-activitypub-video-channels'
2import './search-activitypub-videos' 3import './search-activitypub-videos'
4import './search-channels'
3import './search-index' 5import './search-index'
6import './search-playlists'
4import './search-videos' 7import './search-videos'
5import './search-channels'
diff --git a/server/tests/api/search/search-activitypub-video-channels.ts b/server/tests/api/search/search-activitypub-video-channels.ts
index d7e3ed5be..e83eb7171 100644
--- a/server/tests/api/search/search-activitypub-video-channels.ts
+++ b/server/tests/api/search/search-activitypub-video-channels.ts
@@ -106,9 +106,25 @@ describe('Test ActivityPub video channels search', function () {
106 } 106 }
107 }) 107 })
108 108
109 it('Should search a local video channel with an alternative URL', async function () {
110 const search = 'http://localhost:' + servers[0].port + '/c/channel1_server1'
111
112 for (const token of [ undefined, servers[0].accessToken ]) {
113 const res = await searchVideoChannel(servers[0].url, search, token)
114
115 expect(res.body.total).to.equal(1)
116 expect(res.body.data).to.be.an('array')
117 expect(res.body.data).to.have.lengthOf(1)
118 expect(res.body.data[0].name).to.equal('channel1_server1')
119 expect(res.body.data[0].displayName).to.equal('Channel 1 server 1')
120 }
121 })
122
109 it('Should search a remote video channel with URL or handle', async function () { 123 it('Should search a remote video channel with URL or handle', async function () {
110 const searches = [ 124 const searches = [
111 'http://localhost:' + servers[1].port + '/video-channels/channel1_server2', 125 'http://localhost:' + servers[1].port + '/video-channels/channel1_server2',
126 'http://localhost:' + servers[1].port + '/c/channel1_server2',
127 'http://localhost:' + servers[1].port + '/c/channel1_server2/videos',
112 'channel1_server2@localhost:' + servers[1].port 128 'channel1_server2@localhost:' + servers[1].port
113 ] 129 ]
114 130
diff --git a/server/tests/api/search/search-activitypub-video-playlists.ts b/server/tests/api/search/search-activitypub-video-playlists.ts
new file mode 100644
index 000000000..4c08e9548
--- /dev/null
+++ b/server/tests/api/search/search-activitypub-video-playlists.ts
@@ -0,0 +1,212 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import {
6 addVideoInPlaylist,
7 cleanupTests,
8 createVideoPlaylist,
9 deleteVideoPlaylist,
10 flushAndRunMultipleServers,
11 getVideoPlaylistsList,
12 searchVideoPlaylists,
13 ServerInfo,
14 setAccessTokensToServers,
15 setDefaultVideoChannel,
16 uploadVideoAndGetId,
17 wait
18} from '../../../../shared/extra-utils'
19import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
20import { VideoPlaylist, VideoPlaylistPrivacy } from '../../../../shared/models/videos'
21
22const expect = chai.expect
23
24describe('Test ActivityPub playlists search', function () {
25 let servers: ServerInfo[]
26 let playlistServer1UUID: string
27 let playlistServer2UUID: string
28 let video2Server2: string
29
30 before(async function () {
31 this.timeout(120000)
32
33 servers = await flushAndRunMultipleServers(2)
34
35 await setAccessTokensToServers(servers)
36 await setDefaultVideoChannel(servers)
37
38 {
39 const video1 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 1' })).uuid
40 const video2 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 2' })).uuid
41
42 const attributes = {
43 displayName: 'playlist 1 on server 1',
44 privacy: VideoPlaylistPrivacy.PUBLIC,
45 videoChannelId: servers[0].videoChannel.id
46 }
47 const res = await createVideoPlaylist({ url: servers[0].url, token: servers[0].accessToken, playlistAttrs: attributes })
48 playlistServer1UUID = res.body.videoPlaylist.uuid
49
50 for (const videoId of [ video1, video2 ]) {
51 await addVideoInPlaylist({
52 url: servers[0].url,
53 token: servers[0].accessToken,
54 playlistId: playlistServer1UUID,
55 elementAttrs: { videoId }
56 })
57 }
58 }
59
60 {
61 const videoId = (await uploadVideoAndGetId({ server: servers[1], videoName: 'video 1' })).uuid
62 video2Server2 = (await uploadVideoAndGetId({ server: servers[1], videoName: 'video 2' })).uuid
63
64 const attributes = {
65 displayName: 'playlist 1 on server 2',
66 privacy: VideoPlaylistPrivacy.PUBLIC,
67 videoChannelId: servers[1].videoChannel.id
68 }
69 const res = await createVideoPlaylist({ url: servers[1].url, token: servers[1].accessToken, playlistAttrs: attributes })
70 playlistServer2UUID = res.body.videoPlaylist.uuid
71
72 await addVideoInPlaylist({
73 url: servers[1].url,
74 token: servers[1].accessToken,
75 playlistId: playlistServer2UUID,
76 elementAttrs: { videoId }
77 })
78 }
79
80 await waitJobs(servers)
81 })
82
83 it('Should not find a remote playlist', async function () {
84 {
85 const search = 'http://localhost:' + servers[1].port + '/video-playlists/43'
86 const res = await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
87
88 expect(res.body.total).to.equal(0)
89 expect(res.body.data).to.be.an('array')
90 expect(res.body.data).to.have.lengthOf(0)
91 }
92
93 {
94 // Without token
95 const search = 'http://localhost:' + servers[1].port + '/video-playlists/' + playlistServer2UUID
96 const res = await searchVideoPlaylists(servers[0].url, search)
97
98 expect(res.body.total).to.equal(0)
99 expect(res.body.data).to.be.an('array')
100 expect(res.body.data).to.have.lengthOf(0)
101 }
102 })
103
104 it('Should search a local playlist', async function () {
105 const search = 'http://localhost:' + servers[0].port + '/video-playlists/' + playlistServer1UUID
106 const res = await searchVideoPlaylists(servers[0].url, search)
107
108 expect(res.body.total).to.equal(1)
109 expect(res.body.data).to.be.an('array')
110 expect(res.body.data).to.have.lengthOf(1)
111 expect(res.body.data[0].displayName).to.equal('playlist 1 on server 1')
112 expect(res.body.data[0].videosLength).to.equal(2)
113 })
114
115 it('Should search a local playlist with an alternative URL', async function () {
116 const searches = [
117 'http://localhost:' + servers[0].port + '/videos/watch/playlist/' + playlistServer1UUID,
118 'http://localhost:' + servers[0].port + '/w/p/' + playlistServer1UUID
119 ]
120
121 for (const search of searches) {
122 for (const token of [ undefined, servers[0].accessToken ]) {
123 const res = await searchVideoPlaylists(servers[0].url, search, token)
124
125 expect(res.body.total).to.equal(1)
126 expect(res.body.data).to.be.an('array')
127 expect(res.body.data).to.have.lengthOf(1)
128 expect(res.body.data[0].displayName).to.equal('playlist 1 on server 1')
129 expect(res.body.data[0].videosLength).to.equal(2)
130 }
131 }
132 })
133
134 it('Should search a remote playlist', async function () {
135 const searches = [
136 'http://localhost:' + servers[1].port + '/video-playlists/' + playlistServer2UUID,
137 'http://localhost:' + servers[1].port + '/videos/watch/playlist/' + playlistServer2UUID,
138 'http://localhost:' + servers[1].port + '/w/p/' + playlistServer2UUID
139 ]
140
141 for (const search of searches) {
142 const res = await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
143
144 expect(res.body.total).to.equal(1)
145 expect(res.body.data).to.be.an('array')
146 expect(res.body.data).to.have.lengthOf(1)
147 expect(res.body.data[0].displayName).to.equal('playlist 1 on server 2')
148 expect(res.body.data[0].videosLength).to.equal(1)
149 }
150 })
151
152 it('Should not list this remote playlist', async function () {
153 const res = await getVideoPlaylistsList(servers[0].url, 0, 10)
154 expect(res.body.total).to.equal(1)
155 expect(res.body.data).to.have.lengthOf(1)
156 expect(res.body.data[0].displayName).to.equal('playlist 1 on server 1')
157 })
158
159 it('Should update the playlist of server 2, and refresh it on server 1', async function () {
160 this.timeout(60000)
161
162 await addVideoInPlaylist({
163 url: servers[1].url,
164 token: servers[1].accessToken,
165 playlistId: playlistServer2UUID,
166 elementAttrs: { videoId: video2Server2 }
167 })
168
169 await waitJobs(servers)
170 // Expire playlist
171 await wait(10000)
172
173 // Will run refresh async
174 const search = 'http://localhost:' + servers[1].port + '/video-playlists/' + playlistServer2UUID
175 await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
176
177 // Wait refresh
178 await wait(5000)
179
180 const res = await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
181 expect(res.body.total).to.equal(1)
182 expect(res.body.data).to.have.lengthOf(1)
183
184 const playlist: VideoPlaylist = res.body.data[0]
185 expect(playlist.videosLength).to.equal(2)
186 })
187
188 it('Should delete playlist of server 2, and delete it on server 1', async function () {
189 this.timeout(60000)
190
191 await deleteVideoPlaylist(servers[1].url, servers[1].accessToken, playlistServer2UUID)
192
193 await waitJobs(servers)
194 // Expiration
195 await wait(10000)
196
197 // Will run refresh async
198 const search = 'http://localhost:' + servers[1].port + '/video-playlists/' + playlistServer2UUID
199 await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
200
201 // Wait refresh
202 await wait(5000)
203
204 const res = await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
205 expect(res.body.total).to.equal(0)
206 expect(res.body.data).to.have.lengthOf(0)
207 })
208
209 after(async function () {
210 await cleanupTests(servers)
211 })
212})
diff --git a/server/tests/api/search/search-activitypub-videos.ts b/server/tests/api/search/search-activitypub-videos.ts
index c62dfca0d..e9b4978da 100644
--- a/server/tests/api/search/search-activitypub-videos.ts
+++ b/server/tests/api/search/search-activitypub-videos.ts
@@ -77,14 +77,33 @@ describe('Test ActivityPub videos search', function () {
77 expect(res.body.data[0].name).to.equal('video 1 on server 1') 77 expect(res.body.data[0].name).to.equal('video 1 on server 1')
78 }) 78 })
79 79
80 it('Should search a local video with an alternative URL', async function () {
81 const search = 'http://localhost:' + servers[0].port + '/w/' + videoServer1UUID
82 const res1 = await searchVideo(servers[0].url, search)
83 const res2 = await searchVideoWithToken(servers[0].url, search, servers[0].accessToken)
84
85 for (const res of [ res1, res2 ]) {
86 expect(res.body.total).to.equal(1)
87 expect(res.body.data).to.be.an('array')
88 expect(res.body.data).to.have.lengthOf(1)
89 expect(res.body.data[0].name).to.equal('video 1 on server 1')
90 }
91 })
92
80 it('Should search a remote video', async function () { 93 it('Should search a remote video', async function () {
81 const search = 'http://localhost:' + servers[1].port + '/videos/watch/' + videoServer2UUID 94 const searches = [
82 const res = await searchVideoWithToken(servers[0].url, search, servers[0].accessToken) 95 'http://localhost:' + servers[1].port + '/w/' + videoServer2UUID,
96 'http://localhost:' + servers[1].port + '/videos/watch/' + videoServer2UUID
97 ]
83 98
84 expect(res.body.total).to.equal(1) 99 for (const search of searches) {
85 expect(res.body.data).to.be.an('array') 100 const res = await searchVideoWithToken(servers[0].url, search, servers[0].accessToken)
86 expect(res.body.data).to.have.lengthOf(1) 101
87 expect(res.body.data[0].name).to.equal('video 1 on server 2') 102 expect(res.body.total).to.equal(1)
103 expect(res.body.data).to.be.an('array')
104 expect(res.body.data).to.have.lengthOf(1)
105 expect(res.body.data[0].name).to.equal('video 1 on server 2')
106 }
88 }) 107 })
89 108
90 it('Should not list this remote video', async function () { 109 it('Should not list this remote video', async function () {
@@ -95,7 +114,7 @@ describe('Test ActivityPub videos search', function () {
95 }) 114 })
96 115
97 it('Should update video of server 2, and refresh it on server 1', async function () { 116 it('Should update video of server 2, and refresh it on server 1', async function () {
98 this.timeout(60000) 117 this.timeout(120000)
99 118
100 const channelAttributes = { 119 const channelAttributes = {
101 name: 'super_channel', 120 name: 'super_channel',
@@ -134,7 +153,7 @@ describe('Test ActivityPub videos search', function () {
134 }) 153 })
135 154
136 it('Should delete video of server 2, and delete it on server 1', async function () { 155 it('Should delete video of server 2, and delete it on server 1', async function () {
137 this.timeout(60000) 156 this.timeout(120000)
138 157
139 await removeVideo(servers[1].url, servers[1].accessToken, videoServer2UUID) 158 await removeVideo(servers[1].url, servers[1].accessToken, videoServer2UUID)
140 159
diff --git a/server/tests/api/search/search-index.ts b/server/tests/api/search/search-index.ts
index 849a8a893..00f79232a 100644
--- a/server/tests/api/search/search-index.ts
+++ b/server/tests/api/search/search-index.ts
@@ -2,19 +2,21 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { advancedVideoChannelSearch, searchVideoChannel } from '@shared/extra-utils/search/video-channels'
6import { Video, VideoChannel, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType, VideosSearchQuery } from '@shared/models'
5import { 7import {
8 advancedVideoPlaylistSearch,
9 advancedVideosSearch,
6 cleanupTests, 10 cleanupTests,
7 flushAndRunServer, 11 flushAndRunServer,
12 immutableAssign,
8 searchVideo, 13 searchVideo,
14 searchVideoPlaylists,
9 ServerInfo, 15 ServerInfo,
10 setAccessTokensToServers, 16 setAccessTokensToServers,
11 updateCustomSubConfig, 17 updateCustomSubConfig,
12 uploadVideo, 18 uploadVideo
13 advancedVideosSearch,
14 immutableAssign
15} from '../../../../shared/extra-utils' 19} from '../../../../shared/extra-utils'
16import { searchVideoChannel, advancedVideoChannelSearch } from '@shared/extra-utils/search/video-channels'
17import { VideosSearchQuery, Video, VideoChannel } from '@shared/models'
18 20
19const expect = chai.expect 21const expect = chai.expect
20 22
@@ -277,6 +279,56 @@ describe('Test videos search', function () {
277 }) 279 })
278 }) 280 })
279 281
282 describe('Playlists search', async function () {
283
284 it('Should make a simple search and not have results', async function () {
285 const res = await searchVideoPlaylists(server.url, 'a'.repeat(500))
286
287 expect(res.body.total).to.equal(0)
288 expect(res.body.data).to.have.lengthOf(0)
289 })
290
291 it('Should make a search and have results', async function () {
292 const res = await advancedVideoPlaylistSearch(server.url, { search: 'E2E playlist', sort: '-match' })
293
294 expect(res.body.total).to.be.greaterThan(0)
295 expect(res.body.data).to.have.length.greaterThan(0)
296
297 const videoPlaylist: VideoPlaylist = res.body.data[0]
298
299 expect(videoPlaylist.url).to.equal('https://peertube2.cpy.re/videos/watch/playlist/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a')
300 expect(videoPlaylist.thumbnailUrl).to.exist
301 expect(videoPlaylist.embedUrl).to.equal('https://peertube2.cpy.re/video-playlists/embed/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a')
302
303 expect(videoPlaylist.type.id).to.equal(VideoPlaylistType.REGULAR)
304 expect(videoPlaylist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC)
305 expect(videoPlaylist.videosLength).to.exist
306
307 expect(videoPlaylist.createdAt).to.exist
308 expect(videoPlaylist.updatedAt).to.exist
309
310 expect(videoPlaylist.uuid).to.equal('73804a40-da9a-40c2-b1eb-2c6d9eec8f0a')
311 expect(videoPlaylist.displayName).to.exist
312
313 expect(videoPlaylist.ownerAccount.url).to.equal('https://peertube2.cpy.re/accounts/chocobozzz')
314 expect(videoPlaylist.ownerAccount.name).to.equal('chocobozzz')
315 expect(videoPlaylist.ownerAccount.host).to.equal('peertube2.cpy.re')
316 expect(videoPlaylist.ownerAccount.avatar).to.exist
317
318 expect(videoPlaylist.videoChannel.url).to.equal('https://peertube2.cpy.re/video-channels/chocobozzz_channel')
319 expect(videoPlaylist.videoChannel.name).to.equal('chocobozzz_channel')
320 expect(videoPlaylist.videoChannel.host).to.equal('peertube2.cpy.re')
321 expect(videoPlaylist.videoChannel.avatar).to.exist
322 })
323
324 it('Should have a correct pagination', async function () {
325 const res = await advancedVideoChannelSearch(server.url, { search: 'root', start: 0, count: 2 })
326
327 expect(res.body.total).to.be.greaterThan(2)
328 expect(res.body.data).to.have.lengthOf(2)
329 })
330 })
331
280 after(async function () { 332 after(async function () {
281 await cleanupTests([ server ]) 333 await cleanupTests([ server ])
282 }) 334 })
diff --git a/server/tests/api/search/search-playlists.ts b/server/tests/api/search/search-playlists.ts
new file mode 100644
index 000000000..ab17d55e9
--- /dev/null
+++ b/server/tests/api/search/search-playlists.ts
@@ -0,0 +1,128 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import { VideoPlaylist, VideoPlaylistPrivacy } from '@shared/models'
6import {
7 addVideoInPlaylist,
8 advancedVideoPlaylistSearch,
9 cleanupTests,
10 createVideoPlaylist,
11 flushAndRunServer,
12 searchVideoPlaylists,
13 ServerInfo,
14 setAccessTokensToServers,
15 setDefaultVideoChannel,
16 uploadVideoAndGetId
17} from '../../../../shared/extra-utils'
18
19const expect = chai.expect
20
21describe('Test playlists search', function () {
22 let server: ServerInfo = null
23
24 before(async function () {
25 this.timeout(30000)
26
27 server = await flushAndRunServer(1)
28
29 await setAccessTokensToServers([ server ])
30 await setDefaultVideoChannel([ server ])
31
32 const videoId = (await uploadVideoAndGetId({ server: server, videoName: 'video' })).uuid
33
34 {
35 const attributes = {
36 displayName: 'Dr. Kenzo Tenma hospital videos',
37 privacy: VideoPlaylistPrivacy.PUBLIC,
38 videoChannelId: server.videoChannel.id
39 }
40 const res = await createVideoPlaylist({ url: server.url, token: server.accessToken, playlistAttrs: attributes })
41
42 await addVideoInPlaylist({
43 url: server.url,
44 token: server.accessToken,
45 playlistId: res.body.videoPlaylist.id,
46 elementAttrs: { videoId }
47 })
48 }
49
50 {
51 const attributes = {
52 displayName: 'Johan & Anna Libert musics',
53 privacy: VideoPlaylistPrivacy.PUBLIC,
54 videoChannelId: server.videoChannel.id
55 }
56 const res = await createVideoPlaylist({ url: server.url, token: server.accessToken, playlistAttrs: attributes })
57
58 await addVideoInPlaylist({
59 url: server.url,
60 token: server.accessToken,
61 playlistId: res.body.videoPlaylist.id,
62 elementAttrs: { videoId }
63 })
64 }
65
66 {
67 const attributes = {
68 displayName: 'Inspector Lunge playlist',
69 privacy: VideoPlaylistPrivacy.PUBLIC,
70 videoChannelId: server.videoChannel.id
71 }
72 await createVideoPlaylist({ url: server.url, token: server.accessToken, playlistAttrs: attributes })
73 }
74 })
75
76 it('Should make a simple search and not have results', async function () {
77 const res = await searchVideoPlaylists(server.url, 'abc')
78
79 expect(res.body.total).to.equal(0)
80 expect(res.body.data).to.have.lengthOf(0)
81 })
82
83 it('Should make a search and have results', async function () {
84 {
85 const search = {
86 search: 'tenma',
87 start: 0,
88 count: 1
89 }
90 const res = await advancedVideoPlaylistSearch(server.url, search)
91 expect(res.body.total).to.equal(1)
92 expect(res.body.data).to.have.lengthOf(1)
93
94 const playlist: VideoPlaylist = res.body.data[0]
95 expect(playlist.displayName).to.equal('Dr. Kenzo Tenma hospital videos')
96 expect(playlist.url).to.equal(server.url + '/video-playlists/' + playlist.uuid)
97 }
98
99 {
100 const search = {
101 search: 'Anna Livert',
102 start: 0,
103 count: 1
104 }
105 const res = await advancedVideoPlaylistSearch(server.url, search)
106 expect(res.body.total).to.equal(1)
107 expect(res.body.data).to.have.lengthOf(1)
108
109 const playlist: VideoPlaylist = res.body.data[0]
110 expect(playlist.displayName).to.equal('Johan & Anna Libert musics')
111 }
112 })
113
114 it('Should not display playlists without videos', async function () {
115 const search = {
116 search: 'Lunge',
117 start: 0,
118 count: 1
119 }
120 const res = await advancedVideoPlaylistSearch(server.url, search)
121 expect(res.body.total).to.equal(0)
122 expect(res.body.data).to.have.lengthOf(0)
123 })
124
125 after(async function () {
126 await cleanupTests([ server ])
127 })
128})
diff --git a/server/tests/fixtures/peertube-plugin-test-two/languages/fr.json b/server/tests/fixtures/peertube-plugin-test-filter-translations/languages/fr.json
index 52d8313df..52d8313df 100644
--- a/server/tests/fixtures/peertube-plugin-test-two/languages/fr.json
+++ b/server/tests/fixtures/peertube-plugin-test-filter-translations/languages/fr.json
diff --git a/server/tests/fixtures/peertube-plugin-test-two/languages/it.json b/server/tests/fixtures/peertube-plugin-test-filter-translations/languages/it.json
index 9e187d83b..9e187d83b 100644
--- a/server/tests/fixtures/peertube-plugin-test-two/languages/it.json
+++ b/server/tests/fixtures/peertube-plugin-test-filter-translations/languages/it.json
diff --git a/server/tests/fixtures/peertube-plugin-test-two/main.js b/server/tests/fixtures/peertube-plugin-test-filter-translations/main.js
index 71c11b2ba..71c11b2ba 100644
--- a/server/tests/fixtures/peertube-plugin-test-two/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-filter-translations/main.js
diff --git a/server/tests/fixtures/peertube-plugin-test-two/package.json b/server/tests/fixtures/peertube-plugin-test-filter-translations/package.json
index 926f2d69b..2adce4743 100644
--- a/server/tests/fixtures/peertube-plugin-test-two/package.json
+++ b/server/tests/fixtures/peertube-plugin-test-filter-translations/package.json
@@ -1,7 +1,7 @@
1{ 1{
2 "name": "peertube-plugin-test-two", 2 "name": "peertube-plugin-test-filter-translations",
3 "version": "0.0.1", 3 "version": "0.0.1",
4 "description": "Plugin test 2", 4 "description": "Plugin test filter and translations",
5 "engine": { 5 "engine": {
6 "peertube": ">=1.3.0" 6 "peertube": ">=1.3.0"
7 }, 7 },
diff --git a/server/tests/fixtures/peertube-plugin-test/main.js b/server/tests/fixtures/peertube-plugin-test/main.js
index ee0bc39f3..5e922ad1f 100644
--- a/server/tests/fixtures/peertube-plugin-test/main.js
+++ b/server/tests/fixtures/peertube-plugin-test/main.js
@@ -241,6 +241,10 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
241 'filter:api.search.video-channels.local.list.result', 241 'filter:api.search.video-channels.local.list.result',
242 'filter:api.search.video-channels.index.list.params', 242 'filter:api.search.video-channels.index.list.params',
243 'filter:api.search.video-channels.index.list.result', 243 'filter:api.search.video-channels.index.list.result',
244 'filter:api.search.video-playlists.local.list.params',
245 'filter:api.search.video-playlists.local.list.result',
246 'filter:api.search.video-playlists.index.list.params',
247 'filter:api.search.video-playlists.index.list.result'
244 ] 248 ]
245 249
246 for (const h of searchHooks) { 250 for (const h of searchHooks) {
diff --git a/server/tests/plugins/filter-hooks.ts b/server/tests/plugins/filter-hooks.ts
index a947283c2..644b41dea 100644
--- a/server/tests/plugins/filter-hooks.ts
+++ b/server/tests/plugins/filter-hooks.ts
@@ -8,6 +8,7 @@ import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-code
8import { 8import {
9 addVideoCommentReply, 9 addVideoCommentReply,
10 addVideoCommentThread, 10 addVideoCommentThread,
11 advancedVideoPlaylistSearch,
11 advancedVideosSearch, 12 advancedVideosSearch,
12 createLive, 13 createLive,
13 createVideoPlaylist, 14 createVideoPlaylist,
@@ -71,7 +72,7 @@ describe('Test plugin filter hooks', function () {
71 await installPlugin({ 72 await installPlugin({
72 url: servers[0].url, 73 url: servers[0].url,
73 accessToken: servers[0].accessToken, 74 accessToken: servers[0].accessToken,
74 path: getPluginTestPath('-two') 75 path: getPluginTestPath('-filter-translations')
75 }) 76 })
76 77
77 for (let i = 0; i < 10; i++) { 78 for (let i = 0; i < 10; i++) {
@@ -525,6 +526,27 @@ describe('Test plugin filter hooks', function () {
525 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.params', 1) 526 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.params', 1)
526 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.result', 1) 527 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.result', 1)
527 }) 528 })
529
530 it('Should run filter:api.search.video-playlists.local.list.{params,result}', async function () {
531 await advancedVideoPlaylistSearch(servers[0].url, {
532 search: 'Sun Jian'
533 })
534
535 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.params', 1)
536 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.result', 1)
537 })
538
539 it('Should run filter:api.search.video-playlists.index.list.{params,result}', async function () {
540 await advancedVideoPlaylistSearch(servers[0].url, {
541 search: 'Sun Jian',
542 searchTarget: 'search-index'
543 })
544
545 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.params', 1)
546 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.result', 1)
547 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.index.list.params', 1)
548 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.index.list.result', 1)
549 })
528 }) 550 })
529 551
530 after(async function () { 552 after(async function () {
diff --git a/server/tests/plugins/translations.ts b/server/tests/plugins/translations.ts
index 8dc2043b8..9fd2ba1c5 100644
--- a/server/tests/plugins/translations.ts
+++ b/server/tests/plugins/translations.ts
@@ -31,7 +31,7 @@ describe('Test plugin translations', function () {
31 await installPlugin({ 31 await installPlugin({
32 url: server.url, 32 url: server.url,
33 accessToken: server.accessToken, 33 accessToken: server.accessToken,
34 path: getPluginTestPath('-two') 34 path: getPluginTestPath('-filter-translations')
35 }) 35 })
36 }) 36 })
37 37
@@ -48,7 +48,7 @@ describe('Test plugin translations', function () {
48 'peertube-plugin-test': { 48 'peertube-plugin-test': {
49 Hi: 'Coucou' 49 Hi: 'Coucou'
50 }, 50 },
51 'peertube-plugin-test-two': { 51 'peertube-plugin-test-filter-translations': {
52 'Hello world': 'Bonjour le monde' 52 'Hello world': 'Bonjour le monde'
53 } 53 }
54 }) 54 })
@@ -58,14 +58,14 @@ describe('Test plugin translations', function () {
58 const res = await getPluginTranslations({ url: server.url, locale: 'it-IT' }) 58 const res = await getPluginTranslations({ url: server.url, locale: 'it-IT' })
59 59
60 expect(res.body).to.deep.equal({ 60 expect(res.body).to.deep.equal({
61 'peertube-plugin-test-two': { 61 'peertube-plugin-test-filter-translations': {
62 'Hello world': 'Ciao, mondo!' 62 'Hello world': 'Ciao, mondo!'
63 } 63 }
64 }) 64 })
65 }) 65 })
66 66
67 it('Should remove the plugin and remove the locales', async function () { 67 it('Should remove the plugin and remove the locales', async function () {
68 await uninstallPlugin({ url: server.url, accessToken: server.accessToken, npmName: 'peertube-plugin-test-two' }) 68 await uninstallPlugin({ url: server.url, accessToken: server.accessToken, npmName: 'peertube-plugin-test-filter-translations' })
69 69
70 { 70 {
71 const res = await getPluginTranslations({ url: server.url, locale: 'fr-FR' }) 71 const res = await getPluginTranslations({ url: server.url, locale: 'fr-FR' })
diff --git a/server/types/models/video/video-playlist.ts b/server/types/models/video/video-playlist.ts
index 79e2daebf..2f9537cf5 100644
--- a/server/types/models/video/video-playlist.ts
+++ b/server/types/models/video/video-playlist.ts
@@ -69,7 +69,7 @@ export type MVideoPlaylistAccountChannelDefault =
69// With all associations 69// With all associations
70 70
71export type MVideoPlaylistFull = 71export type MVideoPlaylistFull =
72 MVideoPlaylist & 72 MVideoPlaylistVideosLength &
73 Use<'OwnerAccount', MAccountDefault> & 73 Use<'OwnerAccount', MAccountDefault> &
74 Use<'VideoChannel', MChannelDefault> & 74 Use<'VideoChannel', MChannelDefault> &
75 Use<'Thumbnail', MThumbnail> 75 Use<'Thumbnail', MThumbnail>
@@ -84,7 +84,7 @@ export type MVideoPlaylistAccountChannelSummary =
84 Use<'VideoChannel', MChannelSummary> 84 Use<'VideoChannel', MChannelSummary>
85 85
86export type MVideoPlaylistFullSummary = 86export type MVideoPlaylistFullSummary =
87 MVideoPlaylist & 87 MVideoPlaylistVideosLength &
88 Use<'Thumbnail', MThumbnail> & 88 Use<'Thumbnail', MThumbnail> &
89 Use<'OwnerAccount', MAccountSummary> & 89 Use<'OwnerAccount', MAccountSummary> &
90 Use<'VideoChannel', MChannelSummary> 90 Use<'VideoChannel', MChannelSummary>
diff --git a/shared/extra-utils/index.ts b/shared/extra-utils/index.ts
index 3bc09ead5..87ee8abba 100644
--- a/shared/extra-utils/index.ts
+++ b/shared/extra-utils/index.ts
@@ -19,6 +19,8 @@ export * from './plugins/mock-blocklist'
19export * from './requests/check-api-params' 19export * from './requests/check-api-params'
20export * from './requests/requests' 20export * from './requests/requests'
21 21
22export * from './search/video-channels'
23export * from './search/video-playlists'
22export * from './search/videos' 24export * from './search/videos'
23 25
24export * from './server/activitypub' 26export * from './server/activitypub'
diff --git a/shared/extra-utils/search/video-playlists.ts b/shared/extra-utils/search/video-playlists.ts
new file mode 100644
index 000000000..c22831df7
--- /dev/null
+++ b/shared/extra-utils/search/video-playlists.ts
@@ -0,0 +1,36 @@
1import { VideoPlaylistsSearchQuery } from '@shared/models'
2import { HttpStatusCode } from '../../core-utils/miscs/http-error-codes'
3import { makeGetRequest } from '../requests/requests'
4
5function searchVideoPlaylists (url: string, search: string, token?: string, statusCodeExpected = HttpStatusCode.OK_200) {
6 const path = '/api/v1/search/video-playlists'
7
8 return makeGetRequest({
9 url,
10 path,
11 query: {
12 sort: '-createdAt',
13 search
14 },
15 token,
16 statusCodeExpected
17 })
18}
19
20function advancedVideoPlaylistSearch (url: string, search: VideoPlaylistsSearchQuery) {
21 const path = '/api/v1/search/video-playlists'
22
23 return makeGetRequest({
24 url,
25 path,
26 query: search,
27 statusCodeExpected: HttpStatusCode.OK_200
28 })
29}
30
31// ---------------------------------------------------------------------------
32
33export {
34 searchVideoPlaylists,
35 advancedVideoPlaylistSearch
36}
diff --git a/shared/models/plugins/client/client-hook.model.ts b/shared/models/plugins/client/client-hook.model.ts
index 546866845..cedd1be61 100644
--- a/shared/models/plugins/client/client-hook.model.ts
+++ b/shared/models/plugins/client/client-hook.model.ts
@@ -37,9 +37,12 @@ export const clientFilterHookObject = {
37 // Filter params/result of the function that fetch videos according to the user search 37 // Filter params/result of the function that fetch videos according to the user search
38 'filter:api.search.videos.list.params': true, 38 'filter:api.search.videos.list.params': true,
39 'filter:api.search.videos.list.result': true, 39 'filter:api.search.videos.list.result': true,
40 // Filter params/result of the function that fetch video-channels according to the user search 40 // Filter params/result of the function that fetch video channels according to the user search
41 'filter:api.search.video-channels.list.params': true, 41 'filter:api.search.video-channels.list.params': true,
42 'filter:api.search.video-channels.list.result': true, 42 'filter:api.search.video-channels.list.result': true,
43 // Filter params/result of the function that fetch video playlists according to the user search
44 'filter:api.search.video-playlists.list.params': true,
45 'filter:api.search.video-playlists.list.result': true,
43 46
44 // Filter form 47 // Filter form
45 'filter:api.signup.registration.create.params': true, 48 'filter:api.signup.registration.create.params': true,
diff --git a/shared/models/plugins/server/server-hook.model.ts b/shared/models/plugins/server/server-hook.model.ts
index 88277af5a..dae243dbf 100644
--- a/shared/models/plugins/server/server-hook.model.ts
+++ b/shared/models/plugins/server/server-hook.model.ts
@@ -27,6 +27,10 @@ export const serverFilterHookObject = {
27 'filter:api.search.video-channels.local.list.result': true, 27 'filter:api.search.video-channels.local.list.result': true,
28 'filter:api.search.video-channels.index.list.params': true, 28 'filter:api.search.video-channels.index.list.params': true,
29 'filter:api.search.video-channels.index.list.result': true, 29 'filter:api.search.video-channels.index.list.result': true,
30 'filter:api.search.video-playlists.local.list.params': true,
31 'filter:api.search.video-playlists.local.list.result': true,
32 'filter:api.search.video-playlists.index.list.params': true,
33 'filter:api.search.video-playlists.index.list.result': true,
30 34
31 // Filter the result of the get function 35 // Filter the result of the get function
32 // Used to get detailed video information (video watch page for example) 36 // Used to get detailed video information (video watch page for example)
diff --git a/shared/models/search/index.ts b/shared/models/search/index.ts
index 697ceccb1..50aeeddc8 100644
--- a/shared/models/search/index.ts
+++ b/shared/models/search/index.ts
@@ -1,5 +1,6 @@
1export * from './boolean-both-query.model' 1export * from './boolean-both-query.model'
2export * from './search-target-query.model' 2export * from './search-target-query.model'
3export * from './videos-common-query.model' 3export * from './videos-common-query.model'
4export * from './videos-search-query.model'
5export * from './video-channels-search-query.model' 4export * from './video-channels-search-query.model'
5export * from './video-playlists-search-query.model'
6export * from './videos-search-query.model'
diff --git a/shared/models/search/video-channels-search-query.model.ts b/shared/models/search/video-channels-search-query.model.ts
index c96aa8c1d..8f93c4bd5 100644
--- a/shared/models/search/video-channels-search-query.model.ts
+++ b/shared/models/search/video-channels-search-query.model.ts
@@ -1,4 +1,4 @@
1import { SearchTargetQuery } from "./search-target-query.model" 1import { SearchTargetQuery } from './search-target-query.model'
2 2
3export interface VideoChannelsSearchQuery extends SearchTargetQuery { 3export interface VideoChannelsSearchQuery extends SearchTargetQuery {
4 search: string 4 search: string
diff --git a/shared/models/search/video-playlists-search-query.model.ts b/shared/models/search/video-playlists-search-query.model.ts
new file mode 100644
index 000000000..31f05218e
--- /dev/null
+++ b/shared/models/search/video-playlists-search-query.model.ts
@@ -0,0 +1,9 @@
1import { SearchTargetQuery } from './search-target-query.model'
2
3export interface VideoPlaylistsSearchQuery extends SearchTargetQuery {
4 search: string
5
6 start?: number
7 count?: number
8 sort?: string
9}
diff --git a/shared/models/server/job.model.ts b/shared/models/server/job.model.ts
index e4acfee8d..4ab249e0b 100644
--- a/shared/models/server/job.model.ts
+++ b/shared/models/server/job.model.ts
@@ -53,7 +53,6 @@ export type ActivitypubHttpFetcherPayload = {
53 uri: string 53 uri: string
54 type: FetchType 54 type: FetchType
55 videoId?: number 55 videoId?: number
56 accountId?: number
57} 56}
58 57
59export type ActivitypubHttpUnicastPayload = { 58export type ActivitypubHttpUnicastPayload = {
diff --git a/shared/models/server/peertube-problem-document.model.ts b/shared/models/server/peertube-problem-document.model.ts
index 5e1c320f3..e391d5aad 100644
--- a/shared/models/server/peertube-problem-document.model.ts
+++ b/shared/models/server/peertube-problem-document.model.ts
@@ -1,4 +1,4 @@
1import { HttpStatusCode } from '@shared/core-utils' 1import { HttpStatusCode } from '../../core-utils'
2import { OAuth2ErrorCode, ServerErrorCode } from './server-error-code.enum' 2import { OAuth2ErrorCode, ServerErrorCode } from './server-error-code.enum'
3 3
4export interface PeerTubeProblemDocumentData { 4export interface PeerTubeProblemDocumentData {
diff --git a/shared/models/videos/playlist/video-playlist.model.ts b/shared/models/videos/playlist/video-playlist.model.ts
index f45d0ff88..ab4171ad1 100644
--- a/shared/models/videos/playlist/video-playlist.model.ts
+++ b/shared/models/videos/playlist/video-playlist.model.ts
@@ -8,17 +8,21 @@ export interface VideoPlaylist {
8 uuid: string 8 uuid: string
9 isLocal: boolean 9 isLocal: boolean
10 10
11 url: string
12
11 displayName: string 13 displayName: string
12 description: string 14 description: string
13 privacy: VideoConstant<VideoPlaylistPrivacy> 15 privacy: VideoConstant<VideoPlaylistPrivacy>
14 16
15 thumbnailPath: string 17 thumbnailPath: string
18 thumbnailUrl?: string
16 19
17 videosLength: number 20 videosLength: number
18 21
19 type: VideoConstant<VideoPlaylistType> 22 type: VideoConstant<VideoPlaylistType>
20 23
21 embedPath: string 24 embedPath: string
25 embedUrl?: string
22 26
23 createdAt: Date | string 27 createdAt: Date | string
24 updatedAt: Date | string 28 updatedAt: Date | string
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index b43b6bfa0..ada4f1b7b 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -3584,6 +3584,47 @@ paths:
3584 '500': 3584 '500':
3585 description: search index unavailable 3585 description: search index unavailable
3586 3586
3587 /search/video-playlists:
3588 get:
3589 tags:
3590 - Search
3591 summary: Search playlists
3592 operationId: searchPlaylists
3593 parameters:
3594 - name: search
3595 in: query
3596 required: true
3597 description: >
3598 String to search. If the user can make a remote URI search, and the string is an URI then the
3599 PeerTube instance will fetch the remote object and add it to its database. Then,
3600 you can use the REST API to fetch the complete playlist information and interact with it.
3601 schema:
3602 type: string
3603 - $ref: '#/components/parameters/start'
3604 - $ref: '#/components/parameters/count'
3605 - $ref: '#/components/parameters/searchTarget'
3606 - $ref: '#/components/parameters/sort'
3607 callbacks:
3608 'searchTarget === search-index':
3609 $ref: '#/components/callbacks/searchIndex'
3610 responses:
3611 '200':
3612 description: successful operation
3613 content:
3614 application/json:
3615 schema:
3616 type: object
3617 properties:
3618 total:
3619 type: integer
3620 example: 1
3621 data:
3622 type: array
3623 items:
3624 $ref: '#/components/schemas/VideoPlaylist'
3625 '500':
3626 description: search index unavailable
3627
3587 /server/blocklist/accounts: 3628 /server/blocklist/accounts:
3588 get: 3629 get:
3589 tags: 3630 tags: