Videos overview page: first version
This commit is contained in:
parent
d9eaee3939
commit
2d3741d6d9
32 changed files with 599 additions and 61 deletions
|
@ -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>
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,6 +47,11 @@
|
|||
<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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
1
client/src/app/shared/overview/index.ts
Normal file
1
client/src/app/shared/overview/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './overview.service'
|
76
client/src/app/shared/overview/overview.service.ts
Normal file
76
client/src/app/shared/overview/overview.service.ts
Normal file
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
19
client/src/app/shared/overview/videos-overview.model.ts
Normal file
19
client/src/app/shared/overview/videos-overview.model.ts
Normal file
|
@ -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[]
|
||||
}[]
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
@ -12,11 +12,7 @@
|
|||
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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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">
|
||||
|
@ -56,57 +56,57 @@
|
|||
>
|
||||
<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"
|
||||
|
@ -125,7 +125,7 @@
|
|||
<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">
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
56
client/src/app/videos/video-list/video-overview.component.ts
Normal file
56
client/src/app/videos/video-list/video-overview.component.ts
Normal file
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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: [
|
||||
|
|
18
client/src/assets/images/menu/globe.svg
Normal file
18
client/src/assets/images/menu/globe.svg
Normal file
|
@ -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>
|
After Width: | Height: | Size: 1.2 KiB |
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
97
server/controllers/api/overviews.ts
Normal file
97
server/controllers/api/overviews.ts
Normal file
|
@ -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())
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -13,3 +13,4 @@ import './video-nsfw'
|
|||
import './video-privacy'
|
||||
import './video-schedule-update'
|
||||
import './video-transcoder'
|
||||
import './videos-overview'
|
||||
|
|
96
server/tests/api/videos/videos-overview.ts
Normal file
96
server/tests/api/videos/videos-overview.ts
Normal file
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
18
server/tests/utils/overviews/overviews.ts
Normal file
18
server/tests/utils/overviews/overviews.ts
Normal file
|
@ -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 }
|
|
@ -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'
|
||||
|
|
1
shared/models/overviews/index.ts
Normal file
1
shared/models/overviews/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './videos-overview'
|
18
shared/models/overviews/videos-overview.ts
Normal file
18
shared/models/overviews/videos-overview.ts
Normal file
|
@ -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[]
|
||||
}[]
|
||||
}
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue