<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>
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',
constructor (
private route: ActivatedRoute,
+ private authService: AuthService,
private videoChannelService: VideoChannelService,
private restExtractor: RestExtractor
) { }
ngOnDestroy () {
if (this.routeSub) this.routeSub.unsubscribe()
}
+
+ isUserLoggedIn () {
+ return this.authService.isLoggedIn()
+ }
}
<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>
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;
</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>
@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;
--- /dev/null
+export * from './overview.service'
--- /dev/null
+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)
+ )
+ }
+
+}
--- /dev/null
+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[]
+ }[]
+}
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: [
VideoValidatorsService,
VideoCaptionsValidatorsService,
VideoBlacklistValidatorsService,
+ OverviewService,
I18nPrimengCalendarService,
ScreenService,
</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>
)
}
- 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
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>
<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">
--- /dev/null
+<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>
--- /dev/null
+@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
--- /dev/null
+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
+ }
+}
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 = [
{
component: VideosComponent,
canActivateChild: [ MetaGuard ],
children: [
+ {
+ path: 'overview',
+ component: VideoOverviewComponent,
+ data: {
+ meta: {
+ title: 'Videos overview'
+ }
+ }
+ },
{
path: 'trending',
component: VideoTrendingComponent,
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: [
VideoTrendingComponent,
VideoRecentlyAddedComponent,
VideoLocalComponent,
- VideoUserSubscriptionsComponent
+ VideoUserSubscriptionsComponent,
+ VideoOverviewComponent
],
exports: [
--- /dev/null
+<?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>
}
}
+.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 {
import { videoChannelRouter } from './video-channel'
import * as cors from 'cors'
import { searchRouter } from './search'
+import { overviewsRouter } from './overviews'
const apiRouter = express.Router()
apiRouter.use('/videos', videosRouter)
apiRouter.use('/jobs', jobsRouter)
apiRouter.use('/search', searchRouter)
+apiRouter.use('/overviews', overviewsRouter)
apiRouter.use('/ping', pong)
apiRouter.use('/*', badRequest)
--- /dev/null
+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())
+}
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
}
// ---------------------------------------------------------------------------
+const OVERVIEWS = {
+ VIDEOS: {
+ SAMPLE_THRESHOLD: 4,
+ SAMPLES_COUNT: 2
+ }
+}
+
+// ---------------------------------------------------------------------------
+
const SERVER_ACTOR_NAME = 'peertube'
const ACTIVITY_PUB = {
USER_PASSWORD_RESET_LIFETIME,
USER_EMAIL_VERIFY_LIFETIME,
IMAGE_MIMETYPE_EXT,
+ OVERVIEWS,
SCHEDULER_INTERVALS_MS,
REPEAT_JOBS,
STATIC_DOWNLOAD_PATHS,
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',
})
Videos: VideoModel[]
- static findOrCreateTags (tags: string[], transaction: Transaction) {
+ static findOrCreateTags (tags: string[], transaction: Sequelize.Transaction) {
if (tags === null) return []
const tasks: Bluebird<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))
+ }
}
})
}
+ // 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 {
import './video-privacy'
import './video-schedule-update'
import './video-transcoder'
+import './videos-overview'
--- /dev/null
+/* 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()
+ }
+ })
+})
--- /dev/null
+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 }
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'
--- /dev/null
+export * from './videos-overview'
--- /dev/null
+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[]
+ }[]
+}
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
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 {