Videos overview page: first version
authorChocobozzz <me@florianbigard.com>
Thu, 30 Aug 2018 12:58:00 +0000 (14:58 +0200)
committerChocobozzz <me@florianbigard.com>
Fri, 31 Aug 2018 07:19:58 +0000 (09:19 +0200)
32 files changed:
client/src/app/+video-channels/video-channels.component.html
client/src/app/+video-channels/video-channels.component.ts
client/src/app/menu/menu.component.html
client/src/app/menu/menu.component.scss
client/src/app/search/search.component.html
client/src/app/search/search.component.scss
client/src/app/shared/overview/index.ts [new file with mode: 0644]
client/src/app/shared/overview/overview.service.ts [new file with mode: 0644]
client/src/app/shared/overview/videos-overview.model.ts [new file with mode: 0644]
client/src/app/shared/shared.module.ts
client/src/app/shared/video/abstract-video-list.html
client/src/app/shared/video/video.service.ts
client/src/app/videos/+video-watch/video-watch.component.html
client/src/app/videos/video-list/video-overview.component.html [new file with mode: 0644]
client/src/app/videos/video-list/video-overview.component.scss [new file with mode: 0644]
client/src/app/videos/video-list/video-overview.component.ts [new file with mode: 0644]
client/src/app/videos/videos-routing.module.ts
client/src/app/videos/videos.module.ts
client/src/assets/images/menu/globe.svg [new file with mode: 0644]
client/src/sass/application.scss
server/controllers/api/index.ts
server/controllers/api/overviews.ts [new file with mode: 0644]
server/initializers/constants.ts
server/models/video/tag.ts
server/models/video/video.ts
server/tests/api/videos/index.ts
server/tests/api/videos/videos-overview.ts [new file with mode: 0644]
server/tests/utils/overviews/overviews.ts [new file with mode: 0644]
shared/models/index.ts
shared/models/overviews/index.ts [new file with mode: 0644]
shared/models/overviews/videos-overview.ts [new file with mode: 0644]
shared/models/videos/video.model.ts

index 1941a2eab436cc0274605634f16e0d1361f2dad7..e5a32dc92838a9c32ebc56cfb08e8e716c9c6b7e 100644 (file)
@@ -9,7 +9,7 @@
           <div class="actor-display-name">{{ videoChannel.displayName }}</div>
           <div class="actor-name">{{ videoChannel.nameWithHost }}</div>
 
-          <my-subscribe-button [videoChannel]="videoChannel"></my-subscribe-button>
+          <my-subscribe-button *ngIf="isUserLoggedIn()" [videoChannel]="videoChannel"></my-subscribe-button>
         </div>
         <div i18n class="actor-followers">{{ videoChannel.followersCount }} subscribers</div>
 
index 57c55d28673a20da2b21b04b3c38cad98ada39ae..ee2c86915e50d398de25a0946cebf0a9c760bae0 100644 (file)
@@ -5,6 +5,7 @@ import { VideoChannelService } from '@app/shared/video-channel/video-channel.ser
 import { RestExtractor } from '@app/shared'
 import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators'
 import { Subscription } from 'rxjs'
+import { AuthService } from '@app/core'
 
 @Component({
   templateUrl: './video-channels.component.html',
@@ -17,6 +18,7 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
 
   constructor (
     private route: ActivatedRoute,
+    private authService: AuthService,
     private videoChannelService: VideoChannelService,
     private restExtractor: RestExtractor
   ) { }
@@ -36,4 +38,8 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
   ngOnDestroy () {
     if (this.routeSub) this.routeSub.unsubscribe()
   }
+
+  isUserLoggedIn () {
+    return this.authService.isLoggedIn()
+  }
 }
index bd03af9b3c940f497ac095c60eb23e75351d322d..8fe6797d601cf36c85a64449b178196c320f3901 100644 (file)
           <ng-container i18n>Subscriptions</ng-container>
         </a>
 
+        <a routerLink="/videos/overview" routerLinkActive="active">
+          <span class="icon icon-videos-overview"></span>
+          <ng-container i18n>Overview</ng-container>
+        </a>
+
         <a routerLink="/videos/trending" routerLinkActive="active">
           <span class="icon icon-videos-trending"></span>
           <ng-container i18n>Trending</ng-container>
index 606fea96154785bc1fbe103aca795f867c2f94f1..8539c0e5609a7a27ccda2646b282a3f3b67ee953 100644 (file)
@@ -141,6 +141,11 @@ menu {
           background-image: url('../../assets/images/menu/subscriptions.svg');
         }
 
+        &.icon-videos-overview {
+          position: relative;
+          background-image: url('../../assets/images/menu/globe.svg');
+        }
+
         &.icon-videos-trending {
           position: relative;
           top: -2px;
index d2ed1f881412ae7fe4e022784b39bd50249e0ae3..b35a46ec938efe377dc2632f3307a6529f3aa3d7 100644 (file)
@@ -22,7 +22,7 @@
     </div>
   </div>
 
-  <div i18n *ngIf="pagination.totalItems === 0 && results.length === 0" class="no-result">
+  <div i18n *ngIf="pagination.totalItems === 0 && results.length === 0" class="no-results">
     No results found
   </div>
 
index e5dfddcc59c1e9e5194a4c0d935eb8b19f8fc6ef..f394099e28d4f7132a7c80077d7cf53712607728 100644 (file)
@@ -1,15 +1,6 @@
 @import '_variables';
 @import '_mixins';
 
-.no-result {
-  height: 40vh;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 16px;
-  font-weight: $font-semibold;
-}
-
 .search-result {
   padding: 40px;
 
diff --git a/client/src/app/shared/overview/index.ts b/client/src/app/shared/overview/index.ts
new file mode 100644 (file)
index 0000000..2f7e412
--- /dev/null
@@ -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 (file)
index 0000000..4a4714a
--- /dev/null
@@ -0,0 +1,76 @@
+import { catchError, map, switchMap, tap } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { forkJoin, Observable, of } from 'rxjs'
+import { VideosOverview as VideosOverviewServer, peertubeTranslate } from '../../../../../shared/models'
+import { environment } from '../../../environments/environment'
+import { RestExtractor } from '../rest/rest-extractor.service'
+import { RestService } from '../rest/rest.service'
+import { VideosOverview } from '@app/shared/overview/videos-overview.model'
+import { VideoService } from '@app/shared/video/video.service'
+import { ServerService } from '@app/core'
+import { immutableAssign } from '@app/shared/misc/utils'
+
+@Injectable()
+export class OverviewService {
+  static BASE_OVERVIEW_URL = environment.apiUrl + '/api/v1/overviews/'
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor,
+    private restService: RestService,
+    private videosService: VideoService,
+    private serverService: ServerService
+  ) {}
+
+  getVideosOverview (): Observable<VideosOverview> {
+    return this.authHttp
+               .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos')
+               .pipe(
+                 switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  private updateVideosOverview (serverVideosOverview: VideosOverviewServer): Observable<VideosOverview> {
+    const observables: Observable<any>[] = []
+    const videosOverviewResult: VideosOverview = {
+      tags: [],
+      categories: [],
+      channels: []
+    }
+
+    // Build videos objects
+    for (const key of Object.keys(serverVideosOverview)) {
+      for (const object of serverVideosOverview[ key ]) {
+        observables.push(
+          of(object.videos)
+            .pipe(
+              switchMap(videos => this.videosService.extractVideos({ total: 0, data: videos })),
+              map(result => result.videos),
+              tap(videos => {
+                videosOverviewResult[key].push(immutableAssign(object, { videos }))
+              })
+            )
+        )
+      }
+    }
+
+    return forkJoin(observables)
+      .pipe(
+        // Translate categories
+        switchMap(() => {
+          return this.serverService.localeObservable
+              .pipe(
+                tap(translations => {
+                  for (const c of videosOverviewResult.categories) {
+                    c.category.label = peertubeTranslate(c.category.label, translations)
+                  }
+                })
+              )
+        }),
+        map(() => videosOverviewResult)
+      )
+  }
+
+}
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 (file)
index 0000000..cf02bdb
--- /dev/null
@@ -0,0 +1,19 @@
+import { VideoChannelAttribute, VideoConstant, VideosOverview as VideosOverviewServer } from '../../../../../shared/models'
+import { Video } from '@app/shared/video/video.model'
+
+export class VideosOverview implements VideosOverviewServer {
+  channels: {
+    channel: VideoChannelAttribute
+    videos: Video[]
+  }[]
+
+  categories: {
+    category: VideoConstant<number>
+    videos: Video[]
+  }[]
+
+  tags: {
+    tag: string
+    videos: Video[]
+  }[]
+}
index 2cbaaf4aedabc0578f369713c59de15d42121581..b96a9aa41822605a52e85acad2cf536bef4bb3c9 100644 (file)
@@ -52,6 +52,7 @@ import { ActionDropdownComponent } from '@app/shared/buttons/action-dropdown.com
 import { NgbDropdownModule, NgbModalModule, NgbPopoverModule, NgbTabsetModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
 import { SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription'
 import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component'
+import { OverviewService } from '@app/shared/overview'
 
 @NgModule({
   imports: [
@@ -154,6 +155,7 @@ import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-fe
     VideoValidatorsService,
     VideoCaptionsValidatorsService,
     VideoBlacklistValidatorsService,
+    OverviewService,
 
     I18nPrimengCalendarService,
     ScreenService,
index d4b00c07c1e743fabc166e96d51a3daa8841ee5b..0f48b9a64c6a1aa8d6a9d295cce8cc153c1a09d9 100644 (file)
@@ -4,7 +4,7 @@
   </div>
   <my-video-feed [syndicationItems]="syndicationItems"></my-video-feed>
 
-  <div i18n *ngIf="pagination.totalItems === 0">No results.</div>
+  <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div>
   <div
     myInfiniteScroller
     [pageHeight]="pageHeight"
     class="videos" #videosElement
   >
     <div *ngFor="let videos of videoPages" class="videos-page">
-      <my-video-miniature
-        class="ng-animate"
-        *ngFor="let video of videos" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType"
-      >
-      </my-video-miniature>
+      <my-video-miniature *ngFor="let video of videos" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType"></my-video-miniature>
     </div>
   </div>
 </div>
index 558db95431119c5e4a69daf5242bbeec07d31b47..7cc98c77a1764f207187f2ea87262bc36f921d3a 100644 (file)
@@ -51,14 +51,6 @@ export class VideoService {
                )
   }
 
-  viewVideo (uuid: string): Observable<boolean> {
-    return this.authHttp.post(this.getVideoViewUrl(uuid), {})
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
   updateVideo (video: VideoEdit) {
     const language = video.language || null
     const licence = video.licence || null
index 333c9d11bc8963a38a61e9c9a344bae4ee3f9498..2c8305777add6de277ab06b80e2cdb80257d17cc 100644 (file)
@@ -38,7 +38,7 @@
                 Published {{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views
               </div>
             </div>
-            
+
             <div class="d-flex justify-content-between align-items-sm-end">
               <div class="d-none d-sm-block">
                 <div class="video-info-name">{{ video.name }}</div>
@@ -46,7 +46,7 @@
                 <div i18n class="video-info-date-views">
                   Published {{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views
                 </div>
-              </div>  
+              </div>
 
               <div class="video-actions-rates">
                 <div class="video-actions fullWidth justify-content-end">
                   >
                     <span class="icon icon-like" i18n-title title="Like this video" ></span>
                   </div>
-      
+
                   <div
                     *ngIf="isUserLoggedIn()" [ngClass]="{ 'activated': userRating === 'dislike' }" (click)="setDislike()"
                     class="action-button action-button-dislike" role="button" [attr.aria-pressed]="userRating === 'dislike'"
                   >
                     <span class="icon icon-dislike" i18n-title title="Dislike this video"></span>
                   </div>
-      
+
                   <div *ngIf="video.support" (click)="showSupportModal()" class="action-button action-button-support">
                     <span class="icon icon-support"></span>
                     <span class="icon-text" i18n>Support</span>
                   </div>
-      
+
                   <div (click)="showShareModal()" class="action-button action-button-share" role="button">
                     <span class="icon icon-share"></span>
                     <span class="icon-text" i18n>Share</span>
                   </div>
-      
+
                   <div class="action-more" ngbDropdown placement="top" role="button">
                     <div class="action-button" ngbDropdownToggle role="button">
                       <span class="icon icon-more"></span>
                     </div>
-      
+
                     <div ngbDropdownMenu>
                       <a class="dropdown-item" i18n-title title="Download the video" href="#" (click)="showDownloadModal($event)">
                         <span class="icon icon-download"></span> <ng-container i18n>Download</ng-container>
                       </a>
-      
+
                       <a *ngIf="isUserLoggedIn()" class="dropdown-item" i18n-title title="Report this video" href="#" (click)="showReportModal($event)">
                         <span class="icon icon-alert"></span> <ng-container i18n>Report</ng-container>
                       </a>
-      
+
                       <a *ngIf="isVideoUpdatable()" class="dropdown-item" i18n-title title="Update this video" href="#" [routerLink]="[ '/videos/update', video.uuid ]">
                         <span class="icon icon-edit"></span> <ng-container i18n>Update</ng-container>
                       </a>
-      
+
                       <a *ngIf="isVideoBlacklistable()" class="dropdown-item" i18n-title title="Blacklist this video" href="#" (click)="showBlacklistModal($event)">
                         <span class="icon icon-blacklist"></span> <ng-container i18n>Blacklist</ng-container>
                       </a>
-      
+
                       <a *ngIf="isVideoUnblacklistable()" class="dropdown-item" i18n-title title="Unblacklist this video" href="#" (click)="unblacklistVideo($event)">
                         <span class="icon icon-unblacklist"></span> <ng-container i18n>Unblacklist</ng-container>
                       </a>
-      
+
                       <a *ngIf="isVideoRemovable()" class="dropdown-item" i18n-title title="Delete this video" href="#" (click)="removeVideo($event)">
                         <span class="icon icon-delete"></span> <ng-container i18n>Delete</ng-container>
                       </a>
                     </div>
                   </div>
                 </div>
-      
+
                 <div
                   class="video-info-likes-dislikes-bar"
                   *ngIf="video.likes !== 0 || video.dislikes !== 0"
                 <img [src]="video.videoChannelAvatarUrl" alt="Video channel avatar" />
               </a>
 
-              <my-subscribe-button [videoChannel]="video.channel" size="small"></my-subscribe-button>
+              <my-subscribe-button *ngIf="isUserLoggedIn()" [videoChannel]="video.channel" size="small"></my-subscribe-button>
             </div>
 
             <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 (file)
index 0000000..9282dd5
--- /dev/null
@@ -0,0 +1,35 @@
+<div class="margin-content">
+
+  <div class="no-results" i18n *ngIf="notResults">No results.</div>
+
+  <div class="section" *ngFor="let object of overview.categories">
+    <div class="section-title" i18n>
+      <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">Category {{ object.category.label }}</a>
+    </div>
+
+    <div>
+      <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature>
+    </div>
+  </div>
+
+  <div class="section" *ngFor="let object of overview.tags">
+    <div class="section-title" i18n>
+      <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">Tag {{ object.tag }}</a>
+    </div>
+
+    <div>
+      <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature>
+    </div>
+  </div>
+
+  <div class="section" *ngFor="let object of overview.channels">
+    <div class="section-title" i18n>
+      <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]">Channel {{ object.channel.displayName }}</a>
+    </div>
+
+    <div>
+      <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature>
+    </div>
+  </div>
+
+</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 (file)
index 0000000..8d66cf8
--- /dev/null
@@ -0,0 +1,22 @@
+@import '_variables';
+@import '_mixins';
+
+.section {
+  padding-top: 10px;
+
+  &:first-child {
+    padding-top: 30px;
+  }
+}
+
+.section-title {
+  font-size: 17px;
+  font-weight: $font-semibold;
+  margin-bottom: 20px;
+
+  a {
+    @include disable-default-a-behaviour;
+
+    color: #000;
+  }
+}
\ 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 (file)
index 0000000..c758e11
--- /dev/null
@@ -0,0 +1,56 @@
+import { Component, OnInit } from '@angular/core'
+import { AuthService } from '@app/core'
+import { NotificationsService } from 'angular2-notifications'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideosOverview } from '@app/shared/overview/videos-overview.model'
+import { OverviewService } from '@app/shared/overview'
+import { Video } from '@app/shared/video/video.model'
+
+@Component({
+  selector: 'my-video-overview',
+  templateUrl: './video-overview.component.html',
+  styleUrls: [ './video-overview.component.scss' ]
+})
+export class VideoOverviewComponent implements OnInit {
+  overview: VideosOverview = {
+    categories: [],
+    channels: [],
+    tags: []
+  }
+  notResults = false
+
+  constructor (
+    private i18n: I18n,
+    private notificationsService: NotificationsService,
+    private authService: AuthService,
+    private overviewService: OverviewService
+  ) { }
+
+  get user () {
+    return this.authService.getUser()
+  }
+
+  ngOnInit () {
+    this.overviewService.getVideosOverview()
+        .subscribe(
+          overview => {
+            this.overview = overview
+
+            if (
+              this.overview.categories.length === 0 &&
+              this.overview.channels.length === 0 &&
+              this.overview.tags.length === 0
+            ) this.notResults = true
+          },
+
+          err => {
+            console.log(err)
+            this.notificationsService.error('Error', err.text)
+          }
+        )
+  }
+
+  buildVideoChannelBy (object: { videos: Video[] }) {
+    return object.videos[0].byVideoChannel
+  }
+}
index 18ed5257064e00781860929746fdb2916d7e8056..58988ffd1a5c5f59d8726769ba6ea0529ffad36a 100644 (file)
@@ -6,6 +6,7 @@ import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.c
 import { VideoTrendingComponent } from './video-list/video-trending.component'
 import { VideosComponent } from './videos.component'
 import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component'
+import { VideoOverviewComponent } from '@app/videos/video-list/video-overview.component'
 
 const videosRoutes: Routes = [
   {
@@ -13,6 +14,15 @@ const videosRoutes: Routes = [
     component: VideosComponent,
     canActivateChild: [ MetaGuard ],
     children: [
+      {
+        path: 'overview',
+        component: VideoOverviewComponent,
+        data: {
+          meta: {
+            title: 'Videos overview'
+          }
+        }
+      },
       {
         path: 'trending',
         component: VideoTrendingComponent,
index 3c38772734139f80a6eb2c5dca24f23ab90359bc..5cf1e944f4b7ea79d3d198ecbc0b78d500341545 100644 (file)
@@ -6,6 +6,7 @@ import { VideoTrendingComponent } from './video-list/video-trending.component'
 import { VideosRoutingModule } from './videos-routing.module'
 import { VideosComponent } from './videos.component'
 import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component'
+import { VideoOverviewComponent } from '@app/videos/video-list/video-overview.component'
 
 @NgModule({
   imports: [
@@ -19,7 +20,8 @@ import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-us
     VideoTrendingComponent,
     VideoRecentlyAddedComponent,
     VideoLocalComponent,
-    VideoUserSubscriptionsComponent
+    VideoUserSubscriptionsComponent,
+    VideoOverviewComponent
   ],
 
   exports: [
diff --git a/client/src/assets/images/menu/globe.svg b/client/src/assets/images/menu/globe.svg
new file mode 100644 (file)
index 0000000..a4b3db9
--- /dev/null
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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">
+    <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
+    <title>globe</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Artboard-4" transform="translate(-224.000000, -687.000000)" stroke="#808080" stroke-width="2">
+            <g id="265" transform="translate(224.000000, 687.000000)">
+                <circle id="Oval-148" cx="12" cy="12" r="10"></circle>
+                <path d="M12,2 L12,22.006249" id="Path-199"></path>
+                <path d="M12,2 C12,2 17,4 17,12.0031245 C17,20.006249 12,22.006249 12,22.006249" id="Path-199"></path>
+                <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>
+                <path d="M2,12 L22,12" id="Path-201"></path>
+            </g>
+        </g>
+    </g>
+</svg>
index 21df23c18b93406ea50e0cecce55e88bd50c0ecf..38b7ea8d4bd0dfc1d41f05139a8943b769120314 100644 (file)
@@ -293,6 +293,15 @@ table {
   }
 }
 
+.no-results {
+  height: 40vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 16px;
+  font-weight: $font-semibold;
+}
+
 @media screen and (max-width: 900px) {
   .main-col {
     &, &.expanded {
index e928a747846bab5b6fc1de2a6869c010149c2809..8a58b54662aef26cdf2529cebb010b7a029102dd 100644 (file)
@@ -10,6 +10,7 @@ import { badRequest } from '../../helpers/express-utils'
 import { videoChannelRouter } from './video-channel'
 import * as cors from 'cors'
 import { searchRouter } from './search'
+import { overviewsRouter } from './overviews'
 
 const apiRouter = express.Router()
 
@@ -28,6 +29,7 @@ apiRouter.use('/video-channels', videoChannelRouter)
 apiRouter.use('/videos', videosRouter)
 apiRouter.use('/jobs', jobsRouter)
 apiRouter.use('/search', searchRouter)
+apiRouter.use('/overviews', overviewsRouter)
 apiRouter.use('/ping', pong)
 apiRouter.use('/*', badRequest)
 
diff --git a/server/controllers/api/overviews.ts b/server/controllers/api/overviews.ts
new file mode 100644 (file)
index 0000000..56f921c
--- /dev/null
@@ -0,0 +1,97 @@
+import * as express from 'express'
+import { buildNSFWFilter } from '../../helpers/express-utils'
+import { VideoModel } from '../../models/video/video'
+import { asyncMiddleware, executeIfActivityPub } from '../../middlewares'
+import { TagModel } from '../../models/video/tag'
+import { VideosOverview } from '../../../shared/models/overviews'
+import { OVERVIEWS, ROUTE_CACHE_LIFETIME } from '../../initializers'
+import { cacheRoute } from '../../middlewares/cache'
+
+const overviewsRouter = express.Router()
+
+overviewsRouter.get('/videos',
+  executeIfActivityPub(asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.OVERVIEWS.VIDEOS))),
+  asyncMiddleware(getVideosOverview)
+)
+
+// ---------------------------------------------------------------------------
+
+export { overviewsRouter }
+
+// ---------------------------------------------------------------------------
+
+// This endpoint could be quite long, but we cache it
+async function getVideosOverview (req: express.Request, res: express.Response) {
+  const attributes = await buildSamples()
+  const result: VideosOverview = {
+    categories: await Promise.all(attributes.categories.map(c => getVideosByCategory(c, res))),
+    channels: await Promise.all(attributes.channels.map(c => getVideosByChannel(c, res))),
+    tags: await Promise.all(attributes.tags.map(t => getVideosByTag(t, res)))
+  }
+
+  // Cleanup our object
+  for (const key of Object.keys(result)) {
+    result[key] = result[key].filter(v => v !== undefined)
+  }
+
+  return res.json(result)
+}
+
+async function buildSamples () {
+  const [ categories, channels, tags ] = await Promise.all([
+    VideoModel.getRandomFieldSamples('category', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT),
+    VideoModel.getRandomFieldSamples('channelId', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD ,OVERVIEWS.VIDEOS.SAMPLES_COUNT),
+    TagModel.getRandomSamples(OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT)
+  ])
+
+  return { categories, channels, tags }
+}
+
+async function getVideosByTag (tag: string, res: express.Response) {
+  const videos = await getVideos(res, { tagsOneOf: [ tag ] })
+
+  if (videos.length === 0) return undefined
+
+  return {
+    tag,
+    videos
+  }
+}
+
+async function getVideosByCategory (category: number, res: express.Response) {
+  const videos = await getVideos(res, { categoryOneOf: [ category ] })
+
+  if (videos.length === 0) return undefined
+
+  return {
+    category: videos[0].category,
+    videos
+  }
+}
+
+async function getVideosByChannel (channelId: number, res: express.Response) {
+  const videos = await getVideos(res, { videoChannelId: channelId })
+
+  if (videos.length === 0) return undefined
+
+  return {
+    channel: videos[0].channel,
+    videos
+  }
+}
+
+async function getVideos (
+  res: express.Response,
+  where: { videoChannelId?: number, tagsOneOf?: string[], categoryOneOf?: number[] }
+) {
+  const { data } = await VideoModel.listForApi(Object.assign({
+    start: 0,
+    count: 10,
+    sort: '-createdAt',
+    includeLocalVideos: true,
+    nsfw: buildNSFWFilter(res),
+    withFiles: false
+  }, where))
+
+  return data.map(d => d.toFormattedJSON())
+}
index 5d93c6b82458f1bec7bac7938b15ee5bd39ce831..16d8dca68598fc4da364d815d39bc2e74258c5e9 100644 (file)
@@ -58,6 +58,9 @@ const ROUTE_CACHE_LIFETIME = {
   ROBOTS: '2 hours',
   NODEINFO: '10 minutes',
   DNT_POLICY: '1 week',
+  OVERVIEWS: {
+    VIDEOS: '1 hour'
+  },
   ACTIVITY_PUB: {
     VIDEOS: '1 second' // 1 second, cache concurrent requests after a broadcast for example
   }
@@ -464,6 +467,15 @@ const TORRENT_MIMETYPE_EXT = {
 
 // ---------------------------------------------------------------------------
 
+const OVERVIEWS = {
+  VIDEOS: {
+    SAMPLE_THRESHOLD: 4,
+    SAMPLES_COUNT: 2
+  }
+}
+
+// ---------------------------------------------------------------------------
+
 const SERVER_ACTOR_NAME = 'peertube'
 
 const ACTIVITY_PUB = {
@@ -666,6 +678,7 @@ export {
   USER_PASSWORD_RESET_LIFETIME,
   USER_EMAIL_VERIFY_LIFETIME,
   IMAGE_MIMETYPE_EXT,
+  OVERVIEWS,
   SCHEDULER_INTERVALS_MS,
   REPEAT_JOBS,
   STATIC_DOWNLOAD_PATHS,
index 6d79a55756ad212f84977a383be0757525d950dd..e39a418cdfb0199965bc94ff39c5eb970c45f09a 100644 (file)
@@ -1,10 +1,11 @@
 import * as Bluebird from 'bluebird'
-import { Transaction } from 'sequelize'
+import * as Sequelize from 'sequelize'
 import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
 import { isVideoTagValid } from '../../helpers/custom-validators/videos'
 import { throwIfNotValid } from '../utils'
 import { VideoModel } from './video'
 import { VideoTagModel } from './video-tag'
+import { VideoPrivacy, VideoState } from '../../../shared/models/videos'
 
 @Table({
   tableName: 'tag',
@@ -36,7 +37,7 @@ export class TagModel extends Model<TagModel> {
   })
   Videos: VideoModel[]
 
-  static findOrCreateTags (tags: string[], transaction: Transaction) {
+  static findOrCreateTags (tags: string[], transaction: Sequelize.Transaction) {
     if (tags === null) return []
 
     const tasks: Bluebird<TagModel>[] = []
@@ -59,4 +60,23 @@ export class TagModel extends Model<TagModel> {
 
     return Promise.all(tasks)
   }
+
+  // threshold corresponds to how many video the field should have to be returned
+  static getRandomSamples (threshold: number, count: number): Bluebird<string[]> {
+    const query = 'SELECT tag.name FROM tag ' +
+      'INNER JOIN "videoTag" ON "videoTag"."tagId" = tag.id ' +
+      'INNER JOIN video ON video.id = "videoTag"."videoId" ' +
+      'WHERE video.privacy = $videoPrivacy AND video.state = $videoState ' +
+      'GROUP BY tag.name HAVING COUNT(tag.name) >= $threshold ' +
+      'ORDER BY random() ' +
+      'LIMIT $count'
+
+    const options = {
+      bind: { threshold, count, videoPrivacy: VideoPrivacy.PUBLIC, videoState: VideoState.PUBLISHED },
+      type: Sequelize.QueryTypes.SELECT
+    }
+
+    return TagModel.sequelize.query(query, options)
+                    .then(data => data.map(d => d.name))
+  }
 }
index 3410833c8270c22050ce51725baa2df9b705d057..695990b17ba1d07ebac52425d75ac75c0d8aec3c 100644 (file)
@@ -1083,6 +1083,29 @@ export class VideoModel extends Model<VideoModel> {
     })
   }
 
+  // threshold corresponds to how many video the field should have to be returned
+  static getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
+    const query: IFindOptions<VideoModel> = {
+      attributes: [ field ],
+      limit: count,
+      group: field,
+      having: Sequelize.where(Sequelize.fn('COUNT', Sequelize.col(field)), {
+        [Sequelize.Op.gte]: threshold
+      }) as any, // FIXME: typings
+      where: {
+        [field]: {
+          [Sequelize.Op.not]: null,
+        },
+        privacy: VideoPrivacy.PUBLIC,
+        state: VideoState.PUBLISHED
+      },
+      order: [ this.sequelize.random() ]
+    }
+
+    return VideoModel.findAll(query)
+      .then(rows => rows.map(r => r[field]))
+  }
+
   private static buildActorWhereWithFilter (filter?: VideoFilter) {
     if (filter && filter === 'local') {
       return {
index bc66a78245119945e0a443263801707976ba3b9e..8286ff35654c9a6a0eb31d74be84812d909864c0 100644 (file)
@@ -13,3 +13,4 @@ import './video-nsfw'
 import './video-privacy'
 import './video-schedule-update'
 import './video-transcoder'
+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 (file)
index 0000000..1514d1b
--- /dev/null
@@ -0,0 +1,96 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import { flushTests, killallServers, runServer, ServerInfo, setAccessTokensToServers, uploadVideo } from '../../utils'
+import { getVideosOverview } from '../../utils/overviews/overviews'
+import { VideosOverview } from '../../../../shared/models/overviews'
+
+const expect = chai.expect
+
+describe('Test a videos overview', function () {
+  let server: ServerInfo = null
+
+  before(async function () {
+    this.timeout(30000)
+
+    await flushTests()
+
+    server = await runServer(1)
+
+    await setAccessTokensToServers([ server ])
+  })
+
+  it('Should send empty overview', async function () {
+    const res = await getVideosOverview(server.url)
+
+    const overview: VideosOverview = res.body
+    expect(overview.tags).to.have.lengthOf(0)
+    expect(overview.categories).to.have.lengthOf(0)
+    expect(overview.channels).to.have.lengthOf(0)
+  })
+
+  it('Should upload 3 videos in a specific category, tag and channel but not include them in overview', async function () {
+    for (let i = 0; i < 3; i++) {
+      await uploadVideo(server.url, server.accessToken, {
+        name: 'video ' + i,
+        category: 3,
+        tags: [ 'coucou1', 'coucou2' ]
+      })
+    }
+
+    const res = await getVideosOverview(server.url)
+
+    const overview: VideosOverview = res.body
+    expect(overview.tags).to.have.lengthOf(0)
+    expect(overview.categories).to.have.lengthOf(0)
+    expect(overview.channels).to.have.lengthOf(0)
+  })
+
+  it('Should upload another video and include all videos in the overview', async function () {
+    await uploadVideo(server.url, server.accessToken, {
+      name: 'video 3',
+      category: 3,
+      tags: [ 'coucou1', 'coucou2' ]
+    })
+
+    const res = await getVideosOverview(server.url)
+
+    const overview: VideosOverview = res.body
+    expect(overview.tags).to.have.lengthOf(2)
+    expect(overview.categories).to.have.lengthOf(1)
+    expect(overview.channels).to.have.lengthOf(1)
+  })
+
+  it('Should have the correct overview', async function () {
+    const res = await getVideosOverview(server.url)
+
+    const overview: VideosOverview = res.body
+
+    for (const attr of [ 'tags', 'categories', 'channels' ]) {
+      const obj = overview[attr][0]
+
+      expect(obj.videos).to.have.lengthOf(4)
+      expect(obj.videos[0].name).to.equal('video 3')
+      expect(obj.videos[1].name).to.equal('video 2')
+      expect(obj.videos[2].name).to.equal('video 1')
+      expect(obj.videos[3].name).to.equal('video 0')
+    }
+
+    expect(overview.tags.find(t => t.tag === 'coucou1')).to.not.be.undefined
+    expect(overview.tags.find(t => t.tag === 'coucou2')).to.not.be.undefined
+
+    expect(overview.categories[0].category.id).to.equal(3)
+
+    expect(overview.channels[0].channel.name).to.equal('root_channel')
+  })
+
+  after(async function () {
+    killallServers([ server ])
+
+    // Keep the logs if the test failed
+    if (this['ok']) {
+      await flushTests()
+    }
+  })
+})
diff --git a/server/tests/utils/overviews/overviews.ts b/server/tests/utils/overviews/overviews.ts
new file mode 100644 (file)
index 0000000..23e3ceb
--- /dev/null
@@ -0,0 +1,18 @@
+import { makeGetRequest } from '../requests/requests'
+
+function getVideosOverview (url: string, useCache = false) {
+  const path = '/api/v1/overviews/videos'
+
+  const query = {
+    t: useCache ? undefined : new Date().getTime()
+  }
+
+  return makeGetRequest({
+    url,
+    path,
+    query,
+    statusCodeExpected: 200
+  })
+}
+
+export { getVideosOverview }
index 1db00c295b8ee26369215e7f278f0c4aaf656e16..170f620e716837f6bae968653537516347ba84e0 100644 (file)
@@ -4,6 +4,7 @@ export * from './users'
 export * from './videos'
 export * from './feeds'
 export * from './i18n'
+export * from './overviews'
 export * from './search'
 export * from './server/job.model'
 export * from './oauth-client-local.model'
diff --git a/shared/models/overviews/index.ts b/shared/models/overviews/index.ts
new file mode 100644 (file)
index 0000000..376609e
--- /dev/null
@@ -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 (file)
index 0000000..ee009d9
--- /dev/null
@@ -0,0 +1,18 @@
+import { Video, VideoChannelAttribute, VideoConstant } from '../videos'
+
+export interface VideosOverview {
+  channels: {
+    channel: VideoChannelAttribute
+    videos: Video[]
+  }[]
+
+  categories: {
+    category: VideoConstant<number>
+    videos: Video[]
+  }[]
+
+  tags: {
+    tag: string
+    videos: Video[]
+  }[]
+}
index 8e1fbe444269eb7cb78f2f302ad2488475da4b60..b47ab1ab8c1e0a0a7ff9e3da413fe55f82701adf 100644 (file)
@@ -17,6 +17,26 @@ export interface VideoFile {
   fps: number
 }
 
+export interface VideoChannelAttribute {
+  id: number
+  uuid: string
+  name: string
+  displayName: string
+  url: string
+  host: string
+  avatar: Avatar
+}
+
+export interface AccountAttribute {
+  id: number
+  uuid: string
+  name: string
+  displayName: string
+  url: string
+  host: string
+  avatar: Avatar
+}
+
 export interface Video {
   id: number
   uuid: string
@@ -46,25 +66,8 @@ export interface Video {
   blacklisted?: boolean
   blacklistedReason?: string
 
-  account: {
-    id: number
-    uuid: string
-    name: string
-    displayName: string
-    url: string
-    host: string
-    avatar: Avatar
-  }
-
-  channel: {
-    id: number
-    uuid: string
-    name: string
-    displayName: string
-    url: string
-    host: string
-    avatar: Avatar
-  }
+  account: AccountAttribute
+  channel: VideoChannelAttribute
 }
 
 export interface VideoDetails extends Video {