aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2018-08-30 14:58:00 +0200
committerChocobozzz <me@florianbigard.com>2018-08-31 09:19:58 +0200
commit2d3741d6d92e9bd1f41694c7442a6d1da434e1f2 (patch)
tree93a1e609e14bc14ca9e77a6661ddc9c0e461d6f3
parentd9eaee3939bf2e93e5d775d32bce77842201faba (diff)
downloadPeerTube-2d3741d6d92e9bd1f41694c7442a6d1da434e1f2.tar.gz
PeerTube-2d3741d6d92e9bd1f41694c7442a6d1da434e1f2.tar.zst
PeerTube-2d3741d6d92e9bd1f41694c7442a6d1da434e1f2.zip
Videos overview page: first version
-rw-r--r--client/src/app/+video-channels/video-channels.component.html2
-rw-r--r--client/src/app/+video-channels/video-channels.component.ts6
-rw-r--r--client/src/app/menu/menu.component.html5
-rw-r--r--client/src/app/menu/menu.component.scss5
-rw-r--r--client/src/app/search/search.component.html2
-rw-r--r--client/src/app/search/search.component.scss9
-rw-r--r--client/src/app/shared/overview/index.ts1
-rw-r--r--client/src/app/shared/overview/overview.service.ts76
-rw-r--r--client/src/app/shared/overview/videos-overview.model.ts19
-rw-r--r--client/src/app/shared/shared.module.ts2
-rw-r--r--client/src/app/shared/video/abstract-video-list.html8
-rw-r--r--client/src/app/shared/video/video.service.ts8
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.html28
-rw-r--r--client/src/app/videos/video-list/video-overview.component.html35
-rw-r--r--client/src/app/videos/video-list/video-overview.component.scss22
-rw-r--r--client/src/app/videos/video-list/video-overview.component.ts56
-rw-r--r--client/src/app/videos/videos-routing.module.ts10
-rw-r--r--client/src/app/videos/videos.module.ts4
-rw-r--r--client/src/assets/images/menu/globe.svg18
-rw-r--r--client/src/sass/application.scss9
-rw-r--r--server/controllers/api/index.ts2
-rw-r--r--server/controllers/api/overviews.ts97
-rw-r--r--server/initializers/constants.ts13
-rw-r--r--server/models/video/tag.ts24
-rw-r--r--server/models/video/video.ts23
-rw-r--r--server/tests/api/videos/index.ts1
-rw-r--r--server/tests/api/videos/videos-overview.ts96
-rw-r--r--server/tests/utils/overviews/overviews.ts18
-rw-r--r--shared/models/index.ts1
-rw-r--r--shared/models/overviews/index.ts1
-rw-r--r--shared/models/overviews/videos-overview.ts18
-rw-r--r--shared/models/videos/video.model.ts41
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
5import { RestExtractor } from '@app/shared' 5import { RestExtractor } from '@app/shared'
6import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators' 6import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators'
7import { Subscription } from 'rxjs' 7import { Subscription } from 'rxjs'
8import { 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 @@
1import { catchError, map, switchMap, tap } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { forkJoin, Observable, of } from 'rxjs'
5import { VideosOverview as VideosOverviewServer, peertubeTranslate } from '../../../../../shared/models'
6import { environment } from '../../../environments/environment'
7import { RestExtractor } from '../rest/rest-extractor.service'
8import { RestService } from '../rest/rest.service'
9import { VideosOverview } from '@app/shared/overview/videos-overview.model'
10import { VideoService } from '@app/shared/video/video.service'
11import { ServerService } from '@app/core'
12import { immutableAssign } from '@app/shared/misc/utils'
13
14@Injectable()
15export 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 @@
1import { VideoChannelAttribute, VideoConstant, VideosOverview as VideosOverviewServer } from '../../../../../shared/models'
2import { Video } from '@app/shared/video/video.model'
3
4export 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
52import { NgbDropdownModule, NgbModalModule, NgbPopoverModule, NgbTabsetModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' 52import { NgbDropdownModule, NgbModalModule, NgbPopoverModule, NgbTabsetModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
53import { SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription' 53import { SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription'
54import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component' 54import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component'
55import { 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 @@
1import { Component, OnInit } from '@angular/core'
2import { AuthService } from '@app/core'
3import { NotificationsService } from 'angular2-notifications'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { VideosOverview } from '@app/shared/overview/videos-overview.model'
6import { OverviewService } from '@app/shared/overview'
7import { 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})
14export 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
6import { VideoTrendingComponent } from './video-list/video-trending.component' 6import { VideoTrendingComponent } from './video-list/video-trending.component'
7import { VideosComponent } from './videos.component' 7import { VideosComponent } from './videos.component'
8import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component' 8import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component'
9import { VideoOverviewComponent } from '@app/videos/video-list/video-overview.component'
9 10
10const videosRoutes: Routes = [ 11const 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'
6import { VideosRoutingModule } from './videos-routing.module' 6import { VideosRoutingModule } from './videos-routing.module'
7import { VideosComponent } from './videos.component' 7import { VideosComponent } from './videos.component'
8import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component' 8import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component'
9import { 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'
10import { videoChannelRouter } from './video-channel' 10import { videoChannelRouter } from './video-channel'
11import * as cors from 'cors' 11import * as cors from 'cors'
12import { searchRouter } from './search' 12import { searchRouter } from './search'
13import { overviewsRouter } from './overviews'
13 14
14const apiRouter = express.Router() 15const apiRouter = express.Router()
15 16
@@ -28,6 +29,7 @@ apiRouter.use('/video-channels', videoChannelRouter)
28apiRouter.use('/videos', videosRouter) 29apiRouter.use('/videos', videosRouter)
29apiRouter.use('/jobs', jobsRouter) 30apiRouter.use('/jobs', jobsRouter)
30apiRouter.use('/search', searchRouter) 31apiRouter.use('/search', searchRouter)
32apiRouter.use('/overviews', overviewsRouter)
31apiRouter.use('/ping', pong) 33apiRouter.use('/ping', pong)
32apiRouter.use('/*', badRequest) 34apiRouter.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 @@
1import * as express from 'express'
2import { buildNSFWFilter } from '../../helpers/express-utils'
3import { VideoModel } from '../../models/video/video'
4import { asyncMiddleware, executeIfActivityPub } from '../../middlewares'
5import { TagModel } from '../../models/video/tag'
6import { VideosOverview } from '../../../shared/models/overviews'
7import { OVERVIEWS, ROUTE_CACHE_LIFETIME } from '../../initializers'
8import { cacheRoute } from '../../middlewares/cache'
9
10const overviewsRouter = express.Router()
11
12overviewsRouter.get('/videos',
13 executeIfActivityPub(asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.OVERVIEWS.VIDEOS))),
14 asyncMiddleware(getVideosOverview)
15)
16
17// ---------------------------------------------------------------------------
18
19export { overviewsRouter }
20
21// ---------------------------------------------------------------------------
22
23// This endpoint could be quite long, but we cache it
24async 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
40async 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
50async 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
61async 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
72async 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
83async 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
470const OVERVIEWS = {
471 VIDEOS: {
472 SAMPLE_THRESHOLD: 4,
473 SAMPLES_COUNT: 2
474 }
475}
476
477// ---------------------------------------------------------------------------
478
467const SERVER_ACTOR_NAME = 'peertube' 479const SERVER_ACTOR_NAME = 'peertube'
468 480
469const ACTIVITY_PUB = { 481const 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 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { Transaction } from 'sequelize' 2import * as Sequelize from 'sequelize'
3import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 3import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
4import { isVideoTagValid } from '../../helpers/custom-validators/videos' 4import { isVideoTagValid } from '../../helpers/custom-validators/videos'
5import { throwIfNotValid } from '../utils' 5import { throwIfNotValid } from '../utils'
6import { VideoModel } from './video' 6import { VideoModel } from './video'
7import { VideoTagModel } from './video-tag' 7import { VideoTagModel } from './video-tag'
8import { 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'
13import './video-privacy' 13import './video-privacy'
14import './video-schedule-update' 14import './video-schedule-update'
15import './video-transcoder' 15import './video-transcoder'
16import './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
3import * as chai from 'chai'
4import 'mocha'
5import { flushTests, killallServers, runServer, ServerInfo, setAccessTokensToServers, uploadVideo } from '../../utils'
6import { getVideosOverview } from '../../utils/overviews/overviews'
7import { VideosOverview } from '../../../../shared/models/overviews'
8
9const expect = chai.expect
10
11describe('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 @@
1import { makeGetRequest } from '../requests/requests'
2
3function 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
18export { 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'
4export * from './videos' 4export * from './videos'
5export * from './feeds' 5export * from './feeds'
6export * from './i18n' 6export * from './i18n'
7export * from './overviews'
7export * from './search' 8export * from './search'
8export * from './server/job.model' 9export * from './server/job.model'
9export * from './oauth-client-local.model' 10export * 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 @@
1import { Video, VideoChannelAttribute, VideoConstant } from '../videos'
2
3export 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
20export 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
30export 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
20export interface Video { 40export 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
70export interface VideoDetails extends Video { 73export interface VideoDetails extends Video {