diff options
32 files changed, 599 insertions, 61 deletions
diff --git a/client/src/app/+video-channels/video-channels.component.html b/client/src/app/+video-channels/video-channels.component.html index 1941a2eab..e5a32dc92 100644 --- a/client/src/app/+video-channels/video-channels.component.html +++ b/client/src/app/+video-channels/video-channels.component.html | |||
@@ -9,7 +9,7 @@ | |||
9 | <div class="actor-display-name">{{ videoChannel.displayName }}</div> | 9 | <div class="actor-display-name">{{ videoChannel.displayName }}</div> |
10 | <div class="actor-name">{{ videoChannel.nameWithHost }}</div> | 10 | <div class="actor-name">{{ videoChannel.nameWithHost }}</div> |
11 | 11 | ||
12 | <my-subscribe-button [videoChannel]="videoChannel"></my-subscribe-button> | 12 | <my-subscribe-button *ngIf="isUserLoggedIn()" [videoChannel]="videoChannel"></my-subscribe-button> |
13 | </div> | 13 | </div> |
14 | <div i18n class="actor-followers">{{ videoChannel.followersCount }} subscribers</div> | 14 | <div i18n class="actor-followers">{{ videoChannel.followersCount }} subscribers</div> |
15 | 15 | ||
diff --git a/client/src/app/+video-channels/video-channels.component.ts b/client/src/app/+video-channels/video-channels.component.ts index 57c55d286..ee2c86915 100644 --- a/client/src/app/+video-channels/video-channels.component.ts +++ b/client/src/app/+video-channels/video-channels.component.ts | |||
@@ -5,6 +5,7 @@ import { VideoChannelService } from '@app/shared/video-channel/video-channel.ser | |||
5 | import { RestExtractor } from '@app/shared' | 5 | import { RestExtractor } from '@app/shared' |
6 | import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators' | 6 | import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators' |
7 | import { Subscription } from 'rxjs' | 7 | import { Subscription } from 'rxjs' |
8 | import { AuthService } from '@app/core' | ||
8 | 9 | ||
9 | @Component({ | 10 | @Component({ |
10 | templateUrl: './video-channels.component.html', | 11 | templateUrl: './video-channels.component.html', |
@@ -17,6 +18,7 @@ export class VideoChannelsComponent implements OnInit, OnDestroy { | |||
17 | 18 | ||
18 | constructor ( | 19 | constructor ( |
19 | private route: ActivatedRoute, | 20 | private route: ActivatedRoute, |
21 | private authService: AuthService, | ||
20 | private videoChannelService: VideoChannelService, | 22 | private videoChannelService: VideoChannelService, |
21 | private restExtractor: RestExtractor | 23 | private restExtractor: RestExtractor |
22 | ) { } | 24 | ) { } |
@@ -36,4 +38,8 @@ export class VideoChannelsComponent implements OnInit, OnDestroy { | |||
36 | ngOnDestroy () { | 38 | ngOnDestroy () { |
37 | if (this.routeSub) this.routeSub.unsubscribe() | 39 | if (this.routeSub) this.routeSub.unsubscribe() |
38 | } | 40 | } |
41 | |||
42 | isUserLoggedIn () { | ||
43 | return this.authService.isLoggedIn() | ||
44 | } | ||
39 | } | 45 | } |
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html index bd03af9b3..8fe6797d6 100644 --- a/client/src/app/menu/menu.component.html +++ b/client/src/app/menu/menu.component.html | |||
@@ -47,6 +47,11 @@ | |||
47 | <ng-container i18n>Subscriptions</ng-container> | 47 | <ng-container i18n>Subscriptions</ng-container> |
48 | </a> | 48 | </a> |
49 | 49 | ||
50 | <a routerLink="/videos/overview" routerLinkActive="active"> | ||
51 | <span class="icon icon-videos-overview"></span> | ||
52 | <ng-container i18n>Overview</ng-container> | ||
53 | </a> | ||
54 | |||
50 | <a routerLink="/videos/trending" routerLinkActive="active"> | 55 | <a routerLink="/videos/trending" routerLinkActive="active"> |
51 | <span class="icon icon-videos-trending"></span> | 56 | <span class="icon icon-videos-trending"></span> |
52 | <ng-container i18n>Trending</ng-container> | 57 | <ng-container i18n>Trending</ng-container> |
diff --git a/client/src/app/menu/menu.component.scss b/client/src/app/menu/menu.component.scss index 606fea961..8539c0e56 100644 --- a/client/src/app/menu/menu.component.scss +++ b/client/src/app/menu/menu.component.scss | |||
@@ -141,6 +141,11 @@ menu { | |||
141 | background-image: url('../../assets/images/menu/subscriptions.svg'); | 141 | background-image: url('../../assets/images/menu/subscriptions.svg'); |
142 | } | 142 | } |
143 | 143 | ||
144 | &.icon-videos-overview { | ||
145 | position: relative; | ||
146 | background-image: url('../../assets/images/menu/globe.svg'); | ||
147 | } | ||
148 | |||
144 | &.icon-videos-trending { | 149 | &.icon-videos-trending { |
145 | position: relative; | 150 | position: relative; |
146 | top: -2px; | 151 | top: -2px; |
diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html index d2ed1f881..b35a46ec9 100644 --- a/client/src/app/search/search.component.html +++ b/client/src/app/search/search.component.html | |||
@@ -22,7 +22,7 @@ | |||
22 | </div> | 22 | </div> |
23 | </div> | 23 | </div> |
24 | 24 | ||
25 | <div i18n *ngIf="pagination.totalItems === 0 && results.length === 0" class="no-result"> | 25 | <div i18n *ngIf="pagination.totalItems === 0 && results.length === 0" class="no-results"> |
26 | No results found | 26 | No results found |
27 | </div> | 27 | </div> |
28 | 28 | ||
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss index e5dfddcc5..f394099e2 100644 --- a/client/src/app/search/search.component.scss +++ b/client/src/app/search/search.component.scss | |||
@@ -1,15 +1,6 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | .no-result { | ||
5 | height: 40vh; | ||
6 | display: flex; | ||
7 | align-items: center; | ||
8 | justify-content: center; | ||
9 | font-size: 16px; | ||
10 | font-weight: $font-semibold; | ||
11 | } | ||
12 | |||
13 | .search-result { | 4 | .search-result { |
14 | padding: 40px; | 5 | padding: 40px; |
15 | 6 | ||
diff --git a/client/src/app/shared/overview/index.ts b/client/src/app/shared/overview/index.ts new file mode 100644 index 000000000..2f7e41298 --- /dev/null +++ b/client/src/app/shared/overview/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './overview.service' | |||
diff --git a/client/src/app/shared/overview/overview.service.ts b/client/src/app/shared/overview/overview.service.ts new file mode 100644 index 000000000..4a4714af6 --- /dev/null +++ b/client/src/app/shared/overview/overview.service.ts | |||
@@ -0,0 +1,76 @@ | |||
1 | import { catchError, map, switchMap, tap } from 'rxjs/operators' | ||
2 | import { HttpClient } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { forkJoin, Observable, of } from 'rxjs' | ||
5 | import { VideosOverview as VideosOverviewServer, peertubeTranslate } from '../../../../../shared/models' | ||
6 | import { environment } from '../../../environments/environment' | ||
7 | import { RestExtractor } from '../rest/rest-extractor.service' | ||
8 | import { RestService } from '../rest/rest.service' | ||
9 | import { VideosOverview } from '@app/shared/overview/videos-overview.model' | ||
10 | import { VideoService } from '@app/shared/video/video.service' | ||
11 | import { ServerService } from '@app/core' | ||
12 | import { immutableAssign } from '@app/shared/misc/utils' | ||
13 | |||
14 | @Injectable() | ||
15 | export class OverviewService { | ||
16 | static BASE_OVERVIEW_URL = environment.apiUrl + '/api/v1/overviews/' | ||
17 | |||
18 | constructor ( | ||
19 | private authHttp: HttpClient, | ||
20 | private restExtractor: RestExtractor, | ||
21 | private restService: RestService, | ||
22 | private videosService: VideoService, | ||
23 | private serverService: ServerService | ||
24 | ) {} | ||
25 | |||
26 | getVideosOverview (): Observable<VideosOverview> { | ||
27 | return this.authHttp | ||
28 | .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos') | ||
29 | .pipe( | ||
30 | switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)), | ||
31 | catchError(err => this.restExtractor.handleError(err)) | ||
32 | ) | ||
33 | } | ||
34 | |||
35 | private updateVideosOverview (serverVideosOverview: VideosOverviewServer): Observable<VideosOverview> { | ||
36 | const observables: Observable<any>[] = [] | ||
37 | const videosOverviewResult: VideosOverview = { | ||
38 | tags: [], | ||
39 | categories: [], | ||
40 | channels: [] | ||
41 | } | ||
42 | |||
43 | // Build videos objects | ||
44 | for (const key of Object.keys(serverVideosOverview)) { | ||
45 | for (const object of serverVideosOverview[ key ]) { | ||
46 | observables.push( | ||
47 | of(object.videos) | ||
48 | .pipe( | ||
49 | switchMap(videos => this.videosService.extractVideos({ total: 0, data: videos })), | ||
50 | map(result => result.videos), | ||
51 | tap(videos => { | ||
52 | videosOverviewResult[key].push(immutableAssign(object, { videos })) | ||
53 | }) | ||
54 | ) | ||
55 | ) | ||
56 | } | ||
57 | } | ||
58 | |||
59 | return forkJoin(observables) | ||
60 | .pipe( | ||
61 | // Translate categories | ||
62 | switchMap(() => { | ||
63 | return this.serverService.localeObservable | ||
64 | .pipe( | ||
65 | tap(translations => { | ||
66 | for (const c of videosOverviewResult.categories) { | ||
67 | c.category.label = peertubeTranslate(c.category.label, translations) | ||
68 | } | ||
69 | }) | ||
70 | ) | ||
71 | }), | ||
72 | map(() => videosOverviewResult) | ||
73 | ) | ||
74 | } | ||
75 | |||
76 | } | ||
diff --git a/client/src/app/shared/overview/videos-overview.model.ts b/client/src/app/shared/overview/videos-overview.model.ts new file mode 100644 index 000000000..cf02bdb3d --- /dev/null +++ b/client/src/app/shared/overview/videos-overview.model.ts | |||
@@ -0,0 +1,19 @@ | |||
1 | import { VideoChannelAttribute, VideoConstant, VideosOverview as VideosOverviewServer } from '../../../../../shared/models' | ||
2 | import { Video } from '@app/shared/video/video.model' | ||
3 | |||
4 | export class VideosOverview implements VideosOverviewServer { | ||
5 | channels: { | ||
6 | channel: VideoChannelAttribute | ||
7 | videos: Video[] | ||
8 | }[] | ||
9 | |||
10 | categories: { | ||
11 | category: VideoConstant<number> | ||
12 | videos: Video[] | ||
13 | }[] | ||
14 | |||
15 | tags: { | ||
16 | tag: string | ||
17 | videos: Video[] | ||
18 | }[] | ||
19 | } | ||
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 2cbaaf4ae..b96a9aa41 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts | |||
@@ -52,6 +52,7 @@ import { ActionDropdownComponent } from '@app/shared/buttons/action-dropdown.com | |||
52 | import { NgbDropdownModule, NgbModalModule, NgbPopoverModule, NgbTabsetModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' | 52 | import { NgbDropdownModule, NgbModalModule, NgbPopoverModule, NgbTabsetModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' |
53 | import { SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription' | 53 | import { SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription' |
54 | import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component' | 54 | import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component' |
55 | import { OverviewService } from '@app/shared/overview' | ||
55 | 56 | ||
56 | @NgModule({ | 57 | @NgModule({ |
57 | imports: [ | 58 | imports: [ |
@@ -154,6 +155,7 @@ import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-fe | |||
154 | VideoValidatorsService, | 155 | VideoValidatorsService, |
155 | VideoCaptionsValidatorsService, | 156 | VideoCaptionsValidatorsService, |
156 | VideoBlacklistValidatorsService, | 157 | VideoBlacklistValidatorsService, |
158 | OverviewService, | ||
157 | 159 | ||
158 | I18nPrimengCalendarService, | 160 | I18nPrimengCalendarService, |
159 | ScreenService, | 161 | ScreenService, |
diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html index d4b00c07c..0f48b9a64 100644 --- a/client/src/app/shared/video/abstract-video-list.html +++ b/client/src/app/shared/video/abstract-video-list.html | |||
@@ -4,7 +4,7 @@ | |||
4 | </div> | 4 | </div> |
5 | <my-video-feed [syndicationItems]="syndicationItems"></my-video-feed> | 5 | <my-video-feed [syndicationItems]="syndicationItems"></my-video-feed> |
6 | 6 | ||
7 | <div i18n *ngIf="pagination.totalItems === 0">No results.</div> | 7 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div> |
8 | <div | 8 | <div |
9 | myInfiniteScroller | 9 | myInfiniteScroller |
10 | [pageHeight]="pageHeight" | 10 | [pageHeight]="pageHeight" |
@@ -12,11 +12,7 @@ | |||
12 | class="videos" #videosElement | 12 | class="videos" #videosElement |
13 | > | 13 | > |
14 | <div *ngFor="let videos of videoPages" class="videos-page"> | 14 | <div *ngFor="let videos of videoPages" class="videos-page"> |
15 | <my-video-miniature | 15 | <my-video-miniature *ngFor="let video of videos" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType"></my-video-miniature> |
16 | class="ng-animate" | ||
17 | *ngFor="let video of videos" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType" | ||
18 | > | ||
19 | </my-video-miniature> | ||
20 | </div> | 16 | </div> |
21 | </div> | 17 | </div> |
22 | </div> | 18 | </div> |
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts index 558db9543..7cc98c77a 100644 --- a/client/src/app/shared/video/video.service.ts +++ b/client/src/app/shared/video/video.service.ts | |||
@@ -51,14 +51,6 @@ export class VideoService { | |||
51 | ) | 51 | ) |
52 | } | 52 | } |
53 | 53 | ||
54 | viewVideo (uuid: string): Observable<boolean> { | ||
55 | return this.authHttp.post(this.getVideoViewUrl(uuid), {}) | ||
56 | .pipe( | ||
57 | map(this.restExtractor.extractDataBool), | ||
58 | catchError(err => this.restExtractor.handleError(err)) | ||
59 | ) | ||
60 | } | ||
61 | |||
62 | updateVideo (video: VideoEdit) { | 54 | updateVideo (video: VideoEdit) { |
63 | const language = video.language || null | 55 | const language = video.language || null |
64 | const licence = video.licence || null | 56 | const licence = video.licence || null |
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 333c9d11b..2c8305777 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.html +++ b/client/src/app/videos/+video-watch/video-watch.component.html | |||
@@ -38,7 +38,7 @@ | |||
38 | Published {{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views | 38 | Published {{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views |
39 | </div> | 39 | </div> |
40 | </div> | 40 | </div> |
41 | 41 | ||
42 | <div class="d-flex justify-content-between align-items-sm-end"> | 42 | <div class="d-flex justify-content-between align-items-sm-end"> |
43 | <div class="d-none d-sm-block"> | 43 | <div class="d-none d-sm-block"> |
44 | <div class="video-info-name">{{ video.name }}</div> | 44 | <div class="video-info-name">{{ video.name }}</div> |
@@ -46,7 +46,7 @@ | |||
46 | <div i18n class="video-info-date-views"> | 46 | <div i18n class="video-info-date-views"> |
47 | Published {{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views | 47 | Published {{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views |
48 | </div> | 48 | </div> |
49 | </div> | 49 | </div> |
50 | 50 | ||
51 | <div class="video-actions-rates"> | 51 | <div class="video-actions-rates"> |
52 | <div class="video-actions fullWidth justify-content-end"> | 52 | <div class="video-actions fullWidth justify-content-end"> |
@@ -56,57 +56,57 @@ | |||
56 | > | 56 | > |
57 | <span class="icon icon-like" i18n-title title="Like this video" ></span> | 57 | <span class="icon icon-like" i18n-title title="Like this video" ></span> |
58 | </div> | 58 | </div> |
59 | 59 | ||
60 | <div | 60 | <div |
61 | *ngIf="isUserLoggedIn()" [ngClass]="{ 'activated': userRating === 'dislike' }" (click)="setDislike()" | 61 | *ngIf="isUserLoggedIn()" [ngClass]="{ 'activated': userRating === 'dislike' }" (click)="setDislike()" |
62 | class="action-button action-button-dislike" role="button" [attr.aria-pressed]="userRating === 'dislike'" | 62 | class="action-button action-button-dislike" role="button" [attr.aria-pressed]="userRating === 'dislike'" |
63 | > | 63 | > |
64 | <span class="icon icon-dislike" i18n-title title="Dislike this video"></span> | 64 | <span class="icon icon-dislike" i18n-title title="Dislike this video"></span> |
65 | </div> | 65 | </div> |
66 | 66 | ||
67 | <div *ngIf="video.support" (click)="showSupportModal()" class="action-button action-button-support"> | 67 | <div *ngIf="video.support" (click)="showSupportModal()" class="action-button action-button-support"> |
68 | <span class="icon icon-support"></span> | 68 | <span class="icon icon-support"></span> |
69 | <span class="icon-text" i18n>Support</span> | 69 | <span class="icon-text" i18n>Support</span> |
70 | </div> | 70 | </div> |
71 | 71 | ||
72 | <div (click)="showShareModal()" class="action-button action-button-share" role="button"> | 72 | <div (click)="showShareModal()" class="action-button action-button-share" role="button"> |
73 | <span class="icon icon-share"></span> | 73 | <span class="icon icon-share"></span> |
74 | <span class="icon-text" i18n>Share</span> | 74 | <span class="icon-text" i18n>Share</span> |
75 | </div> | 75 | </div> |
76 | 76 | ||
77 | <div class="action-more" ngbDropdown placement="top" role="button"> | 77 | <div class="action-more" ngbDropdown placement="top" role="button"> |
78 | <div class="action-button" ngbDropdownToggle role="button"> | 78 | <div class="action-button" ngbDropdownToggle role="button"> |
79 | <span class="icon icon-more"></span> | 79 | <span class="icon icon-more"></span> |
80 | </div> | 80 | </div> |
81 | 81 | ||
82 | <div ngbDropdownMenu> | 82 | <div ngbDropdownMenu> |
83 | <a class="dropdown-item" i18n-title title="Download the video" href="#" (click)="showDownloadModal($event)"> | 83 | <a class="dropdown-item" i18n-title title="Download the video" href="#" (click)="showDownloadModal($event)"> |
84 | <span class="icon icon-download"></span> <ng-container i18n>Download</ng-container> | 84 | <span class="icon icon-download"></span> <ng-container i18n>Download</ng-container> |
85 | </a> | 85 | </a> |
86 | 86 | ||
87 | <a *ngIf="isUserLoggedIn()" class="dropdown-item" i18n-title title="Report this video" href="#" (click)="showReportModal($event)"> | 87 | <a *ngIf="isUserLoggedIn()" class="dropdown-item" i18n-title title="Report this video" href="#" (click)="showReportModal($event)"> |
88 | <span class="icon icon-alert"></span> <ng-container i18n>Report</ng-container> | 88 | <span class="icon icon-alert"></span> <ng-container i18n>Report</ng-container> |
89 | </a> | 89 | </a> |
90 | 90 | ||
91 | <a *ngIf="isVideoUpdatable()" class="dropdown-item" i18n-title title="Update this video" href="#" [routerLink]="[ '/videos/update', video.uuid ]"> | 91 | <a *ngIf="isVideoUpdatable()" class="dropdown-item" i18n-title title="Update this video" href="#" [routerLink]="[ '/videos/update', video.uuid ]"> |
92 | <span class="icon icon-edit"></span> <ng-container i18n>Update</ng-container> | 92 | <span class="icon icon-edit"></span> <ng-container i18n>Update</ng-container> |
93 | </a> | 93 | </a> |
94 | 94 | ||
95 | <a *ngIf="isVideoBlacklistable()" class="dropdown-item" i18n-title title="Blacklist this video" href="#" (click)="showBlacklistModal($event)"> | 95 | <a *ngIf="isVideoBlacklistable()" class="dropdown-item" i18n-title title="Blacklist this video" href="#" (click)="showBlacklistModal($event)"> |
96 | <span class="icon icon-blacklist"></span> <ng-container i18n>Blacklist</ng-container> | 96 | <span class="icon icon-blacklist"></span> <ng-container i18n>Blacklist</ng-container> |
97 | </a> | 97 | </a> |
98 | 98 | ||
99 | <a *ngIf="isVideoUnblacklistable()" class="dropdown-item" i18n-title title="Unblacklist this video" href="#" (click)="unblacklistVideo($event)"> | 99 | <a *ngIf="isVideoUnblacklistable()" class="dropdown-item" i18n-title title="Unblacklist this video" href="#" (click)="unblacklistVideo($event)"> |
100 | <span class="icon icon-unblacklist"></span> <ng-container i18n>Unblacklist</ng-container> | 100 | <span class="icon icon-unblacklist"></span> <ng-container i18n>Unblacklist</ng-container> |
101 | </a> | 101 | </a> |
102 | 102 | ||
103 | <a *ngIf="isVideoRemovable()" class="dropdown-item" i18n-title title="Delete this video" href="#" (click)="removeVideo($event)"> | 103 | <a *ngIf="isVideoRemovable()" class="dropdown-item" i18n-title title="Delete this video" href="#" (click)="removeVideo($event)"> |
104 | <span class="icon icon-delete"></span> <ng-container i18n>Delete</ng-container> | 104 | <span class="icon icon-delete"></span> <ng-container i18n>Delete</ng-container> |
105 | </a> | 105 | </a> |
106 | </div> | 106 | </div> |
107 | </div> | 107 | </div> |
108 | </div> | 108 | </div> |
109 | 109 | ||
110 | <div | 110 | <div |
111 | class="video-info-likes-dislikes-bar" | 111 | class="video-info-likes-dislikes-bar" |
112 | *ngIf="video.likes !== 0 || video.dislikes !== 0" | 112 | *ngIf="video.likes !== 0 || video.dislikes !== 0" |
@@ -125,7 +125,7 @@ | |||
125 | <img [src]="video.videoChannelAvatarUrl" alt="Video channel avatar" /> | 125 | <img [src]="video.videoChannelAvatarUrl" alt="Video channel avatar" /> |
126 | </a> | 126 | </a> |
127 | 127 | ||
128 | <my-subscribe-button [videoChannel]="video.channel" size="small"></my-subscribe-button> | 128 | <my-subscribe-button *ngIf="isUserLoggedIn()" [videoChannel]="video.channel" size="small"></my-subscribe-button> |
129 | </div> | 129 | </div> |
130 | 130 | ||
131 | <div class="video-info-by"> | 131 | <div class="video-info-by"> |
diff --git a/client/src/app/videos/video-list/video-overview.component.html b/client/src/app/videos/video-list/video-overview.component.html new file mode 100644 index 000000000..9282dd59c --- /dev/null +++ b/client/src/app/videos/video-list/video-overview.component.html | |||
@@ -0,0 +1,35 @@ | |||
1 | <div class="margin-content"> | ||
2 | |||
3 | <div class="no-results" i18n *ngIf="notResults">No results.</div> | ||
4 | |||
5 | <div class="section" *ngFor="let object of overview.categories"> | ||
6 | <div class="section-title" i18n> | ||
7 | <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">Category {{ object.category.label }}</a> | ||
8 | </div> | ||
9 | |||
10 | <div> | ||
11 | <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature> | ||
12 | </div> | ||
13 | </div> | ||
14 | |||
15 | <div class="section" *ngFor="let object of overview.tags"> | ||
16 | <div class="section-title" i18n> | ||
17 | <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">Tag {{ object.tag }}</a> | ||
18 | </div> | ||
19 | |||
20 | <div> | ||
21 | <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature> | ||
22 | </div> | ||
23 | </div> | ||
24 | |||
25 | <div class="section" *ngFor="let object of overview.channels"> | ||
26 | <div class="section-title" i18n> | ||
27 | <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]">Channel {{ object.channel.displayName }}</a> | ||
28 | </div> | ||
29 | |||
30 | <div> | ||
31 | <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature> | ||
32 | </div> | ||
33 | </div> | ||
34 | |||
35 | </div> | ||
diff --git a/client/src/app/videos/video-list/video-overview.component.scss b/client/src/app/videos/video-list/video-overview.component.scss new file mode 100644 index 000000000..8d66cf80a --- /dev/null +++ b/client/src/app/videos/video-list/video-overview.component.scss | |||
@@ -0,0 +1,22 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .section { | ||
5 | padding-top: 10px; | ||
6 | |||
7 | &:first-child { | ||
8 | padding-top: 30px; | ||
9 | } | ||
10 | } | ||
11 | |||
12 | .section-title { | ||
13 | font-size: 17px; | ||
14 | font-weight: $font-semibold; | ||
15 | margin-bottom: 20px; | ||
16 | |||
17 | a { | ||
18 | @include disable-default-a-behaviour; | ||
19 | |||
20 | color: #000; | ||
21 | } | ||
22 | } \ No newline at end of file | ||
diff --git a/client/src/app/videos/video-list/video-overview.component.ts b/client/src/app/videos/video-list/video-overview.component.ts new file mode 100644 index 000000000..c758e115c --- /dev/null +++ b/client/src/app/videos/video-list/video-overview.component.ts | |||
@@ -0,0 +1,56 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | ||
2 | import { AuthService } from '@app/core' | ||
3 | import { NotificationsService } from 'angular2-notifications' | ||
4 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
5 | import { VideosOverview } from '@app/shared/overview/videos-overview.model' | ||
6 | import { OverviewService } from '@app/shared/overview' | ||
7 | import { Video } from '@app/shared/video/video.model' | ||
8 | |||
9 | @Component({ | ||
10 | selector: 'my-video-overview', | ||
11 | templateUrl: './video-overview.component.html', | ||
12 | styleUrls: [ './video-overview.component.scss' ] | ||
13 | }) | ||
14 | export class VideoOverviewComponent implements OnInit { | ||
15 | overview: VideosOverview = { | ||
16 | categories: [], | ||
17 | channels: [], | ||
18 | tags: [] | ||
19 | } | ||
20 | notResults = false | ||
21 | |||
22 | constructor ( | ||
23 | private i18n: I18n, | ||
24 | private notificationsService: NotificationsService, | ||
25 | private authService: AuthService, | ||
26 | private overviewService: OverviewService | ||
27 | ) { } | ||
28 | |||
29 | get user () { | ||
30 | return this.authService.getUser() | ||
31 | } | ||
32 | |||
33 | ngOnInit () { | ||
34 | this.overviewService.getVideosOverview() | ||
35 | .subscribe( | ||
36 | overview => { | ||
37 | this.overview = overview | ||
38 | |||
39 | if ( | ||
40 | this.overview.categories.length === 0 && | ||
41 | this.overview.channels.length === 0 && | ||
42 | this.overview.tags.length === 0 | ||
43 | ) this.notResults = true | ||
44 | }, | ||
45 | |||
46 | err => { | ||
47 | console.log(err) | ||
48 | this.notificationsService.error('Error', err.text) | ||
49 | } | ||
50 | ) | ||
51 | } | ||
52 | |||
53 | buildVideoChannelBy (object: { videos: Video[] }) { | ||
54 | return object.videos[0].byVideoChannel | ||
55 | } | ||
56 | } | ||
diff --git a/client/src/app/videos/videos-routing.module.ts b/client/src/app/videos/videos-routing.module.ts index 18ed52570..58988ffd1 100644 --- a/client/src/app/videos/videos-routing.module.ts +++ b/client/src/app/videos/videos-routing.module.ts | |||
@@ -6,6 +6,7 @@ import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.c | |||
6 | import { VideoTrendingComponent } from './video-list/video-trending.component' | 6 | import { VideoTrendingComponent } from './video-list/video-trending.component' |
7 | import { VideosComponent } from './videos.component' | 7 | import { VideosComponent } from './videos.component' |
8 | import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component' | 8 | import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component' |
9 | import { VideoOverviewComponent } from '@app/videos/video-list/video-overview.component' | ||
9 | 10 | ||
10 | const videosRoutes: Routes = [ | 11 | const videosRoutes: Routes = [ |
11 | { | 12 | { |
@@ -14,6 +15,15 @@ const videosRoutes: Routes = [ | |||
14 | canActivateChild: [ MetaGuard ], | 15 | canActivateChild: [ MetaGuard ], |
15 | children: [ | 16 | children: [ |
16 | { | 17 | { |
18 | path: 'overview', | ||
19 | component: VideoOverviewComponent, | ||
20 | data: { | ||
21 | meta: { | ||
22 | title: 'Videos overview' | ||
23 | } | ||
24 | } | ||
25 | }, | ||
26 | { | ||
17 | path: 'trending', | 27 | path: 'trending', |
18 | component: VideoTrendingComponent, | 28 | component: VideoTrendingComponent, |
19 | data: { | 29 | data: { |
diff --git a/client/src/app/videos/videos.module.ts b/client/src/app/videos/videos.module.ts index 3c3877273..5cf1e944f 100644 --- a/client/src/app/videos/videos.module.ts +++ b/client/src/app/videos/videos.module.ts | |||
@@ -6,6 +6,7 @@ import { VideoTrendingComponent } from './video-list/video-trending.component' | |||
6 | import { VideosRoutingModule } from './videos-routing.module' | 6 | import { VideosRoutingModule } from './videos-routing.module' |
7 | import { VideosComponent } from './videos.component' | 7 | import { VideosComponent } from './videos.component' |
8 | import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component' | 8 | import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component' |
9 | import { VideoOverviewComponent } from '@app/videos/video-list/video-overview.component' | ||
9 | 10 | ||
10 | @NgModule({ | 11 | @NgModule({ |
11 | imports: [ | 12 | imports: [ |
@@ -19,7 +20,8 @@ import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-us | |||
19 | VideoTrendingComponent, | 20 | VideoTrendingComponent, |
20 | VideoRecentlyAddedComponent, | 21 | VideoRecentlyAddedComponent, |
21 | VideoLocalComponent, | 22 | VideoLocalComponent, |
22 | VideoUserSubscriptionsComponent | 23 | VideoUserSubscriptionsComponent, |
24 | VideoOverviewComponent | ||
23 | ], | 25 | ], |
24 | 26 | ||
25 | exports: [ | 27 | exports: [ |
diff --git a/client/src/assets/images/menu/globe.svg b/client/src/assets/images/menu/globe.svg new file mode 100644 index 000000000..a4b3db9c5 --- /dev/null +++ b/client/src/assets/images/menu/globe.svg | |||
@@ -0,0 +1,18 @@ | |||
1 | <?xml version="1.0" encoding="UTF-8"?> | ||
2 | <svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | ||
3 | <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch --> | ||
4 | <title>globe</title> | ||
5 | <desc>Created with Sketch.</desc> | ||
6 | <defs></defs> | ||
7 | <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> | ||
8 | <g id="Artboard-4" transform="translate(-224.000000, -687.000000)" stroke="#808080" stroke-width="2"> | ||
9 | <g id="265" transform="translate(224.000000, 687.000000)"> | ||
10 | <circle id="Oval-148" cx="12" cy="12" r="10"></circle> | ||
11 | <path d="M12,2 L12,22.006249" id="Path-199"></path> | ||
12 | <path d="M12,2 C12,2 17,4 17,12.0031245 C17,20.006249 12,22.006249 12,22.006249" id="Path-199"></path> | ||
13 | <path d="M7,2 C7,2 12,4 12,12.0031245 C12,20.006249 7,22.006249 7,22.006249" id="Path-199" transform="translate(9.500000, 12.003125) scale(-1, 1) translate(-9.500000, -12.003125) "></path> | ||
14 | <path d="M2,12 L22,12" id="Path-201"></path> | ||
15 | </g> | ||
16 | </g> | ||
17 | </g> | ||
18 | </svg> | ||
diff --git a/client/src/sass/application.scss b/client/src/sass/application.scss index 21df23c18..38b7ea8d4 100644 --- a/client/src/sass/application.scss +++ b/client/src/sass/application.scss | |||
@@ -293,6 +293,15 @@ table { | |||
293 | } | 293 | } |
294 | } | 294 | } |
295 | 295 | ||
296 | .no-results { | ||
297 | height: 40vh; | ||
298 | display: flex; | ||
299 | align-items: center; | ||
300 | justify-content: center; | ||
301 | font-size: 16px; | ||
302 | font-weight: $font-semibold; | ||
303 | } | ||
304 | |||
296 | @media screen and (max-width: 900px) { | 305 | @media screen and (max-width: 900px) { |
297 | .main-col { | 306 | .main-col { |
298 | &, &.expanded { | 307 | &, &.expanded { |
diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts index e928a7478..8a58b5466 100644 --- a/server/controllers/api/index.ts +++ b/server/controllers/api/index.ts | |||
@@ -10,6 +10,7 @@ import { badRequest } from '../../helpers/express-utils' | |||
10 | import { videoChannelRouter } from './video-channel' | 10 | import { videoChannelRouter } from './video-channel' |
11 | import * as cors from 'cors' | 11 | import * as cors from 'cors' |
12 | import { searchRouter } from './search' | 12 | import { searchRouter } from './search' |
13 | import { overviewsRouter } from './overviews' | ||
13 | 14 | ||
14 | const apiRouter = express.Router() | 15 | const apiRouter = express.Router() |
15 | 16 | ||
@@ -28,6 +29,7 @@ apiRouter.use('/video-channels', videoChannelRouter) | |||
28 | apiRouter.use('/videos', videosRouter) | 29 | apiRouter.use('/videos', videosRouter) |
29 | apiRouter.use('/jobs', jobsRouter) | 30 | apiRouter.use('/jobs', jobsRouter) |
30 | apiRouter.use('/search', searchRouter) | 31 | apiRouter.use('/search', searchRouter) |
32 | apiRouter.use('/overviews', overviewsRouter) | ||
31 | apiRouter.use('/ping', pong) | 33 | apiRouter.use('/ping', pong) |
32 | apiRouter.use('/*', badRequest) | 34 | apiRouter.use('/*', badRequest) |
33 | 35 | ||
diff --git a/server/controllers/api/overviews.ts b/server/controllers/api/overviews.ts new file mode 100644 index 000000000..56f921ce5 --- /dev/null +++ b/server/controllers/api/overviews.ts | |||
@@ -0,0 +1,97 @@ | |||
1 | import * as express from 'express' | ||
2 | import { buildNSFWFilter } from '../../helpers/express-utils' | ||
3 | import { VideoModel } from '../../models/video/video' | ||
4 | import { asyncMiddleware, executeIfActivityPub } from '../../middlewares' | ||
5 | import { TagModel } from '../../models/video/tag' | ||
6 | import { VideosOverview } from '../../../shared/models/overviews' | ||
7 | import { OVERVIEWS, ROUTE_CACHE_LIFETIME } from '../../initializers' | ||
8 | import { cacheRoute } from '../../middlewares/cache' | ||
9 | |||
10 | const overviewsRouter = express.Router() | ||
11 | |||
12 | overviewsRouter.get('/videos', | ||
13 | executeIfActivityPub(asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.OVERVIEWS.VIDEOS))), | ||
14 | asyncMiddleware(getVideosOverview) | ||
15 | ) | ||
16 | |||
17 | // --------------------------------------------------------------------------- | ||
18 | |||
19 | export { overviewsRouter } | ||
20 | |||
21 | // --------------------------------------------------------------------------- | ||
22 | |||
23 | // This endpoint could be quite long, but we cache it | ||
24 | async function getVideosOverview (req: express.Request, res: express.Response) { | ||
25 | const attributes = await buildSamples() | ||
26 | const result: VideosOverview = { | ||
27 | categories: await Promise.all(attributes.categories.map(c => getVideosByCategory(c, res))), | ||
28 | channels: await Promise.all(attributes.channels.map(c => getVideosByChannel(c, res))), | ||
29 | tags: await Promise.all(attributes.tags.map(t => getVideosByTag(t, res))) | ||
30 | } | ||
31 | |||
32 | // Cleanup our object | ||
33 | for (const key of Object.keys(result)) { | ||
34 | result[key] = result[key].filter(v => v !== undefined) | ||
35 | } | ||
36 | |||
37 | return res.json(result) | ||
38 | } | ||
39 | |||
40 | async function buildSamples () { | ||
41 | const [ categories, channels, tags ] = await Promise.all([ | ||
42 | VideoModel.getRandomFieldSamples('category', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT), | ||
43 | VideoModel.getRandomFieldSamples('channelId', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD ,OVERVIEWS.VIDEOS.SAMPLES_COUNT), | ||
44 | TagModel.getRandomSamples(OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT) | ||
45 | ]) | ||
46 | |||
47 | return { categories, channels, tags } | ||
48 | } | ||
49 | |||
50 | async function getVideosByTag (tag: string, res: express.Response) { | ||
51 | const videos = await getVideos(res, { tagsOneOf: [ tag ] }) | ||
52 | |||
53 | if (videos.length === 0) return undefined | ||
54 | |||
55 | return { | ||
56 | tag, | ||
57 | videos | ||
58 | } | ||
59 | } | ||
60 | |||
61 | async function getVideosByCategory (category: number, res: express.Response) { | ||
62 | const videos = await getVideos(res, { categoryOneOf: [ category ] }) | ||
63 | |||
64 | if (videos.length === 0) return undefined | ||
65 | |||
66 | return { | ||
67 | category: videos[0].category, | ||
68 | videos | ||
69 | } | ||
70 | } | ||
71 | |||
72 | async function getVideosByChannel (channelId: number, res: express.Response) { | ||
73 | const videos = await getVideos(res, { videoChannelId: channelId }) | ||
74 | |||
75 | if (videos.length === 0) return undefined | ||
76 | |||
77 | return { | ||
78 | channel: videos[0].channel, | ||
79 | videos | ||
80 | } | ||
81 | } | ||
82 | |||
83 | async function getVideos ( | ||
84 | res: express.Response, | ||
85 | where: { videoChannelId?: number, tagsOneOf?: string[], categoryOneOf?: number[] } | ||
86 | ) { | ||
87 | const { data } = await VideoModel.listForApi(Object.assign({ | ||
88 | start: 0, | ||
89 | count: 10, | ||
90 | sort: '-createdAt', | ||
91 | includeLocalVideos: true, | ||
92 | nsfw: buildNSFWFilter(res), | ||
93 | withFiles: false | ||
94 | }, where)) | ||
95 | |||
96 | return data.map(d => d.toFormattedJSON()) | ||
97 | } | ||
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 5d93c6b82..16d8dca68 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -58,6 +58,9 @@ const ROUTE_CACHE_LIFETIME = { | |||
58 | ROBOTS: '2 hours', | 58 | ROBOTS: '2 hours', |
59 | NODEINFO: '10 minutes', | 59 | NODEINFO: '10 minutes', |
60 | DNT_POLICY: '1 week', | 60 | DNT_POLICY: '1 week', |
61 | OVERVIEWS: { | ||
62 | VIDEOS: '1 hour' | ||
63 | }, | ||
61 | ACTIVITY_PUB: { | 64 | ACTIVITY_PUB: { |
62 | VIDEOS: '1 second' // 1 second, cache concurrent requests after a broadcast for example | 65 | VIDEOS: '1 second' // 1 second, cache concurrent requests after a broadcast for example |
63 | } | 66 | } |
@@ -464,6 +467,15 @@ const TORRENT_MIMETYPE_EXT = { | |||
464 | 467 | ||
465 | // --------------------------------------------------------------------------- | 468 | // --------------------------------------------------------------------------- |
466 | 469 | ||
470 | const OVERVIEWS = { | ||
471 | VIDEOS: { | ||
472 | SAMPLE_THRESHOLD: 4, | ||
473 | SAMPLES_COUNT: 2 | ||
474 | } | ||
475 | } | ||
476 | |||
477 | // --------------------------------------------------------------------------- | ||
478 | |||
467 | const SERVER_ACTOR_NAME = 'peertube' | 479 | const SERVER_ACTOR_NAME = 'peertube' |
468 | 480 | ||
469 | const ACTIVITY_PUB = { | 481 | const ACTIVITY_PUB = { |
@@ -666,6 +678,7 @@ export { | |||
666 | USER_PASSWORD_RESET_LIFETIME, | 678 | USER_PASSWORD_RESET_LIFETIME, |
667 | USER_EMAIL_VERIFY_LIFETIME, | 679 | USER_EMAIL_VERIFY_LIFETIME, |
668 | IMAGE_MIMETYPE_EXT, | 680 | IMAGE_MIMETYPE_EXT, |
681 | OVERVIEWS, | ||
669 | SCHEDULER_INTERVALS_MS, | 682 | SCHEDULER_INTERVALS_MS, |
670 | REPEAT_JOBS, | 683 | REPEAT_JOBS, |
671 | STATIC_DOWNLOAD_PATHS, | 684 | STATIC_DOWNLOAD_PATHS, |
diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts index 6d79a5575..e39a418cd 100644 --- a/server/models/video/tag.ts +++ b/server/models/video/tag.ts | |||
@@ -1,10 +1,11 @@ | |||
1 | import * as Bluebird from 'bluebird' | 1 | import * as Bluebird from 'bluebird' |
2 | import { Transaction } from 'sequelize' | 2 | import * as Sequelize from 'sequelize' |
3 | import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | 3 | import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' |
4 | import { isVideoTagValid } from '../../helpers/custom-validators/videos' | 4 | import { isVideoTagValid } from '../../helpers/custom-validators/videos' |
5 | import { throwIfNotValid } from '../utils' | 5 | import { throwIfNotValid } from '../utils' |
6 | import { VideoModel } from './video' | 6 | import { VideoModel } from './video' |
7 | import { VideoTagModel } from './video-tag' | 7 | import { VideoTagModel } from './video-tag' |
8 | import { VideoPrivacy, VideoState } from '../../../shared/models/videos' | ||
8 | 9 | ||
9 | @Table({ | 10 | @Table({ |
10 | tableName: 'tag', | 11 | tableName: 'tag', |
@@ -36,7 +37,7 @@ export class TagModel extends Model<TagModel> { | |||
36 | }) | 37 | }) |
37 | Videos: VideoModel[] | 38 | Videos: VideoModel[] |
38 | 39 | ||
39 | static findOrCreateTags (tags: string[], transaction: Transaction) { | 40 | static findOrCreateTags (tags: string[], transaction: Sequelize.Transaction) { |
40 | if (tags === null) return [] | 41 | if (tags === null) return [] |
41 | 42 | ||
42 | const tasks: Bluebird<TagModel>[] = [] | 43 | const tasks: Bluebird<TagModel>[] = [] |
@@ -59,4 +60,23 @@ export class TagModel extends Model<TagModel> { | |||
59 | 60 | ||
60 | return Promise.all(tasks) | 61 | return Promise.all(tasks) |
61 | } | 62 | } |
63 | |||
64 | // threshold corresponds to how many video the field should have to be returned | ||
65 | static getRandomSamples (threshold: number, count: number): Bluebird<string[]> { | ||
66 | const query = 'SELECT tag.name FROM tag ' + | ||
67 | 'INNER JOIN "videoTag" ON "videoTag"."tagId" = tag.id ' + | ||
68 | 'INNER JOIN video ON video.id = "videoTag"."videoId" ' + | ||
69 | 'WHERE video.privacy = $videoPrivacy AND video.state = $videoState ' + | ||
70 | 'GROUP BY tag.name HAVING COUNT(tag.name) >= $threshold ' + | ||
71 | 'ORDER BY random() ' + | ||
72 | 'LIMIT $count' | ||
73 | |||
74 | const options = { | ||
75 | bind: { threshold, count, videoPrivacy: VideoPrivacy.PUBLIC, videoState: VideoState.PUBLISHED }, | ||
76 | type: Sequelize.QueryTypes.SELECT | ||
77 | } | ||
78 | |||
79 | return TagModel.sequelize.query(query, options) | ||
80 | .then(data => data.map(d => d.name)) | ||
81 | } | ||
62 | } | 82 | } |
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 3410833c8..695990b17 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -1083,6 +1083,29 @@ export class VideoModel extends Model<VideoModel> { | |||
1083 | }) | 1083 | }) |
1084 | } | 1084 | } |
1085 | 1085 | ||
1086 | // threshold corresponds to how many video the field should have to be returned | ||
1087 | static getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) { | ||
1088 | const query: IFindOptions<VideoModel> = { | ||
1089 | attributes: [ field ], | ||
1090 | limit: count, | ||
1091 | group: field, | ||
1092 | having: Sequelize.where(Sequelize.fn('COUNT', Sequelize.col(field)), { | ||
1093 | [Sequelize.Op.gte]: threshold | ||
1094 | }) as any, // FIXME: typings | ||
1095 | where: { | ||
1096 | [field]: { | ||
1097 | [Sequelize.Op.not]: null, | ||
1098 | }, | ||
1099 | privacy: VideoPrivacy.PUBLIC, | ||
1100 | state: VideoState.PUBLISHED | ||
1101 | }, | ||
1102 | order: [ this.sequelize.random() ] | ||
1103 | } | ||
1104 | |||
1105 | return VideoModel.findAll(query) | ||
1106 | .then(rows => rows.map(r => r[field])) | ||
1107 | } | ||
1108 | |||
1086 | private static buildActorWhereWithFilter (filter?: VideoFilter) { | 1109 | private static buildActorWhereWithFilter (filter?: VideoFilter) { |
1087 | if (filter && filter === 'local') { | 1110 | if (filter && filter === 'local') { |
1088 | return { | 1111 | return { |
diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts index bc66a7824..8286ff356 100644 --- a/server/tests/api/videos/index.ts +++ b/server/tests/api/videos/index.ts | |||
@@ -13,3 +13,4 @@ import './video-nsfw' | |||
13 | import './video-privacy' | 13 | import './video-privacy' |
14 | import './video-schedule-update' | 14 | import './video-schedule-update' |
15 | import './video-transcoder' | 15 | import './video-transcoder' |
16 | import './videos-overview' | ||
diff --git a/server/tests/api/videos/videos-overview.ts b/server/tests/api/videos/videos-overview.ts new file mode 100644 index 000000000..1514d1bda --- /dev/null +++ b/server/tests/api/videos/videos-overview.ts | |||
@@ -0,0 +1,96 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { flushTests, killallServers, runServer, ServerInfo, setAccessTokensToServers, uploadVideo } from '../../utils' | ||
6 | import { getVideosOverview } from '../../utils/overviews/overviews' | ||
7 | import { VideosOverview } from '../../../../shared/models/overviews' | ||
8 | |||
9 | const expect = chai.expect | ||
10 | |||
11 | describe('Test a videos overview', function () { | ||
12 | let server: ServerInfo = null | ||
13 | |||
14 | before(async function () { | ||
15 | this.timeout(30000) | ||
16 | |||
17 | await flushTests() | ||
18 | |||
19 | server = await runServer(1) | ||
20 | |||
21 | await setAccessTokensToServers([ server ]) | ||
22 | }) | ||
23 | |||
24 | it('Should send empty overview', async function () { | ||
25 | const res = await getVideosOverview(server.url) | ||
26 | |||
27 | const overview: VideosOverview = res.body | ||
28 | expect(overview.tags).to.have.lengthOf(0) | ||
29 | expect(overview.categories).to.have.lengthOf(0) | ||
30 | expect(overview.channels).to.have.lengthOf(0) | ||
31 | }) | ||
32 | |||
33 | it('Should upload 3 videos in a specific category, tag and channel but not include them in overview', async function () { | ||
34 | for (let i = 0; i < 3; i++) { | ||
35 | await uploadVideo(server.url, server.accessToken, { | ||
36 | name: 'video ' + i, | ||
37 | category: 3, | ||
38 | tags: [ 'coucou1', 'coucou2' ] | ||
39 | }) | ||
40 | } | ||
41 | |||
42 | const res = await getVideosOverview(server.url) | ||
43 | |||
44 | const overview: VideosOverview = res.body | ||
45 | expect(overview.tags).to.have.lengthOf(0) | ||
46 | expect(overview.categories).to.have.lengthOf(0) | ||
47 | expect(overview.channels).to.have.lengthOf(0) | ||
48 | }) | ||
49 | |||
50 | it('Should upload another video and include all videos in the overview', async function () { | ||
51 | await uploadVideo(server.url, server.accessToken, { | ||
52 | name: 'video 3', | ||
53 | category: 3, | ||
54 | tags: [ 'coucou1', 'coucou2' ] | ||
55 | }) | ||
56 | |||
57 | const res = await getVideosOverview(server.url) | ||
58 | |||
59 | const overview: VideosOverview = res.body | ||
60 | expect(overview.tags).to.have.lengthOf(2) | ||
61 | expect(overview.categories).to.have.lengthOf(1) | ||
62 | expect(overview.channels).to.have.lengthOf(1) | ||
63 | }) | ||
64 | |||
65 | it('Should have the correct overview', async function () { | ||
66 | const res = await getVideosOverview(server.url) | ||
67 | |||
68 | const overview: VideosOverview = res.body | ||
69 | |||
70 | for (const attr of [ 'tags', 'categories', 'channels' ]) { | ||
71 | const obj = overview[attr][0] | ||
72 | |||
73 | expect(obj.videos).to.have.lengthOf(4) | ||
74 | expect(obj.videos[0].name).to.equal('video 3') | ||
75 | expect(obj.videos[1].name).to.equal('video 2') | ||
76 | expect(obj.videos[2].name).to.equal('video 1') | ||
77 | expect(obj.videos[3].name).to.equal('video 0') | ||
78 | } | ||
79 | |||
80 | expect(overview.tags.find(t => t.tag === 'coucou1')).to.not.be.undefined | ||
81 | expect(overview.tags.find(t => t.tag === 'coucou2')).to.not.be.undefined | ||
82 | |||
83 | expect(overview.categories[0].category.id).to.equal(3) | ||
84 | |||
85 | expect(overview.channels[0].channel.name).to.equal('root_channel') | ||
86 | }) | ||
87 | |||
88 | after(async function () { | ||
89 | killallServers([ server ]) | ||
90 | |||
91 | // Keep the logs if the test failed | ||
92 | if (this['ok']) { | ||
93 | await flushTests() | ||
94 | } | ||
95 | }) | ||
96 | }) | ||
diff --git a/server/tests/utils/overviews/overviews.ts b/server/tests/utils/overviews/overviews.ts new file mode 100644 index 000000000..23e3ceb1e --- /dev/null +++ b/server/tests/utils/overviews/overviews.ts | |||
@@ -0,0 +1,18 @@ | |||
1 | import { makeGetRequest } from '../requests/requests' | ||
2 | |||
3 | function getVideosOverview (url: string, useCache = false) { | ||
4 | const path = '/api/v1/overviews/videos' | ||
5 | |||
6 | const query = { | ||
7 | t: useCache ? undefined : new Date().getTime() | ||
8 | } | ||
9 | |||
10 | return makeGetRequest({ | ||
11 | url, | ||
12 | path, | ||
13 | query, | ||
14 | statusCodeExpected: 200 | ||
15 | }) | ||
16 | } | ||
17 | |||
18 | export { getVideosOverview } | ||
diff --git a/shared/models/index.ts b/shared/models/index.ts index 1db00c295..170f620e7 100644 --- a/shared/models/index.ts +++ b/shared/models/index.ts | |||
@@ -4,6 +4,7 @@ export * from './users' | |||
4 | export * from './videos' | 4 | export * from './videos' |
5 | export * from './feeds' | 5 | export * from './feeds' |
6 | export * from './i18n' | 6 | export * from './i18n' |
7 | export * from './overviews' | ||
7 | export * from './search' | 8 | export * from './search' |
8 | export * from './server/job.model' | 9 | export * from './server/job.model' |
9 | export * from './oauth-client-local.model' | 10 | export * from './oauth-client-local.model' |
diff --git a/shared/models/overviews/index.ts b/shared/models/overviews/index.ts new file mode 100644 index 000000000..376609efa --- /dev/null +++ b/shared/models/overviews/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './videos-overview' | |||
diff --git a/shared/models/overviews/videos-overview.ts b/shared/models/overviews/videos-overview.ts new file mode 100644 index 000000000..ee009d94c --- /dev/null +++ b/shared/models/overviews/videos-overview.ts | |||
@@ -0,0 +1,18 @@ | |||
1 | import { Video, VideoChannelAttribute, VideoConstant } from '../videos' | ||
2 | |||
3 | export interface VideosOverview { | ||
4 | channels: { | ||
5 | channel: VideoChannelAttribute | ||
6 | videos: Video[] | ||
7 | }[] | ||
8 | |||
9 | categories: { | ||
10 | category: VideoConstant<number> | ||
11 | videos: Video[] | ||
12 | }[] | ||
13 | |||
14 | tags: { | ||
15 | tag: string | ||
16 | videos: Video[] | ||
17 | }[] | ||
18 | } | ||
diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts index 8e1fbe444..b47ab1ab8 100644 --- a/shared/models/videos/video.model.ts +++ b/shared/models/videos/video.model.ts | |||
@@ -17,6 +17,26 @@ export interface VideoFile { | |||
17 | fps: number | 17 | fps: number |
18 | } | 18 | } |
19 | 19 | ||
20 | export interface VideoChannelAttribute { | ||
21 | id: number | ||
22 | uuid: string | ||
23 | name: string | ||
24 | displayName: string | ||
25 | url: string | ||
26 | host: string | ||
27 | avatar: Avatar | ||
28 | } | ||
29 | |||
30 | export interface AccountAttribute { | ||
31 | id: number | ||
32 | uuid: string | ||
33 | name: string | ||
34 | displayName: string | ||
35 | url: string | ||
36 | host: string | ||
37 | avatar: Avatar | ||
38 | } | ||
39 | |||
20 | export interface Video { | 40 | export interface Video { |
21 | id: number | 41 | id: number |
22 | uuid: string | 42 | uuid: string |
@@ -46,25 +66,8 @@ export interface Video { | |||
46 | blacklisted?: boolean | 66 | blacklisted?: boolean |
47 | blacklistedReason?: string | 67 | blacklistedReason?: string |
48 | 68 | ||
49 | account: { | 69 | account: AccountAttribute |
50 | id: number | 70 | channel: VideoChannelAttribute |
51 | uuid: string | ||
52 | name: string | ||
53 | displayName: string | ||
54 | url: string | ||
55 | host: string | ||
56 | avatar: Avatar | ||
57 | } | ||
58 | |||
59 | channel: { | ||
60 | id: number | ||
61 | uuid: string | ||
62 | name: string | ||
63 | displayName: string | ||
64 | url: string | ||
65 | host: string | ||
66 | avatar: Avatar | ||
67 | } | ||
68 | } | 71 | } |
69 | 72 | ||
70 | export interface VideoDetails extends Video { | 73 | export interface VideoDetails extends Video { |