diff options
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 @@ | |||
1 | import { map } from 'rxjs/operators' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router' | ||
4 | import { SearchService } from '@app/shared/shared-search' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { RouterModule, Routes } from '@angular/router' | 2 | import { RouterModule, Routes } from '@angular/router' |
3 | import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver' | ||
4 | import { SearchComponent } from './search.component' | 3 | import { SearchComponent } from './search.component' |
5 | import { VideoLazyLoadResolver } from './video-lazy-load.resolver' | 4 | import { ChannelLazyLoadResolver, PlaylistLazyLoadResolver, VideoLazyLoadResolver } from './shared' |
6 | 5 | ||
7 | const searchRoutes: Routes = [ | 6 | const 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 @@ | |||
1 | import { forkJoin, of, Subscription } from 'rxjs' | 1 | import { forkJoin, of, Subscription } from 'rxjs' |
2 | import { LinkType } from 'src/types/link.type' | ||
2 | import { Component, OnDestroy, OnInit } from '@angular/core' | 3 | import { Component, OnDestroy, OnInit } from '@angular/core' |
3 | import { ActivatedRoute, Router } from '@angular/router' | 4 | import { ActivatedRoute, Router } from '@angular/router' |
4 | import { AuthService, ComponentPagination, HooksService, MetaService, Notifier, ServerService, User, UserService } from '@app/core' | 5 | import { AuthService, HooksService, MetaService, Notifier, ServerService, User, UserService } from '@app/core' |
5 | import { immutableAssign } from '@app/helpers' | 6 | import { immutableAssign } from '@app/helpers' |
6 | import { Video, VideoChannel } from '@app/shared/shared-main' | 7 | import { Video, VideoChannel } from '@app/shared/shared-main' |
7 | import { AdvancedSearch, SearchService } from '@app/shared/shared-search' | 8 | import { AdvancedSearch, SearchService } from '@app/shared/shared-search' |
8 | import { MiniatureDisplayOptions, VideoLinkType } from '@app/shared/shared-video-miniature' | 9 | import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature' |
10 | import { VideoPlaylist } from '@app/shared/shared-video-playlist' | ||
9 | import { HTMLServerConfig, SearchTargetType } from '@shared/models' | 11 | import { HTMLServerConfig, SearchTargetType } from '@shared/models' |
10 | 12 | ||
11 | @Component({ | 13 | @Component({ |
@@ -16,10 +18,9 @@ import { HTMLServerConfig, SearchTargetType } from '@shared/models' | |||
16 | export class SearchComponent implements OnInit, OnDestroy { | 18 | export 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' | |||
5 | import { SharedSearchModule } from '@app/shared/shared-search' | 5 | import { SharedSearchModule } from '@app/shared/shared-search' |
6 | import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' | 6 | import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' |
7 | import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' | 7 | import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' |
8 | import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist' | ||
8 | import { SearchService } from '../shared/shared-search/search.service' | 9 | import { SearchService } from '../shared/shared-search/search.service' |
9 | import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver' | ||
10 | import { SearchFiltersComponent } from './search-filters.component' | 10 | import { SearchFiltersComponent } from './search-filters.component' |
11 | import { SearchRoutingModule } from './search-routing.module' | 11 | import { SearchRoutingModule } from './search-routing.module' |
12 | import { SearchComponent } from './search.component' | 12 | import { SearchComponent } from './search.component' |
13 | import { VideoLazyLoadResolver } from './video-lazy-load.resolver' | 13 | import { 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 | }) |
42 | export class SearchModule { } | 44 | export 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 @@ | |||
1 | import { Observable } from 'rxjs' | ||
1 | import { map } from 'rxjs/operators' | 2 | import { map } from 'rxjs/operators' |
2 | import { Injectable } from '@angular/core' | ||
3 | import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router' | 3 | import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router' |
4 | import { SearchService } from '@app/shared/shared-search' | 4 | import { ResultList } from '@shared/models/result-list.model' |
5 | 5 | ||
6 | @Injectable() | 6 | export abstract class AbstractLazyLoadResolver <T> implements Resolve<any> { |
7 | export 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 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { Router } from '@angular/router' | ||
3 | import { VideoChannel } from '@app/shared/shared-main' | ||
4 | import { SearchService } from '@app/shared/shared-search' | ||
5 | import { AbstractLazyLoadResolver } from './abstract-lazy-load.resolver' | ||
6 | |||
7 | @Injectable() | ||
8 | export 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 @@ | |||
1 | export * from './abstract-lazy-load.resolver' | ||
2 | export * from './channel-lazy-load.resolver' | ||
3 | export * from './playlist-lazy-load.resolver' | ||
4 | export * 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 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { Router } from '@angular/router' | ||
3 | import { SearchService } from '@app/shared/shared-search' | ||
4 | import { VideoPlaylist } from '@app/shared/shared-video-playlist' | ||
5 | import { AbstractLazyLoadResolver } from './abstract-lazy-load.resolver' | ||
6 | |||
7 | @Injectable() | ||
8 | export 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 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { Router } from '@angular/router' | ||
3 | import { Video } from '@app/shared/shared-main' | ||
4 | import { SearchService } from '@app/shared/shared-search' | ||
5 | import { AbstractLazyLoadResolver } from './abstract-lazy-load.resolver' | ||
6 | |||
7 | @Injectable() | ||
8 | export 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' | |||
3 | export * from './duration-formatter.pipe' | 3 | export * from './duration-formatter.pipe' |
4 | export * from './from-now.pipe' | 4 | export * from './from-now.pipe' |
5 | export * from './infinite-scroller.directive' | 5 | export * from './infinite-scroller.directive' |
6 | export * from './link.component' | ||
6 | export * from './number-formatter.pipe' | 7 | export * from './number-formatter.pipe' |
7 | export * from './peertube-template.directive' | 8 | export * 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 @@ | |||
1 | a { | ||
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 @@ | |||
1 | import { Component, Input, ViewEncapsulation } from '@angular/core' | ||
2 | |||
3 | @Component({ | ||
4 | selector: 'my-link', | ||
5 | styleUrls: [ './link.component.scss' ], | ||
6 | templateUrl: './link.component.html' | ||
7 | }) | ||
8 | export 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' | |||
4 | import { HttpClientModule } from '@angular/common/http' | 4 | import { HttpClientModule } from '@angular/common/http' |
5 | import { NgModule } from '@angular/core' | 5 | import { NgModule } from '@angular/core' |
6 | import { FormsModule, ReactiveFormsModule } from '@angular/forms' | 6 | import { FormsModule, ReactiveFormsModule } from '@angular/forms' |
7 | import { ActivatedRouteSnapshot, RouterModule } from '@angular/router' | 7 | import { RouterModule } from '@angular/router' |
8 | import { | 8 | import { |
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' | |||
35 | import { LoaderComponent, SmallLoaderComponent } from './loaders' | 36 | import { LoaderComponent, SmallLoaderComponent } from './loaders' |
36 | import { HelpComponent, ListOverflowComponent, SimpleSearchInputComponent, TopMenuDropdownComponent } from './misc' | 37 | import { HelpComponent, ListOverflowComponent, SimpleSearchInputComponent, TopMenuDropdownComponent } from './misc' |
37 | import { PluginPlaceholderComponent } from './plugins' | 38 | import { PluginPlaceholderComponent } from './plugins' |
39 | import { ActorRedirectGuard } from './router' | ||
38 | import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' | 40 | import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' |
39 | import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video' | 41 | import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video' |
40 | import { VideoCaptionService } from './video-caption' | 42 | import { VideoCaptionService } from './video-caption' |
41 | import { VideoChannelService } from './video-channel' | 43 | import { VideoChannelService } from './video-channel' |
42 | import { 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' | |||
3 | import { HttpClient, HttpParams } from '@angular/common/http' | 3 | import { HttpClient, HttpParams } from '@angular/common/http' |
4 | import { Injectable } from '@angular/core' | 4 | import { Injectable } from '@angular/core' |
5 | import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core' | 5 | import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core' |
6 | import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage' | ||
7 | import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' | 6 | import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' |
8 | import { ResultList, SearchTargetType, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '@shared/models' | 7 | import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage' |
8 | import { | ||
9 | ResultList, | ||
10 | SearchTargetType, | ||
11 | Video as VideoServerModel, | ||
12 | VideoChannel as VideoChannelServerModel, | ||
13 | VideoPlaylist as VideoPlaylistServerModel | ||
14 | } from '@shared/models' | ||
9 | import { environment } from '../../../environments/environment' | 15 | import { environment } from '../../../environments/environment' |
16 | import { VideoPlaylist, VideoPlaylistService } from '../shared-video-playlist' | ||
10 | import { AdvancedSearch } from './advanced-search.model' | 17 | import { 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' |
13 | import { AuthService, ScreenService, ServerService, User } from '@app/core' | 13 | import { AuthService, ScreenService, ServerService, User } from '@app/core' |
14 | import { HTMLServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '@shared/models' | 14 | import { HTMLServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '@shared/models' |
15 | import { LinkType } from '../../../types/link.type' | ||
15 | import { ActorAvatarSize } from '../shared-actor-image/actor-avatar.component' | 16 | import { ActorAvatarSize } from '../shared-actor-image/actor-avatar.component' |
16 | import { Video } from '../shared-main' | 17 | import { Video } from '../shared-main' |
17 | import { VideoPlaylistService } from '../shared-video-playlist' | 18 | import { 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 | } |
31 | export 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 @@ | |||
1 | import { Component, Input } from '@angular/core' | 1 | import { LinkType } from 'src/types/link.type' |
2 | import { Component, Input, OnInit } from '@angular/core' | ||
2 | import { VideoPlaylist } from './video-playlist.model' | 3 | import { 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 | }) |
9 | export class VideoPlaylistMiniatureComponent { | 10 | export 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 @@ | |||
1 | import { getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers' | 1 | import { getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers' |
2 | import { Account, Actor, VideoChannel } from '@app/shared/shared-main' | 2 | import { Actor } from '@app/shared/shared-main' |
3 | import { peertubeTranslate } from '@shared/core-utils/i18n' | 3 | import { peertubeTranslate } from '@shared/core-utils/i18n' |
4 | import { | 4 | import { |
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 | ||
158 | activityPubClientRouter.get('/video-playlists/:playlistId', | 158 | activityPubClientRouter.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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { sanitizeUrl } from '@server/helpers/core-utils' | ||
3 | import { doJSONRequest } from '@server/helpers/requests' | ||
4 | import { CONFIG } from '@server/initializers/config' | ||
5 | import { getOrCreateAPVideo } from '@server/lib/activitypub/videos' | ||
6 | import { Hooks } from '@server/lib/plugins/hooks' | ||
7 | import { AccountBlocklistModel } from '@server/models/account/account-blocklist' | ||
8 | import { getServerActor } from '@server/models/application/application' | ||
9 | import { ServerBlocklistModel } from '@server/models/server/server-blocklist' | ||
10 | import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' | ||
11 | import { ResultList, Video, VideoChannel } from '@shared/models' | ||
12 | import { SearchTargetQuery } from '@shared/models/search/search-target-query.model' | ||
13 | import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search' | ||
14 | import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' | ||
15 | import { logger } from '../../helpers/logger' | ||
16 | import { getFormattedObjects } from '../../helpers/utils' | ||
17 | import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../lib/activitypub/actors' | ||
18 | import { | ||
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' | ||
31 | import { VideoModel } from '../../models/video/video' | ||
32 | import { VideoChannelModel } from '../../models/video/video-channel' | ||
33 | import { MChannelAccountDefault, MVideoAccountLightBlacklistAllFiles } from '../../types/models' | ||
34 | |||
35 | const searchRouter = express.Router() | ||
36 | |||
37 | searchRouter.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 | |||
49 | searchRouter.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 | |||
62 | export { searchRouter } | ||
63 | |||
64 | // --------------------------------------------------------------------------- | ||
65 | |||
66 | function 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 | |||
90 | async 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 | |||
114 | async 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 | |||
134 | async 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 | |||
165 | function 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 | |||
180 | async 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 | |||
217 | async 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 | |||
234 | async 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 | |||
264 | function 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 | |||
277 | async 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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { searchChannelsRouter } from './search-video-channels' | ||
3 | import { searchPlaylistsRouter } from './search-video-playlists' | ||
4 | import { searchVideosRouter } from './search-videos' | ||
5 | |||
6 | const searchRouter = express.Router() | ||
7 | |||
8 | searchRouter.use('/', searchVideosRouter) | ||
9 | searchRouter.use('/', searchChannelsRouter) | ||
10 | searchRouter.use('/', searchPlaylistsRouter) | ||
11 | |||
12 | // --------------------------------------------------------------------------- | ||
13 | |||
14 | export { | ||
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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { sanitizeUrl } from '@server/helpers/core-utils' | ||
3 | import { doJSONRequest } from '@server/helpers/requests' | ||
4 | import { CONFIG } from '@server/initializers/config' | ||
5 | import { WEBSERVER } from '@server/initializers/constants' | ||
6 | import { Hooks } from '@server/lib/plugins/hooks' | ||
7 | import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' | ||
8 | import { getServerActor } from '@server/models/application/application' | ||
9 | import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' | ||
10 | import { ResultList, VideoChannel } from '@shared/models' | ||
11 | import { VideoChannelsSearchQuery } from '../../../../shared/models/search' | ||
12 | import { isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils' | ||
13 | import { logger } from '../../../helpers/logger' | ||
14 | import { getFormattedObjects } from '../../../helpers/utils' | ||
15 | import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../../lib/activitypub/actors' | ||
16 | import { | ||
17 | asyncMiddleware, | ||
18 | openapiOperationDoc, | ||
19 | optionalAuthenticate, | ||
20 | paginationValidator, | ||
21 | setDefaultPagination, | ||
22 | setDefaultSearchSort, | ||
23 | videoChannelsListSearchValidator, | ||
24 | videoChannelsSearchSortValidator | ||
25 | } from '../../../middlewares' | ||
26 | import { VideoChannelModel } from '../../../models/video/video-channel' | ||
27 | import { MChannelAccountDefault } from '../../../types/models' | ||
28 | |||
29 | const searchChannelsRouter = express.Router() | ||
30 | |||
31 | searchChannelsRouter.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 | |||
44 | export { searchChannelsRouter } | ||
45 | |||
46 | // --------------------------------------------------------------------------- | ||
47 | |||
48 | function 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 | |||
70 | async 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 | |||
94 | async 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 | |||
114 | async 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 | |||
145 | function 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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { sanitizeUrl } from '@server/helpers/core-utils' | ||
3 | import { isUserAbleToSearchRemoteURI } from '@server/helpers/express-utils' | ||
4 | import { logger } from '@server/helpers/logger' | ||
5 | import { doJSONRequest } from '@server/helpers/requests' | ||
6 | import { getFormattedObjects } from '@server/helpers/utils' | ||
7 | import { CONFIG } from '@server/initializers/config' | ||
8 | import { getOrCreateAPVideoPlaylist } from '@server/lib/activitypub/playlists/get' | ||
9 | import { Hooks } from '@server/lib/plugins/hooks' | ||
10 | import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' | ||
11 | import { getServerActor } from '@server/models/application/application' | ||
12 | import { VideoPlaylistModel } from '@server/models/video/video-playlist' | ||
13 | import { MVideoPlaylistFullSummary } from '@server/types/models' | ||
14 | import { HttpStatusCode } from '@shared/core-utils' | ||
15 | import { ResultList, VideoPlaylist, VideoPlaylistsSearchQuery } from '@shared/models' | ||
16 | import { | ||
17 | asyncMiddleware, | ||
18 | openapiOperationDoc, | ||
19 | optionalAuthenticate, | ||
20 | paginationValidator, | ||
21 | setDefaultPagination, | ||
22 | setDefaultSearchSort, | ||
23 | videoPlaylistsListSearchValidator, | ||
24 | videoPlaylistsSearchSortValidator | ||
25 | } from '../../../middlewares' | ||
26 | import { WEBSERVER } from '@server/initializers/constants' | ||
27 | |||
28 | const searchPlaylistsRouter = express.Router() | ||
29 | |||
30 | searchPlaylistsRouter.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 | |||
43 | export { searchPlaylistsRouter } | ||
44 | |||
45 | // --------------------------------------------------------------------------- | ||
46 | |||
47 | function 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 | |||
60 | async 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 | |||
84 | async 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 | |||
104 | async 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 | |||
123 | function 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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { sanitizeUrl } from '@server/helpers/core-utils' | ||
3 | import { doJSONRequest } from '@server/helpers/requests' | ||
4 | import { CONFIG } from '@server/initializers/config' | ||
5 | import { WEBSERVER } from '@server/initializers/constants' | ||
6 | import { getOrCreateAPVideo } from '@server/lib/activitypub/videos' | ||
7 | import { Hooks } from '@server/lib/plugins/hooks' | ||
8 | import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' | ||
9 | import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' | ||
10 | import { ResultList, Video } from '@shared/models' | ||
11 | import { VideosSearchQuery } from '../../../../shared/models/search' | ||
12 | import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils' | ||
13 | import { logger } from '../../../helpers/logger' | ||
14 | import { getFormattedObjects } from '../../../helpers/utils' | ||
15 | import { | ||
16 | asyncMiddleware, | ||
17 | commonVideosFiltersValidator, | ||
18 | openapiOperationDoc, | ||
19 | optionalAuthenticate, | ||
20 | paginationValidator, | ||
21 | setDefaultPagination, | ||
22 | setDefaultSearchSort, | ||
23 | videosSearchSortValidator, | ||
24 | videosSearchValidator | ||
25 | } from '../../../middlewares' | ||
26 | import { VideoModel } from '../../../models/video/video' | ||
27 | import { MVideoAccountLightBlacklistAllFiles } from '../../../types/models' | ||
28 | |||
29 | const searchVideosRouter = express.Router() | ||
30 | |||
31 | searchVideosRouter.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 | |||
45 | export { searchVideosRouter } | ||
46 | |||
47 | // --------------------------------------------------------------------------- | ||
48 | |||
49 | function 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 | |||
64 | async 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 | |||
101 | async 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 | |||
118 | async 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 | |||
148 | function 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' |
35 | import { videoChannelsNameWithHostValidator, videoChannelsOwnSearchValidator, videosSortValidator } from '../../middlewares/validators' | 35 | import { videoChannelsListValidator, videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators' |
36 | import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image' | 36 | import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image' |
37 | import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' | 37 | import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' |
38 | import { AccountModel } from '../../models/account/account' | 38 | import { 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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { join } from 'path' | 2 | import { join } from 'path' |
3 | import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists' | ||
3 | import { getServerActor } from '@server/models/application/application' | 4 | import { getServerActor } from '@server/models/application/application' |
4 | import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models' | 5 | import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models' |
6 | import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' | ||
5 | import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model' | 7 | import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model' |
6 | import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model' | 8 | import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model' |
7 | import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model' | 9 | import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model' |
@@ -17,7 +19,6 @@ import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constant | |||
17 | import { sequelizeTypescript } from '../../initializers/database' | 19 | import { sequelizeTypescript } from '../../initializers/database' |
18 | import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send' | 20 | import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send' |
19 | import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url' | 21 | import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url' |
20 | import { JobQueue } from '../../lib/job-queue' | ||
21 | import { updatePlaylistMiniatureFromExisting } from '../../lib/thumbnail' | 22 | import { updatePlaylistMiniatureFromExisting } from '../../lib/thumbnail' |
22 | import { | 23 | import { |
23 | asyncMiddleware, | 24 | asyncMiddleware, |
@@ -42,7 +43,6 @@ import { | |||
42 | import { AccountModel } from '../../models/account/account' | 43 | import { AccountModel } from '../../models/account/account' |
43 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | 44 | import { VideoPlaylistModel } from '../../models/video/video-playlist' |
44 | import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' | 45 | import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' |
45 | import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' | ||
46 | 46 | ||
47 | const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR }) | 47 | const 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) | |||
144 | function getVideoPlaylist (req: express.Request, res: express.Response) { | 144 | function 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 @@ | |||
1 | import { exists, isDateValid } from '../misc' | ||
2 | import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object' | ||
3 | import validator from 'validator' | 1 | import validator from 'validator' |
4 | import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object' | 2 | import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object' |
3 | import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object' | ||
4 | import { exists, isDateValid, isUUIDValid } from '../misc' | ||
5 | import { isVideoPlaylistNameValid } from '../video-playlists' | ||
5 | import { isActivityPubUrlValid } from './misc' | 6 | import { isActivityPubUrlValid } from './misc' |
6 | 7 | ||
7 | function isPlaylistObjectValid (object: PlaylistObject) { | 8 | function 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 | |||
116 | async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) { | 116 | async 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 @@ | |||
1 | import * as Bluebird from 'bluebird' | ||
2 | import { getAPId } from '@server/helpers/activitypub' | ||
1 | import { isArray } from '@server/helpers/custom-validators/misc' | 3 | import { isArray } from '@server/helpers/custom-validators/misc' |
2 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | 4 | import { logger, loggerTagsFactory } from '@server/helpers/logger' |
3 | import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants' | 5 | import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants' |
@@ -6,7 +8,7 @@ import { updatePlaylistMiniatureFromUrl } from '@server/lib/thumbnail' | |||
6 | import { VideoPlaylistModel } from '@server/models/video/video-playlist' | 8 | import { VideoPlaylistModel } from '@server/models/video/video-playlist' |
7 | import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' | 9 | import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' |
8 | import { FilteredModelAttributes } from '@server/types' | 10 | import { FilteredModelAttributes } from '@server/types' |
9 | import { MAccountDefault, MAccountId, MThumbnail, MVideoPlaylist, MVideoPlaylistFull } from '@server/types/models' | 11 | import { MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models' |
10 | import { AttributesOnly } from '@shared/core-utils' | 12 | import { AttributesOnly } from '@shared/core-utils' |
11 | import { PlaylistObject } from '@shared/models' | 13 | import { PlaylistObject } from '@shared/models' |
12 | import { getOrCreateAPActor } from '../actors' | 14 | import { getOrCreateAPActor } from '../actors' |
@@ -19,11 +21,9 @@ import { | |||
19 | playlistObjectToDBAttributes | 21 | playlistObjectToDBAttributes |
20 | } from './shared' | 22 | } from './shared' |
21 | 23 | ||
22 | import Bluebird = require('bluebird') | ||
23 | |||
24 | const lTags = loggerTagsFactory('ap', 'video-playlist') | 24 | const lTags = loggerTagsFactory('ap', 'video-playlist') |
25 | 25 | ||
26 | async function createAccountPlaylists (playlistUrls: string[], account: MAccountDefault) { | 26 | async 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 | ||
45 | async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) { | 45 | async 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 | ||
71 | async function setVideoChannelIfNeeded (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) { | 74 | async 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 | ||
84 | async function fetchElementUrls (playlistObject: PlaylistObject) { | 90 | async 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 | ||
134 | async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) { | 140 | async 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 @@ | |||
1 | import { getAPId } from '@server/helpers/activitypub' | ||
2 | import { VideoPlaylistModel } from '@server/models/video/video-playlist' | ||
3 | import { MVideoPlaylistFullSummary } from '@server/types/models' | ||
4 | import { APObject } from '@shared/models' | ||
5 | import { createOrUpdateVideoPlaylist } from './create-update' | ||
6 | import { scheduleRefreshIfNeeded } from './refresh' | ||
7 | import { fetchRemoteVideoPlaylist } from './shared' | ||
8 | |||
9 | async 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 | |||
33 | export { | ||
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 @@ | |||
1 | export * from './get' | ||
1 | export * from './create-update' | 2 | export * from './create-update' |
2 | export * from './refresh' | 3 | export * 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 @@ | |||
1 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | 1 | import { logger, loggerTagsFactory } from '@server/helpers/logger' |
2 | import { PeerTubeRequestError } from '@server/helpers/requests' | 2 | import { PeerTubeRequestError } from '@server/helpers/requests' |
3 | import { MVideoPlaylistOwner } from '@server/types/models' | 3 | import { JobQueue } from '@server/lib/job-queue' |
4 | import { MVideoPlaylist, MVideoPlaylistOwner } from '@server/types/models' | ||
4 | import { HttpStatusCode } from '@shared/core-utils' | 5 | import { HttpStatusCode } from '@shared/core-utils' |
5 | import { createOrUpdateVideoPlaylist } from './create-update' | 6 | import { createOrUpdateVideoPlaylist } from './create-update' |
6 | import { fetchRemoteVideoPlaylist } from './shared' | 7 | import { fetchRemoteVideoPlaylist } from './shared' |
7 | 8 | ||
9 | function 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 | |||
8 | async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> { | 15 | async 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 | ||
44 | export { | 50 | export { |
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 @@ | |||
1 | import { ACTIVITY_PUB } from '@server/initializers/constants' | 1 | import { ACTIVITY_PUB } from '@server/initializers/constants' |
2 | import { VideoPlaylistModel } from '@server/models/video/video-playlist' | 2 | import { VideoPlaylistModel } from '@server/models/video/video-playlist' |
3 | import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' | 3 | import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' |
4 | import { MAccountId, MVideoId, MVideoPlaylistId } from '@server/types/models' | 4 | import { MVideoId, MVideoPlaylistId } from '@server/types/models' |
5 | import { AttributesOnly } from '@shared/core-utils' | 5 | import { AttributesOnly } from '@shared/core-utils' |
6 | import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models' | 6 | import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models' |
7 | 7 | ||
8 | function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) { | 8 | function 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' | |||
3 | import { JobQueue } from '@server/lib/job-queue' | 3 | import { JobQueue } from '@server/lib/job-queue' |
4 | import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders' | 4 | import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders' |
5 | import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models' | 5 | import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models' |
6 | import { APObject } from '@shared/models' | ||
6 | import { refreshVideoIfNeeded } from './refresh' | 7 | import { refreshVideoIfNeeded } from './refresh' |
7 | import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared' | 8 | import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared' |
8 | 9 | ||
@@ -13,21 +14,21 @@ type GetVideoResult <T> = Promise<{ | |||
13 | }> | 14 | }> |
14 | 15 | ||
15 | type GetVideoParamAll = { | 16 | type 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 | ||
22 | type GetVideoParamImmutable = { | 23 | type 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 | ||
29 | type GetVideoParamOther = { | 30 | type 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 @@ | |||
1 | import * as Bull from 'bull' | 1 | import * as Bull from 'bull' |
2 | import { ActivitypubHttpFetcherPayload, FetchType } from '@shared/models' | 2 | import { ActivitypubHttpFetcherPayload, FetchType } from '@shared/models' |
3 | import { logger } from '../../../helpers/logger' | 3 | import { logger } from '../../../helpers/logger' |
4 | import { AccountModel } from '../../../models/account/account' | ||
5 | import { AccountVideoRateModel } from '../../../models/account/account-video-rate' | 4 | import { AccountVideoRateModel } from '../../../models/account/account-video-rate' |
6 | import { VideoModel } from '../../../models/video/video' | 5 | import { VideoModel } from '../../../models/video/video' |
7 | import { VideoCommentModel } from '../../../models/video/video-comment' | 6 | import { VideoCommentModel } from '../../../models/video/video-comment' |
8 | import { VideoShareModel } from '../../../models/video/video-share' | 7 | import { VideoShareModel } from '../../../models/video/video-share' |
9 | import { MAccountDefault, MVideoFullLight } from '../../../types/models' | 8 | import { MVideoFullLight } from '../../../types/models' |
10 | import { crawlCollectionPage } from '../../activitypub/crawl' | 9 | import { crawlCollectionPage } from '../../activitypub/crawl' |
11 | import { createAccountPlaylists } from '../../activitypub/playlists' | 10 | import { createAccountPlaylists } from '../../activitypub/playlists' |
12 | import { processActivities } from '../../activitypub/process' | 11 | import { 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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { CONFIG } from '@server/initializers/config' | ||
3 | import { AccountBlocklistModel } from '@server/models/account/account-blocklist' | ||
4 | import { getServerActor } from '@server/models/application/application' | ||
5 | import { ServerBlocklistModel } from '@server/models/server/server-blocklist' | ||
6 | import { SearchTargetQuery } from '@shared/models' | ||
7 | |||
8 | function 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 | |||
21 | async 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 | |||
40 | function isURISearch (search: string) { | ||
41 | if (!search) return false | ||
42 | |||
43 | return search.startsWith('http://') || search.startsWith('https://') | ||
44 | } | ||
45 | |||
46 | export { | ||
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 | ||
52 | const videoChannelsOwnSearchValidator = [ | 52 | const 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 = [ | |||
66 | export { | 67 | export { |
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) | |||
9 | const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS) | 9 | const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS) |
10 | const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH) | 10 | const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH) |
11 | const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH) | 11 | const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH) |
12 | const SORTABLE_VIDEO_PLAYLISTS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH) | ||
12 | const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS) | 13 | const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS) |
13 | const SORTABLE_VIDEO_COMMENTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) | 14 | const SORTABLE_VIDEO_COMMENTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) |
14 | const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) | 15 | const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) |
@@ -34,6 +35,7 @@ const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS) | |||
34 | const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS) | 35 | const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS) |
35 | const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS) | 36 | const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS) |
36 | const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS) | 37 | const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS) |
38 | const videoPlaylistsSearchSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_SEARCH_COLUMNS) | ||
37 | const videoCommentsValidator = checkSort(SORTABLE_VIDEO_COMMENTS_COLUMNS) | 39 | const videoCommentsValidator = checkSort(SORTABLE_VIDEO_COMMENTS_COLUMNS) |
38 | const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS) | 40 | const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS) |
39 | const videoRatesSortValidator = checkSort(SORTABLE_VIDEO_RATES_COLUMNS) | 41 | const 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 | ||
144 | const 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 | ||
146 | export { | 158 | export { |
@@ -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 @@ | |||
1 | import { join } from 'path' | 1 | import { join } from 'path' |
2 | import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize' | 2 | import { FindOptions, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' |
3 | import { | 3 | import { |
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' |
54 | import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' | 54 | import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' |
55 | import { ActorModel } from '../actor/actor' | 55 | import { ActorModel } from '../actor/actor' |
56 | import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils' | 56 | import { |
57 | buildServerIdsFollowedBy, | ||
58 | buildTrigramSearchIndex, | ||
59 | buildWhereIdOrUUID, | ||
60 | createSimilarityAttribute, | ||
61 | getPlaylistSort, | ||
62 | isOutdated, | ||
63 | throwIfNotValid | ||
64 | } from '../utils' | ||
57 | import { ThumbnailModel } from './thumbnail' | 65 | import { ThumbnailModel } from './thumbnail' |
58 | import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' | 66 | import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' |
59 | import { VideoPlaylistElementModel } from './video-playlist-element' | 67 | import { 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 | |||
88 | function 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 @@ | |||
1 | import './search-activitypub-video-playlists' | ||
1 | import './search-activitypub-video-channels' | 2 | import './search-activitypub-video-channels' |
2 | import './search-activitypub-videos' | 3 | import './search-activitypub-videos' |
4 | import './search-channels' | ||
3 | import './search-index' | 5 | import './search-index' |
6 | import './search-playlists' | ||
4 | import './search-videos' | 7 | import './search-videos' |
5 | import './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 | |||
3 | import 'mocha' | ||
4 | import * as chai from 'chai' | ||
5 | import { | ||
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' | ||
19 | import { waitJobs } from '../../../../shared/extra-utils/server/jobs' | ||
20 | import { VideoPlaylist, VideoPlaylistPrivacy } from '../../../../shared/models/videos' | ||
21 | |||
22 | const expect = chai.expect | ||
23 | |||
24 | describe('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 | ||
3 | import 'mocha' | 3 | import 'mocha' |
4 | import * as chai from 'chai' | 4 | import * as chai from 'chai' |
5 | import { advancedVideoChannelSearch, searchVideoChannel } from '@shared/extra-utils/search/video-channels' | ||
6 | import { Video, VideoChannel, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType, VideosSearchQuery } from '@shared/models' | ||
5 | import { | 7 | import { |
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' |
16 | import { searchVideoChannel, advancedVideoChannelSearch } from '@shared/extra-utils/search/video-channels' | ||
17 | import { VideosSearchQuery, Video, VideoChannel } from '@shared/models' | ||
18 | 20 | ||
19 | const expect = chai.expect | 21 | const 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 | |||
3 | import 'mocha' | ||
4 | import * as chai from 'chai' | ||
5 | import { VideoPlaylist, VideoPlaylistPrivacy } from '@shared/models' | ||
6 | import { | ||
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 | |||
19 | const expect = chai.expect | ||
20 | |||
21 | describe('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 | |||
8 | import { | 8 | import { |
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 | ||
71 | export type MVideoPlaylistFull = | 71 | export 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 | ||
86 | export type MVideoPlaylistFullSummary = | 86 | export 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' | |||
19 | export * from './requests/check-api-params' | 19 | export * from './requests/check-api-params' |
20 | export * from './requests/requests' | 20 | export * from './requests/requests' |
21 | 21 | ||
22 | export * from './search/video-channels' | ||
23 | export * from './search/video-playlists' | ||
22 | export * from './search/videos' | 24 | export * from './search/videos' |
23 | 25 | ||
24 | export * from './server/activitypub' | 26 | export * 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 @@ | |||
1 | import { VideoPlaylistsSearchQuery } from '@shared/models' | ||
2 | import { HttpStatusCode } from '../../core-utils/miscs/http-error-codes' | ||
3 | import { makeGetRequest } from '../requests/requests' | ||
4 | |||
5 | function 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 | |||
20 | function 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 | |||
33 | export { | ||
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 @@ | |||
1 | export * from './boolean-both-query.model' | 1 | export * from './boolean-both-query.model' |
2 | export * from './search-target-query.model' | 2 | export * from './search-target-query.model' |
3 | export * from './videos-common-query.model' | 3 | export * from './videos-common-query.model' |
4 | export * from './videos-search-query.model' | ||
5 | export * from './video-channels-search-query.model' | 4 | export * from './video-channels-search-query.model' |
5 | export * from './video-playlists-search-query.model' | ||
6 | export * 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 @@ | |||
1 | import { SearchTargetQuery } from "./search-target-query.model" | 1 | import { SearchTargetQuery } from './search-target-query.model' |
2 | 2 | ||
3 | export interface VideoChannelsSearchQuery extends SearchTargetQuery { | 3 | export 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 @@ | |||
1 | import { SearchTargetQuery } from './search-target-query.model' | ||
2 | |||
3 | export 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 | ||
59 | export type ActivitypubHttpUnicastPayload = { | 58 | export 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 @@ | |||
1 | import { HttpStatusCode } from '@shared/core-utils' | 1 | import { HttpStatusCode } from '../../core-utils' |
2 | import { OAuth2ErrorCode, ServerErrorCode } from './server-error-code.enum' | 2 | import { OAuth2ErrorCode, ServerErrorCode } from './server-error-code.enum' |
3 | 3 | ||
4 | export interface PeerTubeProblemDocumentData { | 4 | export 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: |