diff options
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' | |||
2 | import { ActivatedRoute, Router } from '@angular/router' | 2 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { RedirectService } from '@app/core' | 3 | import { RedirectService } from '@app/core' |
4 | import { NotificationsService } from 'angular2-notifications' | 4 | import { NotificationsService } from 'angular2-notifications' |
5 | import { Subscription } from 'rxjs' | 5 | import { forkJoin, Subscription } from 'rxjs' |
6 | import { SearchService } from '@app/search/search.service' | 6 | import { SearchService } from '@app/search/search.service' |
7 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | 7 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' |
8 | import { I18n } from '@ngx-translate/i18n-polyfill' | 8 | import { I18n } from '@ngx-translate/i18n-polyfill' |
9 | import { Video } from '../../../../shared' | 9 | import { Video } from '../../../../shared' |
10 | import { MetaService } from '@ngx-meta/core' | 10 | import { MetaService } from '@ngx-meta/core' |
11 | import { AdvancedSearch } from '@app/search/advanced-search.model' | 11 | import { AdvancedSearch } from '@app/search/advanced-search.model' |
12 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | ||
13 | import { 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 | }) |
18 | export class SearchComponent implements OnInit, OnDestroy { | 20 | export 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 @@ | |||
1 | import { catchError, switchMap } from 'rxjs/operators' | 1 | import { catchError, map, switchMap } from 'rxjs/operators' |
2 | import { HttpClient, HttpParams } from '@angular/common/http' | 2 | import { HttpClient, HttpParams } from '@angular/common/http' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { Observable } from 'rxjs' | 4 | import { Observable } from 'rxjs' |
@@ -6,13 +6,11 @@ import { ComponentPagination } from '@app/shared/rest/component-pagination.model | |||
6 | import { VideoService } from '@app/shared/video/video.service' | 6 | import { VideoService } from '@app/shared/video/video.service' |
7 | import { RestExtractor, RestService } from '@app/shared' | 7 | import { RestExtractor, RestService } from '@app/shared' |
8 | import { environment } from 'environments/environment' | 8 | import { environment } from 'environments/environment' |
9 | import { ResultList, Video } from '../../../../shared' | 9 | import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared' |
10 | import { Video as VideoServerModel } from '@app/shared/video/video.model' | 10 | import { Video } from '@app/shared/video/video.model' |
11 | import { AdvancedSearch } from '@app/search/advanced-search.model' | 11 | import { AdvancedSearch } from '@app/search/advanced-search.model' |
12 | 12 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | |
13 | export type SearchResult = { | 13 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' |
14 | videosResult: { totalVideos: number, videos: Video[] } | ||
15 | } | ||
16 | 14 | ||
17 | @Injectable() | 15 | @Injectable() |
18 | export class SearchService { | 16 | export 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' | |||
15 | export class SubscribeButtonComponent implements OnInit { | 15 | export 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 @@ | |||
1 | import { catchError, map } from 'rxjs/operators' | 1 | import { bufferTime, catchError, filter, map, share, switchMap, tap } from 'rxjs/operators' |
2 | import { HttpClient } from '@angular/common/http' | 2 | import { HttpClient, HttpParams } from '@angular/common/http' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { ResultList } from '../../../../../shared' | 4 | import { ResultList } from '../../../../../shared' |
5 | import { environment } from '../../../environments/environment' | 5 | import { environment } from '../../../environments/environment' |
6 | import { RestExtractor } from '../rest' | 6 | import { RestExtractor, RestService } from '../rest' |
7 | import { Observable, of } from 'rxjs' | 7 | import { Observable, ReplaySubject, Subject } from 'rxjs' |
8 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 8 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
9 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | 9 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' |
10 | import { VideoChannel as VideoChannelServer } from '../../../../../shared/models/videos' | 10 | import { VideoChannel as VideoChannelServer } from '../../../../../shared/models/videos' |
11 | 11 | ||
12 | type SubscriptionExistResult = { [ uri: string ]: boolean } | ||
13 | |||
12 | @Injectable() | 14 | @Injectable() |
13 | export class UserSubscriptionService { | 15 | export 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 |
6 | webserver: | 6 | webserver: |
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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { buildNSFWFilter } from '../../helpers/express-utils' | 2 | import { buildNSFWFilter } from '../../helpers/express-utils' |
3 | import { getFormattedObjects } from '../../helpers/utils' | 3 | import { getFormattedObjects, getServerActor } from '../../helpers/utils' |
4 | import { VideoModel } from '../../models/video/video' | 4 | import { VideoModel } from '../../models/video/video' |
5 | import { | 5 | import { |
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' |
15 | import { VideosSearchQuery } from '../../../shared/models/search' | 17 | import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search' |
16 | import { getOrCreateVideoAndAccountAndChannel } from '../../lib/activitypub' | 18 | import { getOrCreateActorAndServerAndModel, getOrCreateVideoAndAccountAndChannel } from '../../lib/activitypub' |
17 | import { logger } from '../../helpers/logger' | 19 | import { logger } from '../../helpers/logger' |
18 | import { User } from '../../../shared/models/users' | 20 | import { User } from '../../../shared/models/users' |
19 | import { CONFIG } from '../../initializers/constants' | 21 | import { CONFIG } from '../../initializers/constants' |
22 | import { VideoChannelModel } from '../../models/video/video-channel' | ||
23 | import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger' | ||
20 | 24 | ||
21 | const searchRouter = express.Router() | 25 | const 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 | ||
38 | searchRouter.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 | ||
36 | export { searchRouter } | 51 | export { searchRouter } |
37 | 52 | ||
38 | // --------------------------------------------------------------------------- | 53 | // --------------------------------------------------------------------------- |
39 | 54 | ||
55 | function 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 | |||
69 | async 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 | |||
84 | async 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 | |||
40 | function searchVideos (req: express.Request, res: express.Response) { | 103 | function 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 | ||
60 | async function searchVideoUrl (url: string, res: express.Response) { | 123 | async 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 | |||
153 | function 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' |
25 | import { AccountVideoRateModel } from '../../../models/account/account-video-rate' | 26 | import { AccountVideoRateModel } from '../../../models/account/account-video-rate' |
26 | import { UserModel } from '../../../models/account/user' | 27 | import { UserModel } from '../../../models/account/user' |
@@ -99,7 +100,6 @@ meRouter.post('/me/avatar/pick', | |||
99 | 100 | ||
100 | meRouter.get('/me/subscriptions/videos', | 101 | meRouter.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 | ||
111 | meRouter.get('/me/subscriptions/exist', | ||
112 | authenticate, | ||
113 | areSubscriptionsExistValidator, | ||
114 | asyncMiddleware(areSubscriptionsExist) | ||
115 | ) | ||
116 | |||
111 | meRouter.get('/me/subscriptions', | 117 | meRouter.get('/me/subscriptions', |
112 | authenticate, | 118 | authenticate, |
113 | paginationValidator, | 119 | paginationValidator, |
@@ -143,6 +149,37 @@ export { | |||
143 | 149 | ||
144 | // --------------------------------------------------------------------------- | 150 | // --------------------------------------------------------------------------- |
145 | 151 | ||
152 | async 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 | |||
146 | async function addUserSubscription (req: express.Request, res: express.Response) { | 183 | async 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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { getFormattedObjects } from '../../helpers/utils' | 2 | import { getFormattedObjects, getServerActor } from '../../helpers/utils' |
3 | import { | 3 | import { |
4 | asyncMiddleware, | 4 | asyncMiddleware, |
5 | asyncRetryTransactionMiddleware, | 5 | asyncRetryTransactionMiddleware, |
@@ -95,7 +95,8 @@ export { | |||
95 | // --------------------------------------------------------------------------- | 95 | // --------------------------------------------------------------------------- |
96 | 96 | ||
97 | async function listVideoChannels (req: express.Request, res: express.Response, next: express.NextFunction) { | 97 | async 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 @@ | |||
1 | import * as validator from 'validator' | 1 | import * as validator from 'validator' |
2 | import { CONSTRAINTS_FIELDS } from '../../../initializers' | 2 | import { CONSTRAINTS_FIELDS } from '../../../initializers' |
3 | import { exists } from '../misc' | 3 | import { exists, isArray } from '../misc' |
4 | import { truncate } from 'lodash' | 4 | import { truncate } from 'lodash' |
5 | import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc' | 5 | import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc' |
6 | import { isHostValid } from '../servers' | 6 | import { 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 | ||
122 | function areValidActorHandles (handles: string[]) { | ||
123 | return isArray(handles) && handles.every(h => isValidActorHandle(h)) | ||
124 | } | ||
125 | |||
122 | // --------------------------------------------------------------------------- | 126 | // --------------------------------------------------------------------------- |
123 | 127 | ||
124 | export { | 128 | export { |
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 | ||
49 | const OAUTH_LIFETIME = { | 50 | const 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' | |||
7 | import { ActorModel } from '../../../models/activitypub/actor' | 7 | import { ActorModel } from '../../../models/activitypub/actor' |
8 | import { VideoChannelModel } from '../../../models/video/video-channel' | 8 | import { VideoChannelModel } from '../../../models/video/video-channel' |
9 | import { fetchAvatarIfExists, getOrCreateActorAndServerAndModel, updateActorAvatarInstance, updateActorInstance } from '../actor' | 9 | import { fetchAvatarIfExists, getOrCreateActorAndServerAndModel, updateActorAvatarInstance, updateActorInstance } from '../actor' |
10 | import { getOrCreateVideoAndAccountAndChannel, getOrCreateVideoChannel, updateVideoFromAP } from '../videos' | 10 | import { getOrCreateVideoAndAccountAndChannel, getOrCreateVideoChannelFromVideoObject, updateVideoFromAP } from '../videos' |
11 | import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos' | 11 | import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos' |
12 | 12 | ||
13 | async function processUpdateActivity (activity: ActivityUpdate) { | 13 | async 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 | ||
177 | function getOrCreateVideoChannel (videoObject: VideoTorrentObject) { | 177 | function 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' | |||
5 | import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search' | 5 | import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search' |
6 | import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc' | 6 | import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc' |
7 | 7 | ||
8 | const searchValidator = [ | 8 | const 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 | |||
26 | const 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 | ||
62 | export { | 74 | export { |
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) | |||
8 | const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES) | 8 | const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_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_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS) | 12 | const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS) |
12 | const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) | 13 | const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) |
13 | const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS) | 14 | const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS) |
@@ -23,6 +24,7 @@ const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS) | |||
23 | const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS) | 24 | const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS) |
24 | const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS) | 25 | const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS) |
25 | const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS) | 26 | const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS) |
27 | const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS) | ||
26 | const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS) | 28 | const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS) |
27 | const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS) | 29 | const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS) |
28 | const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS) | 30 | const 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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import 'express-validator' | 2 | import 'express-validator' |
3 | import { body, param } from 'express-validator/check' | 3 | import { body, param, query } from 'express-validator/check' |
4 | import { logger } from '../../helpers/logger' | 4 | import { logger } from '../../helpers/logger' |
5 | import { areValidationErrors } from './utils' | 5 | import { areValidationErrors } from './utils' |
6 | import { ActorFollowModel } from '../../models/activitypub/actor-follow' | 6 | import { ActorFollowModel } from '../../models/activitypub/actor-follow' |
7 | import { isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor' | 7 | import { areValidActorHandles, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor' |
8 | import { UserModel } from '../../models/account/user' | 8 | import { UserModel } from '../../models/account/user' |
9 | import { CONFIG } from '../../initializers' | 9 | import { CONFIG } from '../../initializers' |
10 | import { toArray } from '../../helpers/custom-validators/misc' | ||
10 | 11 | ||
11 | const userSubscriptionAddValidator = [ | 12 | const 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 | ||
24 | const 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 | |||
23 | const userSubscriptionGetValidator = [ | 38 | const 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 | ||
53 | export { | 68 | export { |
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' | |||
26 | import { FOLLOW_STATES } from '../../initializers/constants' | 26 | import { FOLLOW_STATES } from '../../initializers/constants' |
27 | import { ServerModel } from '../server/server' | 27 | import { ServerModel } from '../server/server' |
28 | import { getSort } from '../utils' | 28 | import { getSort } from '../utils' |
29 | import { ActorModel } from './actor' | 29 | import { ActorModel, unusedActorAttributesForAPI } from './actor' |
30 | import { VideoChannelModel } from '../video/video-channel' | 30 | import { VideoChannelModel } from '../video/video-channel' |
31 | import { IIncludeOptions } from '../../../node_modules/sequelize-typescript/lib/interfaces/IIncludeOptions' | 31 | import { IIncludeOptions } from '../../../node_modules/sequelize-typescript/lib/interfaces/IIncludeOptions' |
32 | import { AccountModel } from '../account/account' | 32 | import { 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 | ||
45 | export 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' |
25 | import { sendDeleteActor } from '../../lib/activitypub/send' | 26 | import { sendDeleteActor } from '../../lib/activitypub/send' |
26 | import { AccountModel } from '../account/account' | 27 | import { AccountModel } from '../account/account' |
27 | import { ActorModel } from '../activitypub/actor' | 28 | import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' |
28 | import { getSort, throwIfNotValid } from '../utils' | 29 | import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' |
29 | import { VideoModel } from './video' | 30 | import { VideoModel } from './video' |
30 | import { CONSTRAINTS_FIELDS } from '../../initializers' | 31 | import { CONSTRAINTS_FIELDS } from '../../initializers' |
31 | import { AvatarModel } from '../avatar/avatar' | ||
32 | import { ServerModel } from '../server/server' | 32 | import { ServerModel } from '../server/server' |
33 | import { 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 | ||
36 | const indexes: DefineIndexesOptions[] = [ | ||
37 | buildTrigramSearchIndex('video_channel_name_trigram', 'name'), | ||
38 | |||
39 | { | ||
40 | fields: [ 'accountId' ] | ||
41 | }, | ||
42 | { | ||
43 | fields: [ 'actorId' ] | ||
44 | } | ||
45 | ] | ||
33 | 46 | ||
34 | enum ScopeNames { | 47 | enum 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 | ||
54 | type 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 | }) |
91 | export class VideoChannelModel extends Model<VideoChannelModel> { | 136 | export 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 | ||
18 | const expect = chai.expect | 18 | const 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 | ||
61 | function 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 | ||
63 | export { | 75 | export { |
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 @@ | |||
1 | export * from './nsfw-query.model' | 1 | export * from './nsfw-query.model' |
2 | export * from './videos-search-query.model' | 2 | export * from './videos-search-query.model' |
3 | export * 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 @@ | |||
1 | export interface VideoChannelsSearchQuery { | ||
2 | search: string | ||
3 | |||
4 | start?: number | ||
5 | count?: number | ||
6 | sort?: string | ||
7 | } | ||