Videos overview page: first version

pull/965/merge
Chocobozzz 2018-08-30 14:58:00 +02:00
parent d9eaee3939
commit 2d3741d6d9
32 changed files with 599 additions and 61 deletions

View 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>

View 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()
}
}

View File

@ -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>

View 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;

View 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>

View 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;

View File

@ -0,0 +1 @@
export * from './overview.service'

View 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)
)
}
}

View 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[]
}[]
}

View 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,

View 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"
@ -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>

View 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

View 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">
@ -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">

View File

@ -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>

View File

@ -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;
}
}

View 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
}
}

View 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,

View 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: [

View 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

View 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 {

View 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)

View 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())
}

View 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,

View 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))
}
}

View 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 {

View File

@ -13,3 +13,4 @@ import './video-nsfw'
import './video-privacy'
import './video-schedule-update'
import './video-transcoder'
import './videos-overview'

View 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()
}
})
})

View 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 }

View 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'

View File

@ -0,0 +1 @@
export * from './videos-overview'

View 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[]
}[]
}

View 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 {