aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss7
-rw-r--r--client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss4
-rw-r--r--client/src/app/search/search.component.html19
-rw-r--r--client/src/app/search/search.component.scss36
-rw-r--r--client/src/app/search/search.component.ts26
-rw-r--r--client/src/app/search/search.service.ts44
-rw-r--r--client/src/app/shared/rest/rest.service.ts15
-rw-r--r--client/src/app/shared/user-subscription/subscribe-button.component.html4
-rw-r--r--client/src/app/shared/user-subscription/subscribe-button.component.scss16
-rw-r--r--client/src/app/shared/user-subscription/subscribe-button.component.ts3
-rw-r--r--client/src/app/shared/user-subscription/user-subscription.service.ts47
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.html2
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.scss4
-rw-r--r--config/production.yaml.example2
-rw-r--r--server/controllers/api/search.ts96
-rw-r--r--server/controllers/api/users/me.ts41
-rw-r--r--server/controllers/api/video-channel.ts5
-rw-r--r--server/helpers/custom-validators/activitypub/actor.ts7
-rw-r--r--server/initializers/constants.ts3
-rw-r--r--server/lib/activitypub/process/process-update.ts4
-rw-r--r--server/lib/activitypub/videos.ts8
-rw-r--r--server/middlewares/validators/follows.ts2
-rw-r--r--server/middlewares/validators/search.ts19
-rw-r--r--server/middlewares/validators/sort.ts5
-rw-r--r--server/middlewares/validators/user-subscriptions.ts24
-rw-r--r--server/models/account/account.ts14
-rw-r--r--server/models/activitypub/actor-follow.ts103
-rw-r--r--server/models/activitypub/actor.ts10
-rw-r--r--server/models/video/video-channel.ts163
-rw-r--r--server/tests/api/check-params/user-subscriptions.ts40
-rw-r--r--server/tests/api/users/user-subscriptions.ts19
-rw-r--r--server/tests/utils/users/user-subscriptions.ts13
-rw-r--r--shared/models/search/index.ts1
-rw-r--r--shared/models/search/video-channels-search-query.model.ts7
35 files changed, 670 insertions, 145 deletions
diff --git a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss
index 2fbfa335b..8cb0b677d 100644
--- a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss
+++ b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss
@@ -37,13 +37,6 @@
37 .actor-owner { 37 .actor-owner {
38 @include actor-owner; 38 @include actor-owner;
39 } 39 }
40
41 my-subscribe-button {
42 /deep/ span[role=button] {
43 padding: 7px 12px;
44 font-size: 16px;
45 }
46 }
47} 40}
48 41
49 42
diff --git a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts
index 1e94cf90b..9434b196f 100644
--- a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts
+++ b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts
@@ -21,7 +21,7 @@ export class MyAccountSubscriptionsComponent implements OnInit {
21 ngOnInit () { 21 ngOnInit () {
22 this.userSubscriptionService.listSubscriptions() 22 this.userSubscriptionService.listSubscriptions()
23 .subscribe( 23 .subscribe(
24 res => { console.log(res); this.videoChannels = res.data }, 24 res => this.videoChannels = res.data,
25 25
26 error => this.notificationsService.error(this.i18n('Error'), error.message) 26 error => this.notificationsService.error(this.i18n('Error'), error.message)
27 ) 27 )
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss
index 5c892be01..83d657f03 100644
--- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss
+++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss
@@ -41,6 +41,10 @@
41 color: $grey-actor-name; 41 color: $grey-actor-name;
42 margin-left: 5px; 42 margin-left: 5px;
43 } 43 }
44
45 .video-channel-followers {
46
47 }
44 } 48 }
45 } 49 }
46 50
diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html
index bbc70f772..128cc52f5 100644
--- a/client/src/app/search/search.component.html
+++ b/client/src/app/search/search.component.html
@@ -22,10 +22,27 @@
22 </div> 22 </div>
23 </div> 23 </div>
24 24
25 <div i18n *ngIf="pagination.totalItems === 0" class="no-result"> 25 <div i18n *ngIf="pagination.totalItems === 0 && videoChannels.length === 0" class="no-result">
26 No results found 26 No results found
27 </div> 27 </div>
28 28
29 <div *ngFor="let videoChannel of videoChannels" class="entry video-channel">
30 <a [routerLink]="[ '/video-channels', videoChannel.name ]">
31 <img [src]="videoChannel.avatarUrl" alt="Avatar" />
32 </a>
33
34 <div class="video-channel-info">
35 <a [routerLink]="[ '/video-channels', videoChannel.name ]" class="video-channel-names">
36 <div class="video-channel-display-name">{{ videoChannel.displayName }}</div>
37 <div class="video-channel-name">{{ videoChannel.name }}</div>
38 </a>
39
40 <div i18n class="video-channel-followers">{{ videoChannel.followersCount }} subscribers</div>
41 </div>
42
43 <my-subscribe-button [videoChannel]="videoChannel"></my-subscribe-button>
44 </div>
45
29 <div *ngFor="let video of videos" class="entry video"> 46 <div *ngFor="let video of videos" class="entry video">
30 <my-video-thumbnail [video]="video"></my-video-thumbnail> 47 <my-video-thumbnail [video]="video"></my-video-thumbnail>
31 48
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss
index e54a8b32a..be7dd39cf 100644
--- a/client/src/app/search/search.component.scss
+++ b/client/src/app/search/search.component.scss
@@ -103,6 +103,42 @@
103 } 103 }
104 } 104 }
105 } 105 }
106
107 &.video-channel {
108
109 img {
110 @include avatar(120px);
111
112 margin: 0 50px 0 40px;
113 }
114
115 .video-channel-info {
116
117
118 flex-grow: 1;
119 width: fit-content;
120
121 .video-channel-names {
122 @include disable-default-a-behaviour;
123
124 display: flex;
125 align-items: baseline;
126 color: #000;
127 width: fit-content;
128
129 .video-channel-display-name {
130 font-weight: $font-semibold;
131 font-size: 18px;
132 }
133
134 .video-channel-name {
135 font-size: 14px;
136 color: $grey-actor-name;
137 margin-left: 5px;
138 }
139 }
140 }
141 }
106 } 142 }
107} 143}
108 144
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts
index 8d615fd05..f88df6391 100644
--- a/client/src/app/search/search.component.ts
+++ b/client/src/app/search/search.component.ts
@@ -2,13 +2,15 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { RedirectService } from '@app/core' 3import { RedirectService } from '@app/core'
4import { NotificationsService } from 'angular2-notifications' 4import { NotificationsService } from 'angular2-notifications'
5import { Subscription } from 'rxjs' 5import { forkJoin, Subscription } from 'rxjs'
6import { SearchService } from '@app/search/search.service' 6import { SearchService } from '@app/search/search.service'
7import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 7import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
8import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
9import { Video } from '../../../../shared' 9import { Video } from '../../../../shared'
10import { MetaService } from '@ngx-meta/core' 10import { MetaService } from '@ngx-meta/core'
11import { AdvancedSearch } from '@app/search/advanced-search.model' 11import { AdvancedSearch } from '@app/search/advanced-search.model'
12import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
13import { immutableAssign } from '@app/shared/misc/utils'
12 14
13@Component({ 15@Component({
14 selector: 'my-search', 16 selector: 'my-search',
@@ -17,18 +19,22 @@ import { AdvancedSearch } from '@app/search/advanced-search.model'
17}) 19})
18export class SearchComponent implements OnInit, OnDestroy { 20export class SearchComponent implements OnInit, OnDestroy {
19 videos: Video[] = [] 21 videos: Video[] = []
22 videoChannels: VideoChannel[] = []
23
20 pagination: ComponentPagination = { 24 pagination: ComponentPagination = {
21 currentPage: 1, 25 currentPage: 1,
22 itemsPerPage: 10, // It's per object type (so 10 videos, 10 video channels etc) 26 itemsPerPage: 10, // Only for videos, use another variable for channels
23 totalItems: null 27 totalItems: null
24 } 28 }
25 advancedSearch: AdvancedSearch = new AdvancedSearch() 29 advancedSearch: AdvancedSearch = new AdvancedSearch()
26 isSearchFilterCollapsed = true 30 isSearchFilterCollapsed = true
31 currentSearch: string
27 32
28 private subActivatedRoute: Subscription 33 private subActivatedRoute: Subscription
29 private currentSearch: string
30 private isInitialLoad = true 34 private isInitialLoad = true
31 35
36 private channelsPerPage = 2
37
32 constructor ( 38 constructor (
33 private i18n: I18n, 39 private i18n: I18n,
34 private route: ActivatedRoute, 40 private route: ActivatedRoute,
@@ -74,17 +80,23 @@ export class SearchComponent implements OnInit, OnDestroy {
74 } 80 }
75 81
76 search () { 82 search () {
77 return this.searchService.searchVideos(this.currentSearch, this.pagination, this.advancedSearch) 83 forkJoin([
84 this.searchService.searchVideos(this.currentSearch, this.pagination, this.advancedSearch),
85 this.searchService.searchVideoChannels(this.currentSearch, immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }))
86 ])
78 .subscribe( 87 .subscribe(
79 ({ videos, totalVideos }) => { 88 ([ videosResult, videoChannelsResult ]) => {
80 this.videos = this.videos.concat(videos) 89 this.videos = this.videos.concat(videosResult.videos)
81 this.pagination.totalItems = totalVideos 90 this.pagination.totalItems = videosResult.totalVideos
91
92 this.videoChannels = videoChannelsResult.data
82 }, 93 },
83 94
84 error => { 95 error => {
85 this.notificationsService.error(this.i18n('Error'), error.message) 96 this.notificationsService.error(this.i18n('Error'), error.message)
86 } 97 }
87 ) 98 )
99
88 } 100 }
89 101
90 onNearOfBottom () { 102 onNearOfBottom () {
diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts
index a37c49161..cd3bdad35 100644
--- a/client/src/app/search/search.service.ts
+++ b/client/src/app/search/search.service.ts
@@ -1,4 +1,4 @@
1import { catchError, switchMap } from 'rxjs/operators' 1import { catchError, map, switchMap } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http' 2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { Observable } from 'rxjs' 4import { Observable } from 'rxjs'
@@ -6,13 +6,11 @@ import { ComponentPagination } from '@app/shared/rest/component-pagination.model
6import { VideoService } from '@app/shared/video/video.service' 6import { VideoService } from '@app/shared/video/video.service'
7import { RestExtractor, RestService } from '@app/shared' 7import { RestExtractor, RestService } from '@app/shared'
8import { environment } from 'environments/environment' 8import { environment } from 'environments/environment'
9import { ResultList, Video } from '../../../../shared' 9import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared'
10import { Video as VideoServerModel } from '@app/shared/video/video.model' 10import { Video } from '@app/shared/video/video.model'
11import { AdvancedSearch } from '@app/search/advanced-search.model' 11import { AdvancedSearch } from '@app/search/advanced-search.model'
12 12import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
13export type SearchResult = { 13import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
14 videosResult: { totalVideos: number, videos: Video[] }
15}
16 14
17@Injectable() 15@Injectable()
18export class SearchService { 16export class SearchService {
@@ -40,17 +38,7 @@ export class SearchService {
40 if (search) params = params.append('search', search) 38 if (search) params = params.append('search', search)
41 39
42 const advancedSearchObject = advancedSearch.toAPIObject() 40 const advancedSearchObject = advancedSearch.toAPIObject()
43 41 params = this.restService.addObjectParams(params, advancedSearchObject)
44 for (const name of Object.keys(advancedSearchObject)) {
45 const value = advancedSearchObject[name]
46 if (!value) continue
47
48 if (Array.isArray(value) && value.length !== 0) {
49 for (const v of value) params = params.append(name, v)
50 } else {
51 params = params.append(name, value)
52 }
53 }
54 42
55 return this.authHttp 43 return this.authHttp
56 .get<ResultList<VideoServerModel>>(url, { params }) 44 .get<ResultList<VideoServerModel>>(url, { params })
@@ -59,4 +47,24 @@ export class SearchService {
59 catchError(err => this.restExtractor.handleError(err)) 47 catchError(err => this.restExtractor.handleError(err))
60 ) 48 )
61 } 49 }
50
51 searchVideoChannels (
52 search: string,
53 componentPagination: ComponentPagination
54 ): Observable<{ data: VideoChannel[], total: number }> {
55 const url = SearchService.BASE_SEARCH_URL + 'video-channels'
56
57 const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
58
59 let params = new HttpParams()
60 params = this.restService.addRestGetParams(params, pagination)
61 params = params.append('search', search)
62
63 return this.authHttp
64 .get<ResultList<VideoChannelServerModel>>(url, { params })
65 .pipe(
66 map(res => VideoChannelService.extractVideoChannels(res)),
67 catchError(err => this.restExtractor.handleError(err))
68 )
69 }
62} 70}
diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts
index 5d5410de9..4560c2024 100644
--- a/client/src/app/shared/rest/rest.service.ts
+++ b/client/src/app/shared/rest/rest.service.ts
@@ -32,6 +32,21 @@ export class RestService {
32 return newParams 32 return newParams
33 } 33 }
34 34
35 addObjectParams (params: HttpParams, object: object) {
36 for (const name of Object.keys(object)) {
37 const value = object[name]
38 if (!value) continue
39
40 if (Array.isArray(value) && value.length !== 0) {
41 for (const v of value) params = params.append(name, v)
42 } else {
43 params = params.append(name, value)
44 }
45 }
46
47 return params
48 }
49
35 componentPaginationToRestPagination (componentPagination: ComponentPagination): RestPagination { 50 componentPaginationToRestPagination (componentPagination: ComponentPagination): RestPagination {
36 const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage 51 const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage
37 const count: number = componentPagination.itemsPerPage 52 const count: number = componentPagination.itemsPerPage
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.html b/client/src/app/shared/user-subscription/subscribe-button.component.html
index 63b313662..34c024c17 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.html
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.html
@@ -1,11 +1,11 @@
1<span i18n *ngIf="subscribed === false" class="subscribe-button" role="button" (click)="subscribe()"> 1<span i18n *ngIf="subscribed === false" class="subscribe-button" [ngClass]="size" role="button" (click)="subscribe()">
2 <span>Subscribe</span> 2 <span>Subscribe</span>
3 <span *ngIf="displayFollowers && videoChannel.followersCount !== 0" class="followers-count"> 3 <span *ngIf="displayFollowers && videoChannel.followersCount !== 0" class="followers-count">
4 {{ videoChannel.followersCount | myNumberFormatter }} 4 {{ videoChannel.followersCount | myNumberFormatter }}
5 </span> 5 </span>
6</span> 6</span>
7 7
8<span *ngIf="subscribed === true" class="unsubscribe-button" role="button" (click)="unsubscribe()"> 8<span *ngIf="subscribed === true" class="unsubscribe-button" [ngClass]="size" role="button" (click)="unsubscribe()">
9 <span class="subscribed" i18n>Subscribed</span> 9 <span class="subscribed" i18n>Subscribed</span>
10 <span class="unsubscribe" i18n>Unsubscribe</span> 10 <span class="unsubscribe" i18n>Unsubscribe</span>
11 11
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.scss b/client/src/app/shared/user-subscription/subscribe-button.component.scss
index 9811fdc0c..b78d2f59c 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.scss
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.scss
@@ -13,7 +13,21 @@
13 13
14.subscribe-button, 14.subscribe-button,
15.unsubscribe-button { 15.unsubscribe-button {
16 padding: 3px 7px; 16 display: inline-block;
17
18 &.small {
19 min-width: 75px;
20 height: 20px;
21 line-height: 20px;
22 font-size: 13px;
23 }
24
25 &.normal {
26 min-width: 120px;
27 height: 30px;
28 line-height: 30px;
29 font-size: 16px;
30 }
17} 31}
18 32
19.unsubscribe-button { 33.unsubscribe-button {
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.ts b/client/src/app/shared/user-subscription/subscribe-button.component.ts
index 46d6dbaf7..ba7acf69a 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.ts
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.ts
@@ -15,6 +15,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
15export class SubscribeButtonComponent implements OnInit { 15export class SubscribeButtonComponent implements OnInit {
16 @Input() videoChannel: VideoChannel 16 @Input() videoChannel: VideoChannel
17 @Input() displayFollowers = false 17 @Input() displayFollowers = false
18 @Input() size: 'small' | 'normal' = 'normal'
18 19
19 subscribed: boolean 20 subscribed: boolean
20 21
@@ -34,7 +35,7 @@ export class SubscribeButtonComponent implements OnInit {
34 ngOnInit () { 35 ngOnInit () {
35 this.userSubscriptionService.isSubscriptionExists(this.uri) 36 this.userSubscriptionService.isSubscriptionExists(this.uri)
36 .subscribe( 37 .subscribe(
37 exists => this.subscribed = exists, 38 res => this.subscribed = res[this.uri],
38 39
39 err => this.notificationsService.error(this.i18n('Error'), err.message) 40 err => this.notificationsService.error(this.i18n('Error'), err.message)
40 ) 41 )
diff --git a/client/src/app/shared/user-subscription/user-subscription.service.ts b/client/src/app/shared/user-subscription/user-subscription.service.ts
index 3103706d1..cf622019f 100644
--- a/client/src/app/shared/user-subscription/user-subscription.service.ts
+++ b/client/src/app/shared/user-subscription/user-subscription.service.ts
@@ -1,22 +1,36 @@
1import { catchError, map } from 'rxjs/operators' 1import { bufferTime, catchError, filter, map, share, switchMap, tap } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http' 2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { ResultList } from '../../../../../shared' 4import { ResultList } from '../../../../../shared'
5import { environment } from '../../../environments/environment' 5import { environment } from '../../../environments/environment'
6import { RestExtractor } from '../rest' 6import { RestExtractor, RestService } from '../rest'
7import { Observable, of } from 'rxjs' 7import { Observable, ReplaySubject, Subject } from 'rxjs'
8import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 8import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
9import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 9import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
10import { VideoChannel as VideoChannelServer } from '../../../../../shared/models/videos' 10import { VideoChannel as VideoChannelServer } from '../../../../../shared/models/videos'
11 11
12type SubscriptionExistResult = { [ uri: string ]: boolean }
13
12@Injectable() 14@Injectable()
13export class UserSubscriptionService { 15export class UserSubscriptionService {
14 static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions' 16 static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions'
15 17
18 // Use a replay subject because we "next" a value before subscribing
19 private existsSubject: Subject<string> = new ReplaySubject(1)
20 private existsObservable: Observable<SubscriptionExistResult>
21
16 constructor ( 22 constructor (
17 private authHttp: HttpClient, 23 private authHttp: HttpClient,
18 private restExtractor: RestExtractor 24 private restExtractor: RestExtractor,
25 private restService: RestService
19 ) { 26 ) {
27 this.existsObservable = this.existsSubject.pipe(
28 tap(u => console.log(u)),
29 bufferTime(500),
30 filter(uris => uris.length !== 0),
31 switchMap(uris => this.areSubscriptionExist(uris)),
32 share()
33 )
20 } 34 }
21 35
22 deleteSubscription (nameWithHost: string) { 36 deleteSubscription (nameWithHost: string) {
@@ -50,17 +64,20 @@ export class UserSubscriptionService {
50 ) 64 )
51 } 65 }
52 66
53 isSubscriptionExists (nameWithHost: string): Observable<boolean> { 67 isSubscriptionExists (nameWithHost: string) {
54 const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/' + nameWithHost 68 this.existsSubject.next(nameWithHost)
55 69
56 return this.authHttp.get(url) 70 return this.existsObservable
57 .pipe( 71 }
58 map(this.restExtractor.extractDataBool),
59 catchError(err => {
60 if (err.status === 404) return of(false)
61 72
62 return this.restExtractor.handleError(err) 73 private areSubscriptionExist (uris: string[]): Observable<SubscriptionExistResult> {
63 }) 74 console.log(uris)
64 ) 75 const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist'
76 let params = new HttpParams()
77
78 params = this.restService.addObjectParams(params, { uris })
79
80 return this.authHttp.get<SubscriptionExistResult>(url, { params })
81 .pipe(catchError(err => this.restExtractor.handleError(err)))
65 } 82 }
66} 83}
diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html
index 8a49e3566..e9c79741e 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.html
+++ b/client/src/app/videos/+video-watch/video-watch.component.html
@@ -43,7 +43,7 @@
43 <img [src]="video.videoChannelAvatarUrl" alt="Video channel avatar" /> 43 <img [src]="video.videoChannelAvatarUrl" alt="Video channel avatar" />
44 </a> 44 </a>
45 45
46 <my-subscribe-button [videoChannel]="video.channel"></my-subscribe-button> 46 <my-subscribe-button [videoChannel]="video.channel" size="small"></my-subscribe-button>
47 </div> 47 </div>
48 48
49 <div class="video-info-by"> 49 <div class="video-info-by">
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss
index 5bf2f485a..6b18dc88a 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/videos/+video-watch/video-watch.component.scss
@@ -127,10 +127,6 @@
127 } 127 }
128 128
129 my-subscribe-button { 129 my-subscribe-button {
130 /deep/ span[role=button] {
131 font-size: 13px !important;
132 }
133
134 margin-left: 5px; 130 margin-left: 5px;
135 } 131 }
136 } 132 }
diff --git a/config/production.yaml.example b/config/production.yaml.example
index 272a3cb46..fc698ae96 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -2,7 +2,7 @@ listen:
2 hostname: 'localhost' 2 hostname: 'localhost'
3 port: 9000 3 port: 9000
4 4
5# Correspond to your reverse proxy "listen" configuration 5# Correspond to your reverse proxy server_name/listen configuration
6webserver: 6webserver:
7 https: true 7 https: true
8 hostname: 'example.com' 8 hostname: 'example.com'
diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts
index f408e7932..87aa5d76f 100644
--- a/server/controllers/api/search.ts
+++ b/server/controllers/api/search.ts
@@ -1,22 +1,26 @@
1import * as express from 'express' 1import * as express from 'express'
2import { buildNSFWFilter } from '../../helpers/express-utils' 2import { buildNSFWFilter } from '../../helpers/express-utils'
3import { getFormattedObjects } from '../../helpers/utils' 3import { getFormattedObjects, getServerActor } from '../../helpers/utils'
4import { VideoModel } from '../../models/video/video' 4import { VideoModel } from '../../models/video/video'
5import { 5import {
6 asyncMiddleware, 6 asyncMiddleware,
7 commonVideosFiltersValidator, 7 commonVideosFiltersValidator,
8 optionalAuthenticate, 8 optionalAuthenticate,
9 paginationValidator, 9 paginationValidator,
10 searchValidator,
11 setDefaultPagination, 10 setDefaultPagination,
12 setDefaultSearchSort, 11 setDefaultSearchSort,
13 videosSearchSortValidator 12 videoChannelsSearchSortValidator,
13 videoChannelsSearchValidator,
14 videosSearchSortValidator,
15 videosSearchValidator
14} from '../../middlewares' 16} from '../../middlewares'
15import { VideosSearchQuery } from '../../../shared/models/search' 17import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search'
16import { getOrCreateVideoAndAccountAndChannel } from '../../lib/activitypub' 18import { getOrCreateActorAndServerAndModel, getOrCreateVideoAndAccountAndChannel } from '../../lib/activitypub'
17import { logger } from '../../helpers/logger' 19import { logger } from '../../helpers/logger'
18import { User } from '../../../shared/models/users' 20import { User } from '../../../shared/models/users'
19import { CONFIG } from '../../initializers/constants' 21import { CONFIG } from '../../initializers/constants'
22import { VideoChannelModel } from '../../models/video/video-channel'
23import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger'
20 24
21const searchRouter = express.Router() 25const searchRouter = express.Router()
22 26
@@ -27,21 +31,80 @@ searchRouter.get('/videos',
27 setDefaultSearchSort, 31 setDefaultSearchSort,
28 optionalAuthenticate, 32 optionalAuthenticate,
29 commonVideosFiltersValidator, 33 commonVideosFiltersValidator,
30 searchValidator, 34 videosSearchValidator,
31 asyncMiddleware(searchVideos) 35 asyncMiddleware(searchVideos)
32) 36)
33 37
38searchRouter.get('/video-channels',
39 paginationValidator,
40 setDefaultPagination,
41 videoChannelsSearchSortValidator,
42 setDefaultSearchSort,
43 optionalAuthenticate,
44 commonVideosFiltersValidator,
45 videoChannelsSearchValidator,
46 asyncMiddleware(searchVideoChannels)
47)
48
34// --------------------------------------------------------------------------- 49// ---------------------------------------------------------------------------
35 50
36export { searchRouter } 51export { searchRouter }
37 52
38// --------------------------------------------------------------------------- 53// ---------------------------------------------------------------------------
39 54
55function searchVideoChannels (req: express.Request, res: express.Response) {
56 const query: VideoChannelsSearchQuery = req.query
57 const search = query.search
58
59 const isURISearch = search.startsWith('http://') || search.startsWith('https://')
60
61 const parts = search.split('@')
62 const isHandleSearch = parts.length === 2 && parts.every(p => p.indexOf(' ') === -1)
63
64 if (isURISearch || isHandleSearch) return searchVideoChannelURI(search, isHandleSearch, res)
65
66 return searchVideoChannelsDB(query, res)
67}
68
69async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) {
70 const serverActor = await getServerActor()
71
72 const options = {
73 actorId: serverActor.id,
74 search: query.search,
75 start: query.start,
76 count: query.count,
77 sort: query.sort
78 }
79 const resultList = await VideoChannelModel.searchForApi(options)
80
81 return res.json(getFormattedObjects(resultList.data, resultList.total))
82}
83
84async function searchVideoChannelURI (search: string, isHandleSearch: boolean, res: express.Response) {
85 let videoChannel: VideoChannelModel
86
87 if (isUserAbleToSearchRemoteURI(res)) {
88 let uri = search
89 if (isHandleSearch) uri = await loadActorUrlOrGetFromWebfinger(search)
90
91 const actor = await getOrCreateActorAndServerAndModel(uri)
92 videoChannel = actor.VideoChannel
93 } else {
94 videoChannel = await VideoChannelModel.loadByUrlAndPopulateAccount(search)
95 }
96
97 return res.json({
98 total: videoChannel ? 1 : 0,
99 data: videoChannel ? [ videoChannel.toFormattedJSON() ] : []
100 })
101}
102
40function searchVideos (req: express.Request, res: express.Response) { 103function searchVideos (req: express.Request, res: express.Response) {
41 const query: VideosSearchQuery = req.query 104 const query: VideosSearchQuery = req.query
42 const search = query.search 105 const search = query.search
43 if (search && (search.startsWith('http://') || search.startsWith('https://'))) { 106 if (search && (search.startsWith('http://') || search.startsWith('https://'))) {
44 return searchVideoUrl(search, res) 107 return searchVideoURI(search, res)
45 } 108 }
46 109
47 return searchVideosDB(query, res) 110 return searchVideosDB(query, res)
@@ -57,15 +120,11 @@ async function searchVideosDB (query: VideosSearchQuery, res: express.Response)
57 return res.json(getFormattedObjects(resultList.data, resultList.total)) 120 return res.json(getFormattedObjects(resultList.data, resultList.total))
58} 121}
59 122
60async function searchVideoUrl (url: string, res: express.Response) { 123async function searchVideoURI (url: string, res: express.Response) {
61 let video: VideoModel 124 let video: VideoModel
62 const user: User = res.locals.oauth ? res.locals.oauth.token.User : undefined
63 125
64 // Check if we can fetch a remote video with the URL 126 // Check if we can fetch a remote video with the URL
65 if ( 127 if (isUserAbleToSearchRemoteURI(res)) {
66 CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true ||
67 (CONFIG.SEARCH.REMOTE_URI.USERS === true && user !== undefined)
68 ) {
69 try { 128 try {
70 const syncParam = { 129 const syncParam = {
71 likes: false, 130 likes: false,
@@ -76,8 +135,8 @@ async function searchVideoUrl (url: string, res: express.Response) {
76 refreshVideo: false 135 refreshVideo: false
77 } 136 }
78 137
79 const res = await getOrCreateVideoAndAccountAndChannel(url, syncParam) 138 const result = await getOrCreateVideoAndAccountAndChannel(url, syncParam)
80 video = res ? res.video : undefined 139 video = result ? result.video : undefined
81 } catch (err) { 140 } catch (err) {
82 logger.info('Cannot search remote video %s.', url) 141 logger.info('Cannot search remote video %s.', url)
83 } 142 }
@@ -90,3 +149,10 @@ async function searchVideoUrl (url: string, res: express.Response) {
90 data: video ? [ video.toFormattedJSON() ] : [] 149 data: video ? [ video.toFormattedJSON() ] : []
91 }) 150 })
92} 151}
152
153function isUserAbleToSearchRemoteURI (res: express.Response) {
154 const user: User = res.locals.oauth ? res.locals.oauth.token.User : undefined
155
156 return CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true ||
157 (CONFIG.SEARCH.REMOTE_URI.USERS === true && user !== undefined)
158}
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
index 2300f5dbe..000c706b5 100644
--- a/server/controllers/api/users/me.ts
+++ b/server/controllers/api/users/me.ts
@@ -20,7 +20,8 @@ import {
20 deleteMeValidator, 20 deleteMeValidator,
21 userSubscriptionsSortValidator, 21 userSubscriptionsSortValidator,
22 videoImportsSortValidator, 22 videoImportsSortValidator,
23 videosSortValidator 23 videosSortValidator,
24 areSubscriptionsExistValidator
24} from '../../../middlewares/validators' 25} from '../../../middlewares/validators'
25import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 26import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
26import { UserModel } from '../../../models/account/user' 27import { UserModel } from '../../../models/account/user'
@@ -99,7 +100,6 @@ meRouter.post('/me/avatar/pick',
99 100
100meRouter.get('/me/subscriptions/videos', 101meRouter.get('/me/subscriptions/videos',
101 authenticate, 102 authenticate,
102 authenticate,
103 paginationValidator, 103 paginationValidator,
104 videosSortValidator, 104 videosSortValidator,
105 setDefaultSort, 105 setDefaultSort,
@@ -108,6 +108,12 @@ meRouter.get('/me/subscriptions/videos',
108 asyncMiddleware(getUserSubscriptionVideos) 108 asyncMiddleware(getUserSubscriptionVideos)
109) 109)
110 110
111meRouter.get('/me/subscriptions/exist',
112 authenticate,
113 areSubscriptionsExistValidator,
114 asyncMiddleware(areSubscriptionsExist)
115)
116
111meRouter.get('/me/subscriptions', 117meRouter.get('/me/subscriptions',
112 authenticate, 118 authenticate,
113 paginationValidator, 119 paginationValidator,
@@ -143,6 +149,37 @@ export {
143 149
144// --------------------------------------------------------------------------- 150// ---------------------------------------------------------------------------
145 151
152async function areSubscriptionsExist (req: express.Request, res: express.Response) {
153 const uris = req.query.uris as string[]
154 const user = res.locals.oauth.token.User as UserModel
155
156 const handles = uris.map(u => {
157 let [ name, host ] = u.split('@')
158 if (host === CONFIG.WEBSERVER.HOST) host = null
159
160 return { name, host, uri: u }
161 })
162
163 const results = await ActorFollowModel.listSubscribedIn(user.Account.Actor.id, handles)
164
165 const existObject: { [id: string ]: boolean } = {}
166 for (const handle of handles) {
167 const obj = results.find(r => {
168 const server = r.ActorFollowing.Server
169
170 return r.ActorFollowing.preferredUsername === handle.name &&
171 (
172 (!server && !handle.host) ||
173 (server.host === handle.host)
174 )
175 })
176
177 existObject[handle.uri] = obj !== undefined
178 }
179
180 return res.json(existObject)
181}
182
146async function addUserSubscription (req: express.Request, res: express.Response) { 183async function addUserSubscription (req: express.Request, res: express.Response) {
147 const user = res.locals.oauth.token.User as UserModel 184 const user = res.locals.oauth.token.User as UserModel
148 const [ name, host ] = req.body.uri.split('@') 185 const [ name, host ] = req.body.uri.split('@')
diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts
index 3f51f03f4..bd08d7a08 100644
--- a/server/controllers/api/video-channel.ts
+++ b/server/controllers/api/video-channel.ts
@@ -1,5 +1,5 @@
1import * as express from 'express' 1import * as express from 'express'
2import { getFormattedObjects } from '../../helpers/utils' 2import { getFormattedObjects, getServerActor } from '../../helpers/utils'
3import { 3import {
4 asyncMiddleware, 4 asyncMiddleware,
5 asyncRetryTransactionMiddleware, 5 asyncRetryTransactionMiddleware,
@@ -95,7 +95,8 @@ export {
95// --------------------------------------------------------------------------- 95// ---------------------------------------------------------------------------
96 96
97async function listVideoChannels (req: express.Request, res: express.Response, next: express.NextFunction) { 97async function listVideoChannels (req: express.Request, res: express.Response, next: express.NextFunction) {
98 const resultList = await VideoChannelModel.listForApi(req.query.start, req.query.count, req.query.sort) 98 const serverActor = await getServerActor()
99 const resultList = await VideoChannelModel.listForApi(serverActor.id, req.query.start, req.query.count, req.query.sort)
99 100
100 return res.json(getFormattedObjects(resultList.data, resultList.total)) 101 return res.json(getFormattedObjects(resultList.data, resultList.total))
101} 102}
diff --git a/server/helpers/custom-validators/activitypub/actor.ts b/server/helpers/custom-validators/activitypub/actor.ts
index c3a62c12d..6958b2b00 100644
--- a/server/helpers/custom-validators/activitypub/actor.ts
+++ b/server/helpers/custom-validators/activitypub/actor.ts
@@ -1,6 +1,6 @@
1import * as validator from 'validator' 1import * as validator from 'validator'
2import { CONSTRAINTS_FIELDS } from '../../../initializers' 2import { CONSTRAINTS_FIELDS } from '../../../initializers'
3import { exists } from '../misc' 3import { exists, isArray } from '../misc'
4import { truncate } from 'lodash' 4import { truncate } from 'lodash'
5import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc' 5import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc'
6import { isHostValid } from '../servers' 6import { isHostValid } from '../servers'
@@ -119,10 +119,15 @@ function isValidActorHandle (handle: string) {
119 return isHostValid(parts[1]) 119 return isHostValid(parts[1])
120} 120}
121 121
122function areValidActorHandles (handles: string[]) {
123 return isArray(handles) && handles.every(h => isValidActorHandle(h))
124}
125
122// --------------------------------------------------------------------------- 126// ---------------------------------------------------------------------------
123 127
124export { 128export {
125 normalizeActor, 129 normalizeActor,
130 areValidActorHandles,
126 isActorEndpointsObjectValid, 131 isActorEndpointsObjectValid,
127 isActorPublicKeyObjectValid, 132 isActorPublicKeyObjectValid,
128 isActorTypeValid, 133 isActorTypeValid,
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 46b63c5e9..9beb9b7c2 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -43,7 +43,8 @@ const SORTABLE_COLUMNS = {
43 FOLLOWERS: [ 'createdAt' ], 43 FOLLOWERS: [ 'createdAt' ],
44 FOLLOWING: [ 'createdAt' ], 44 FOLLOWING: [ 'createdAt' ],
45 45
46 VIDEOS_SEARCH: [ 'match', 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ] 46 VIDEOS_SEARCH: [ 'match', 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ],
47 VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName' ]
47} 48}
48 49
49const OAUTH_LIFETIME = { 50const OAUTH_LIFETIME = {
diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts
index 07a5ff92f..d2ad738a2 100644
--- a/server/lib/activitypub/process/process-update.ts
+++ b/server/lib/activitypub/process/process-update.ts
@@ -7,7 +7,7 @@ import { AccountModel } from '../../../models/account/account'
7import { ActorModel } from '../../../models/activitypub/actor' 7import { ActorModel } from '../../../models/activitypub/actor'
8import { VideoChannelModel } from '../../../models/video/video-channel' 8import { VideoChannelModel } from '../../../models/video/video-channel'
9import { fetchAvatarIfExists, getOrCreateActorAndServerAndModel, updateActorAvatarInstance, updateActorInstance } from '../actor' 9import { fetchAvatarIfExists, getOrCreateActorAndServerAndModel, updateActorAvatarInstance, updateActorInstance } from '../actor'
10import { getOrCreateVideoAndAccountAndChannel, getOrCreateVideoChannel, updateVideoFromAP } from '../videos' 10import { getOrCreateVideoAndAccountAndChannel, getOrCreateVideoChannelFromVideoObject, updateVideoFromAP } from '../videos'
11import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos' 11import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
12 12
13async function processUpdateActivity (activity: ActivityUpdate) { 13async function processUpdateActivity (activity: ActivityUpdate) {
@@ -40,7 +40,7 @@ async function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate)
40 } 40 }
41 41
42 const { video } = await getOrCreateVideoAndAccountAndChannel(videoObject.id) 42 const { video } = await getOrCreateVideoAndAccountAndChannel(videoObject.id)
43 const channelActor = await getOrCreateVideoChannel(videoObject) 43 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
44 44
45 return updateVideoFromAP(video, videoObject, actor, channelActor, activity.to) 45 return updateVideoFromAP(video, videoObject, actor, channelActor, activity.to)
46} 46}
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index 388c31fe5..6c2095897 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -174,7 +174,7 @@ function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObje
174 return attributes 174 return attributes
175} 175}
176 176
177function getOrCreateVideoChannel (videoObject: VideoTorrentObject) { 177function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
178 const channel = videoObject.attributedTo.find(a => a.type === 'Group') 178 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
179 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url) 179 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
180 180
@@ -251,7 +251,7 @@ async function getOrCreateVideoAndAccountAndChannel (
251 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl) 251 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
252 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl) 252 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
253 253
254 const channelActor = await getOrCreateVideoChannel(fetchedVideo) 254 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
255 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail) 255 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
256 256
257 // Process outside the transaction because we could fetch remote data 257 // Process outside the transaction because we could fetch remote data
@@ -329,7 +329,7 @@ async function refreshVideoIfNeeded (video: VideoModel): Promise<VideoModel> {
329 return video 329 return video
330 } 330 }
331 331
332 const channelActor = await getOrCreateVideoChannel(videoObject) 332 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
333 const account = await AccountModel.load(channelActor.VideoChannel.accountId) 333 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
334 return updateVideoFromAP(video, videoObject, account.Actor, channelActor) 334 return updateVideoFromAP(video, videoObject, account.Actor, channelActor)
335 335
@@ -440,7 +440,7 @@ export {
440 videoActivityObjectToDBAttributes, 440 videoActivityObjectToDBAttributes,
441 videoFileActivityUrlToDBAttributes, 441 videoFileActivityUrlToDBAttributes,
442 createVideo, 442 createVideo,
443 getOrCreateVideoChannel, 443 getOrCreateVideoChannelFromVideoObject,
444 addVideoShares, 444 addVideoShares,
445 createRates 445 createRates
446} 446}
diff --git a/server/middlewares/validators/follows.ts b/server/middlewares/validators/follows.ts
index faefc1179..73fa28be9 100644
--- a/server/middlewares/validators/follows.ts
+++ b/server/middlewares/validators/follows.ts
@@ -38,7 +38,7 @@ const removeFollowingValidator = [
38 if (areValidationErrors(req, res)) return 38 if (areValidationErrors(req, res)) return
39 39
40 const serverActor = await getServerActor() 40 const serverActor = await getServerActor()
41 const follow = await ActorFollowModel.loadByActorAndTargetNameAndHost(serverActor.id, SERVER_ACTOR_NAME, req.params.host) 41 const follow = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(serverActor.id, SERVER_ACTOR_NAME, req.params.host)
42 42
43 if (!follow) { 43 if (!follow) {
44 return res 44 return res
diff --git a/server/middlewares/validators/search.ts b/server/middlewares/validators/search.ts
index e516c4c41..8baf643a5 100644
--- a/server/middlewares/validators/search.ts
+++ b/server/middlewares/validators/search.ts
@@ -5,7 +5,7 @@ import { query } from 'express-validator/check'
5import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search' 5import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search'
6import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc' 6import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc'
7 7
8const searchValidator = [ 8const videosSearchValidator = [
9 query('search').optional().not().isEmpty().withMessage('Should have a valid search'), 9 query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
10 10
11 query('startDate').optional().custom(isDateValid).withMessage('Should have a valid start date'), 11 query('startDate').optional().custom(isDateValid).withMessage('Should have a valid start date'),
@@ -15,7 +15,19 @@ const searchValidator = [
15 query('durationMax').optional().isInt().withMessage('Should have a valid max duration'), 15 query('durationMax').optional().isInt().withMessage('Should have a valid max duration'),
16 16
17 (req: express.Request, res: express.Response, next: express.NextFunction) => { 17 (req: express.Request, res: express.Response, next: express.NextFunction) => {
18 logger.debug('Checking search query', { parameters: req.query }) 18 logger.debug('Checking videos search query', { parameters: req.query })
19
20 if (areValidationErrors(req, res)) return
21
22 return next()
23 }
24]
25
26const videoChannelsSearchValidator = [
27 query('search').not().isEmpty().withMessage('Should have a valid search'),
28
29 (req: express.Request, res: express.Response, next: express.NextFunction) => {
30 logger.debug('Checking video channels search query', { parameters: req.query })
19 31
20 if (areValidationErrors(req, res)) return 32 if (areValidationErrors(req, res)) return
21 33
@@ -61,5 +73,6 @@ const commonVideosFiltersValidator = [
61 73
62export { 74export {
63 commonVideosFiltersValidator, 75 commonVideosFiltersValidator,
64 searchValidator 76 videoChannelsSearchValidator,
77 videosSearchValidator
65} 78}
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index b30e97e61..08dcc2680 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -8,6 +8,7 @@ const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS)
8const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES) 8const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES)
9const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS) 9const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
10const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH) 10const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH)
11const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
11const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS) 12const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS)
12const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) 13const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
13const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS) 14const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS)
@@ -23,6 +24,7 @@ const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS)
23const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS) 24const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
24const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS) 25const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS)
25const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS) 26const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
27const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS)
26const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS) 28const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
27const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS) 29const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS)
28const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS) 30const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
@@ -45,5 +47,6 @@ export {
45 followingSortValidator, 47 followingSortValidator,
46 jobsSortValidator, 48 jobsSortValidator,
47 videoCommentThreadsSortValidator, 49 videoCommentThreadsSortValidator,
48 userSubscriptionsSortValidator 50 userSubscriptionsSortValidator,
51 videoChannelsSearchSortValidator
49} 52}
diff --git a/server/middlewares/validators/user-subscriptions.ts b/server/middlewares/validators/user-subscriptions.ts
index d8c26c742..c5f8d9d4c 100644
--- a/server/middlewares/validators/user-subscriptions.ts
+++ b/server/middlewares/validators/user-subscriptions.ts
@@ -1,12 +1,13 @@
1import * as express from 'express' 1import * as express from 'express'
2import 'express-validator' 2import 'express-validator'
3import { body, param } from 'express-validator/check' 3import { body, param, query } from 'express-validator/check'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils' 5import { areValidationErrors } from './utils'
6import { ActorFollowModel } from '../../models/activitypub/actor-follow' 6import { ActorFollowModel } from '../../models/activitypub/actor-follow'
7import { isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor' 7import { areValidActorHandles, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor'
8import { UserModel } from '../../models/account/user' 8import { UserModel } from '../../models/account/user'
9import { CONFIG } from '../../initializers' 9import { CONFIG } from '../../initializers'
10import { toArray } from '../../helpers/custom-validators/misc'
10 11
11const userSubscriptionAddValidator = [ 12const userSubscriptionAddValidator = [
12 body('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to follow (username@domain)'), 13 body('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to follow (username@domain)'),
@@ -20,6 +21,20 @@ const userSubscriptionAddValidator = [
20 } 21 }
21] 22]
22 23
24const areSubscriptionsExistValidator = [
25 query('uris')
26 .customSanitizer(toArray)
27 .custom(areValidActorHandles).withMessage('Should have a valid uri array'),
28
29 (req: express.Request, res: express.Response, next: express.NextFunction) => {
30 logger.debug('Checking areSubscriptionsExistValidator parameters', { parameters: req.query })
31
32 if (areValidationErrors(req, res)) return
33
34 return next()
35 }
36]
37
23const userSubscriptionGetValidator = [ 38const userSubscriptionGetValidator = [
24 param('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to unfollow'), 39 param('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to unfollow'),
25 40
@@ -32,7 +47,7 @@ const userSubscriptionGetValidator = [
32 if (host === CONFIG.WEBSERVER.HOST) host = null 47 if (host === CONFIG.WEBSERVER.HOST) host = null
33 48
34 const user: UserModel = res.locals.oauth.token.User 49 const user: UserModel = res.locals.oauth.token.User
35 const subscription = await ActorFollowModel.loadByActorAndTargetNameAndHost(user.Account.Actor.id, name, host) 50 const subscription = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(user.Account.Actor.id, name, host)
36 51
37 if (!subscription || !subscription.ActorFollowing.VideoChannel) { 52 if (!subscription || !subscription.ActorFollowing.VideoChannel) {
38 return res 53 return res
@@ -51,8 +66,7 @@ const userSubscriptionGetValidator = [
51// --------------------------------------------------------------------------- 66// ---------------------------------------------------------------------------
52 67
53export { 68export {
69 areSubscriptionsExistValidator,
54 userSubscriptionAddValidator, 70 userSubscriptionAddValidator,
55 userSubscriptionGetValidator 71 userSubscriptionGetValidator
56} 72}
57
58// ---------------------------------------------------------------------------
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 07539a04e..6bbfc6f4e 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -29,18 +29,8 @@ import { UserModel } from './user'
29@DefaultScope({ 29@DefaultScope({
30 include: [ 30 include: [
31 { 31 {
32 model: () => ActorModel, 32 model: () => ActorModel, // Default scope includes avatar and server
33 required: true, 33 required: true
34 include: [
35 {
36 model: () => ServerModel,
37 required: false
38 },
39 {
40 model: () => AvatarModel,
41 required: false
42 }
43 ]
44 } 34 }
45 ] 35 ]
46}) 36})
diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts
index b2d7ace66..81fcf7001 100644
--- a/server/models/activitypub/actor-follow.ts
+++ b/server/models/activitypub/actor-follow.ts
@@ -26,7 +26,7 @@ import { ACTOR_FOLLOW_SCORE } from '../../initializers'
26import { FOLLOW_STATES } from '../../initializers/constants' 26import { FOLLOW_STATES } from '../../initializers/constants'
27import { ServerModel } from '../server/server' 27import { ServerModel } from '../server/server'
28import { getSort } from '../utils' 28import { getSort } from '../utils'
29import { ActorModel } from './actor' 29import { ActorModel, unusedActorAttributesForAPI } from './actor'
30import { VideoChannelModel } from '../video/video-channel' 30import { VideoChannelModel } from '../video/video-channel'
31import { IIncludeOptions } from '../../../node_modules/sequelize-typescript/lib/interfaces/IIncludeOptions' 31import { IIncludeOptions } from '../../../node_modules/sequelize-typescript/lib/interfaces/IIncludeOptions'
32import { AccountModel } from '../account/account' 32import { AccountModel } from '../account/account'
@@ -167,8 +167,11 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
167 return ActorFollowModel.findOne(query) 167 return ActorFollowModel.findOne(query)
168 } 168 }
169 169
170 static loadByActorAndTargetNameAndHost (actorId: number, targetName: string, targetHost: string, t?: Sequelize.Transaction) { 170 static loadByActorAndTargetNameAndHostForAPI (actorId: number, targetName: string, targetHost: string, t?: Sequelize.Transaction) {
171 const actorFollowingPartInclude: IIncludeOptions = { 171 const actorFollowingPartInclude: IIncludeOptions = {
172 attributes: {
173 exclude: unusedActorAttributesForAPI
174 },
172 model: ActorModel, 175 model: ActorModel,
173 required: true, 176 required: true,
174 as: 'ActorFollowing', 177 as: 'ActorFollowing',
@@ -177,7 +180,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
177 }, 180 },
178 include: [ 181 include: [
179 { 182 {
180 model: VideoChannelModel, 183 model: VideoChannelModel.unscoped(),
181 required: false 184 required: false
182 } 185 }
183 ] 186 ]
@@ -200,17 +203,79 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
200 actorId 203 actorId
201 }, 204 },
202 include: [ 205 include: [
203 {
204 model: ActorModel,
205 required: true,
206 as: 'ActorFollower'
207 },
208 actorFollowingPartInclude 206 actorFollowingPartInclude
209 ], 207 ],
210 transaction: t 208 transaction: t
211 } 209 }
212 210
213 return ActorFollowModel.findOne(query) 211 return ActorFollowModel.findOne(query)
212 .then(result => {
213 if (result && result.ActorFollowing.VideoChannel) {
214 result.ActorFollowing.VideoChannel.Actor = result.ActorFollowing
215 }
216
217 return result
218 })
219 }
220
221 static listSubscribedIn (actorId: number, targets: { name: string, host?: string }[]) {
222 const whereTab = targets
223 .map(t => {
224 if (t.host) {
225 return {
226 [ Sequelize.Op.and ]: [
227 {
228 '$preferredUsername$': t.name
229 },
230 {
231 '$host$': t.host
232 }
233 ]
234 }
235 }
236
237 return {
238 [ Sequelize.Op.and ]: [
239 {
240 '$preferredUsername$': t.name
241 },
242 {
243 '$serverId$': null
244 }
245 ]
246 }
247 })
248
249 const query = {
250 attributes: [],
251 where: {
252 [ Sequelize.Op.and ]: [
253 {
254 [ Sequelize.Op.or ]: whereTab
255 },
256 {
257 actorId
258 }
259 ]
260 },
261 include: [
262 {
263 attributes: [ 'preferredUsername' ],
264 model: ActorModel.unscoped(),
265 required: true,
266 as: 'ActorFollowing',
267 include: [
268 {
269 attributes: [ 'host' ],
270 model: ServerModel.unscoped(),
271 required: false
272 }
273 ]
274 }
275 ]
276 }
277
278 return ActorFollowModel.findAll(query)
214 } 279 }
215 280
216 static listFollowingForApi (id: number, start: number, count: number, sort: string) { 281 static listFollowingForApi (id: number, start: number, count: number, sort: string) {
@@ -248,6 +313,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
248 313
249 static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) { 314 static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) {
250 const query = { 315 const query = {
316 attributes: [],
251 distinct: true, 317 distinct: true,
252 offset: start, 318 offset: start,
253 limit: count, 319 limit: count,
@@ -257,6 +323,9 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
257 }, 323 },
258 include: [ 324 include: [
259 { 325 {
326 attributes: {
327 exclude: unusedActorAttributesForAPI
328 },
260 model: ActorModel, 329 model: ActorModel,
261 as: 'ActorFollowing', 330 as: 'ActorFollowing',
262 required: true, 331 required: true,
@@ -266,8 +335,24 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
266 required: true, 335 required: true,
267 include: [ 336 include: [
268 { 337 {
269 model: AccountModel, 338 attributes: {
339 exclude: unusedActorAttributesForAPI
340 },
341 model: ActorModel,
270 required: true 342 required: true
343 },
344 {
345 model: AccountModel,
346 required: true,
347 include: [
348 {
349 attributes: {
350 exclude: unusedActorAttributesForAPI
351 },
352 model: ActorModel,
353 required: true
354 }
355 ]
271 } 356 }
272 ] 357 ]
273 } 358 }
diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts
index 2abf40713..ec0b4b2d9 100644
--- a/server/models/activitypub/actor.ts
+++ b/server/models/activitypub/actor.ts
@@ -42,6 +42,16 @@ enum ScopeNames {
42 FULL = 'FULL' 42 FULL = 'FULL'
43} 43}
44 44
45export const unusedActorAttributesForAPI = [
46 'publicKey',
47 'privateKey',
48 'inboxUrl',
49 'outboxUrl',
50 'sharedInboxUrl',
51 'followersUrl',
52 'followingUrl'
53]
54
45@DefaultScope({ 55@DefaultScope({
46 include: [ 56 include: [
47 { 57 {
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 9f80e0b8d..7d717fc68 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -12,6 +12,7 @@ import {
12 Is, 12 Is,
13 Model, 13 Model,
14 Scopes, 14 Scopes,
15 Sequelize,
15 Table, 16 Table,
16 UpdatedAt 17 UpdatedAt
17} from 'sequelize-typescript' 18} from 'sequelize-typescript'
@@ -24,19 +25,36 @@ import {
24} from '../../helpers/custom-validators/video-channels' 25} from '../../helpers/custom-validators/video-channels'
25import { sendDeleteActor } from '../../lib/activitypub/send' 26import { sendDeleteActor } from '../../lib/activitypub/send'
26import { AccountModel } from '../account/account' 27import { AccountModel } from '../account/account'
27import { ActorModel } from '../activitypub/actor' 28import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
28import { getSort, throwIfNotValid } from '../utils' 29import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
29import { VideoModel } from './video' 30import { VideoModel } from './video'
30import { CONSTRAINTS_FIELDS } from '../../initializers' 31import { CONSTRAINTS_FIELDS } from '../../initializers'
31import { AvatarModel } from '../avatar/avatar'
32import { ServerModel } from '../server/server' 32import { ServerModel } from '../server/server'
33import { DefineIndexesOptions } from 'sequelize'
34
35// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
36const indexes: DefineIndexesOptions[] = [
37 buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
38
39 {
40 fields: [ 'accountId' ]
41 },
42 {
43 fields: [ 'actorId' ]
44 }
45]
33 46
34enum ScopeNames { 47enum ScopeNames {
48 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
35 WITH_ACCOUNT = 'WITH_ACCOUNT', 49 WITH_ACCOUNT = 'WITH_ACCOUNT',
36 WITH_ACTOR = 'WITH_ACTOR', 50 WITH_ACTOR = 'WITH_ACTOR',
37 WITH_VIDEOS = 'WITH_VIDEOS' 51 WITH_VIDEOS = 'WITH_VIDEOS'
38} 52}
39 53
54type AvailableForListOptions = {
55 actorId: number
56}
57
40@DefaultScope({ 58@DefaultScope({
41 include: [ 59 include: [
42 { 60 {
@@ -46,23 +64,57 @@ enum ScopeNames {
46 ] 64 ]
47}) 65})
48@Scopes({ 66@Scopes({
49 [ScopeNames.WITH_ACCOUNT]: { 67 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
50 include: [ 68 const actorIdNumber = parseInt(options.actorId + '', 10)
51 { 69
52 model: () => AccountModel.unscoped(), 70 // Only list local channels OR channels that are on an instance followed by actorId
53 required: true, 71 const inQueryInstanceFollow = '(' +
54 include: [ 72 'SELECT "actor"."serverId" FROM "actor" ' +
55 { 73 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = actor.id ' +
56 model: () => ActorModel.unscoped(), 74 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
57 required: true, 75 ')'
58 include: [ 76
77 return {
78 include: [
79 {
80 attributes: {
81 exclude: unusedActorAttributesForAPI
82 },
83 model: ActorModel,
84 where: {
85 [Sequelize.Op.or]: [
86 {
87 serverId: null
88 },
59 { 89 {
60 model: () => AvatarModel.unscoped(), 90 serverId: {
61 required: false 91 [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow)
92 }
62 } 93 }
63 ] 94 ]
64 } 95 }
65 ] 96 },
97 {
98 model: AccountModel,
99 required: true,
100 include: [
101 {
102 attributes: {
103 exclude: unusedActorAttributesForAPI
104 },
105 model: ActorModel, // Default scope includes avatar and server
106 required: true
107 }
108 ]
109 }
110 ]
111 }
112 },
113 [ScopeNames.WITH_ACCOUNT]: {
114 include: [
115 {
116 model: () => AccountModel,
117 required: true
66 } 118 }
67 ] 119 ]
68 }, 120 },
@@ -79,14 +131,7 @@ enum ScopeNames {
79}) 131})
80@Table({ 132@Table({
81 tableName: 'videoChannel', 133 tableName: 'videoChannel',
82 indexes: [ 134 indexes
83 {
84 fields: [ 'accountId' ]
85 },
86 {
87 fields: [ 'actorId' ]
88 }
89 ]
90}) 135})
91export class VideoChannelModel extends Model<VideoChannelModel> { 136export class VideoChannelModel extends Model<VideoChannelModel> {
92 137
@@ -170,15 +215,61 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
170 return VideoChannelModel.count(query) 215 return VideoChannelModel.count(query)
171 } 216 }
172 217
173 static listForApi (start: number, count: number, sort: string) { 218 static listForApi (actorId: number, start: number, count: number, sort: string) {
174 const query = { 219 const query = {
175 offset: start, 220 offset: start,
176 limit: count, 221 limit: count,
177 order: getSort(sort) 222 order: getSort(sort)
178 } 223 }
179 224
225 const scopes = {
226 method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId } as AvailableForListOptions ]
227 }
180 return VideoChannelModel 228 return VideoChannelModel
181 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) 229 .scope(scopes)
230 .findAndCountAll(query)
231 .then(({ rows, count }) => {
232 return { total: count, data: rows }
233 })
234 }
235
236 static searchForApi (options: {
237 actorId: number
238 search: string
239 start: number
240 count: number
241 sort: string
242 }) {
243 const attributesInclude = []
244 const escapedSearch = VideoModel.sequelize.escape(options.search)
245 const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
246 attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
247
248 const query = {
249 attributes: {
250 include: attributesInclude
251 },
252 offset: options.start,
253 limit: options.count,
254 order: getSort(options.sort),
255 where: {
256 id: {
257 [ Sequelize.Op.in ]: Sequelize.literal(
258 '(' +
259 'SELECT id FROM "videoChannel" WHERE ' +
260 'lower(immutable_unaccent("name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
261 'lower(immutable_unaccent("name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
262 ')'
263 )
264 }
265 }
266 }
267
268 const scopes = {
269 method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId: options.actorId } as AvailableForListOptions ]
270 }
271 return VideoChannelModel
272 .scope(scopes)
182 .findAndCountAll(query) 273 .findAndCountAll(query)
183 .then(({ rows, count }) => { 274 .then(({ rows, count }) => {
184 return { total: count, data: rows } 275 return { total: count, data: rows }
@@ -239,7 +330,25 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
239 } 330 }
240 331
241 return VideoChannelModel 332 return VideoChannelModel
242 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) 333 .scope([ ScopeNames.WITH_ACCOUNT ])
334 .findOne(query)
335 }
336
337 static loadByUrlAndPopulateAccount (url: string) {
338 const query = {
339 include: [
340 {
341 model: ActorModel,
342 required: true,
343 where: {
344 url
345 }
346 }
347 ]
348 }
349
350 return VideoChannelModel
351 .scope([ ScopeNames.WITH_ACCOUNT ])
243 .findOne(query) 352 .findOne(query)
244 } 353 }
245 354
diff --git a/server/tests/api/check-params/user-subscriptions.ts b/server/tests/api/check-params/user-subscriptions.ts
index 6a6dd9a6f..9fba99ac8 100644
--- a/server/tests/api/check-params/user-subscriptions.ts
+++ b/server/tests/api/check-params/user-subscriptions.ts
@@ -202,6 +202,46 @@ describe('Test user subscriptions API validators', function () {
202 }) 202 })
203 }) 203 })
204 204
205 describe('When checking if subscriptions exist', async function () {
206 const existPath = path + '/exist'
207
208 it('Should fail with a non authenticated user', async function () {
209 await makeGetRequest({
210 url: server.url,
211 path: existPath,
212 statusCodeExpected: 401
213 })
214 })
215
216 it('Should fail with bad URIs', async function () {
217 await makeGetRequest({
218 url: server.url,
219 path: existPath,
220 query: { uris: 'toto' },
221 token: server.accessToken,
222 statusCodeExpected: 400
223 })
224
225 await makeGetRequest({
226 url: server.url,
227 path: existPath,
228 query: { 'uris[]': 1 },
229 token: server.accessToken,
230 statusCodeExpected: 400
231 })
232 })
233
234 it('Should succeed with the correct parameters', async function () {
235 await makeGetRequest({
236 url: server.url,
237 path: existPath,
238 query: { 'uris[]': 'coucou@localhost:9001' },
239 token: server.accessToken,
240 statusCodeExpected: 200
241 })
242 })
243 })
244
205 describe('When removing a subscription', function () { 245 describe('When removing a subscription', function () {
206 it('Should fail with a non authenticated user', async function () { 246 it('Should fail with a non authenticated user', async function () {
207 await makeDeleteRequest({ 247 await makeDeleteRequest({
diff --git a/server/tests/api/users/user-subscriptions.ts b/server/tests/api/users/user-subscriptions.ts
index cb7d94b0b..65b80540c 100644
--- a/server/tests/api/users/user-subscriptions.ts
+++ b/server/tests/api/users/user-subscriptions.ts
@@ -12,7 +12,7 @@ import {
12 listUserSubscriptions, 12 listUserSubscriptions,
13 listUserSubscriptionVideos, 13 listUserSubscriptionVideos,
14 removeUserSubscription, 14 removeUserSubscription,
15 getUserSubscription 15 getUserSubscription, areSubscriptionsExist
16} from '../../utils/users/user-subscriptions' 16} from '../../utils/users/user-subscriptions'
17 17
18const expect = chai.expect 18const expect = chai.expect
@@ -128,6 +128,23 @@ describe('Test users subscriptions', function () {
128 } 128 }
129 }) 129 })
130 130
131 it('Should return the existing subscriptions', async function () {
132 const uris = [
133 'user3_channel@localhost:9003',
134 'root2_channel@localhost:9001',
135 'root_channel@localhost:9001',
136 'user3_channel@localhost:9001'
137 ]
138
139 const res = await areSubscriptionsExist(servers[ 0 ].url, users[ 0 ].accessToken, uris)
140 const body = res.body
141
142 expect(body['user3_channel@localhost:9003']).to.be.true
143 expect(body['root2_channel@localhost:9001']).to.be.false
144 expect(body['root_channel@localhost:9001']).to.be.true
145 expect(body['user3_channel@localhost:9001']).to.be.false
146 })
147
131 it('Should list subscription videos', async function () { 148 it('Should list subscription videos', async function () {
132 { 149 {
133 const res = await listUserSubscriptionVideos(servers[0].url, servers[0].accessToken) 150 const res = await listUserSubscriptionVideos(servers[0].url, servers[0].accessToken)
diff --git a/server/tests/utils/users/user-subscriptions.ts b/server/tests/utils/users/user-subscriptions.ts
index 852f590cf..b0e7da7cc 100644
--- a/server/tests/utils/users/user-subscriptions.ts
+++ b/server/tests/utils/users/user-subscriptions.ts
@@ -58,9 +58,22 @@ function removeUserSubscription (url: string, token: string, uri: string, status
58 }) 58 })
59} 59}
60 60
61function areSubscriptionsExist (url: string, token: string, uris: string[], statusCodeExpected = 200) {
62 const path = '/api/v1/users/me/subscriptions/exist'
63
64 return makeGetRequest({
65 url,
66 path,
67 query: { 'uris[]': uris },
68 token,
69 statusCodeExpected
70 })
71}
72
61// --------------------------------------------------------------------------- 73// ---------------------------------------------------------------------------
62 74
63export { 75export {
76 areSubscriptionsExist,
64 addUserSubscription, 77 addUserSubscription,
65 listUserSubscriptions, 78 listUserSubscriptions,
66 getUserSubscription, 79 getUserSubscription,
diff --git a/shared/models/search/index.ts b/shared/models/search/index.ts
index 928846c39..28dd95443 100644
--- a/shared/models/search/index.ts
+++ b/shared/models/search/index.ts
@@ -1,2 +1,3 @@
1export * from './nsfw-query.model' 1export * from './nsfw-query.model'
2export * from './videos-search-query.model' 2export * from './videos-search-query.model'
3export * from './video-channels-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
new file mode 100644
index 000000000..de2741e14
--- /dev/null
+++ b/shared/models/search/video-channels-search-query.model.ts
@@ -0,0 +1,7 @@
1export interface VideoChannelsSearchQuery {
2 search: string
3
4 start?: number
5 count?: number
6 sort?: string
7}