From 2d3741d6d92e9bd1f41694c7442a6d1da434e1f2 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 30 Aug 2018 14:58:00 +0200 Subject: [PATCH] Videos overview page: first version --- .../video-channels.component.html | 2 +- .../video-channels.component.ts | 6 ++ client/src/app/menu/menu.component.html | 5 + client/src/app/menu/menu.component.scss | 5 + client/src/app/search/search.component.html | 2 +- client/src/app/search/search.component.scss | 9 -- client/src/app/shared/overview/index.ts | 1 + .../app/shared/overview/overview.service.ts | 76 +++++++++++++++ .../shared/overview/videos-overview.model.ts | 19 ++++ client/src/app/shared/shared.module.ts | 2 + .../app/shared/video/abstract-video-list.html | 8 +- client/src/app/shared/video/video.service.ts | 8 -- .../+video-watch/video-watch.component.html | 28 +++--- .../video-list/video-overview.component.html | 35 +++++++ .../video-list/video-overview.component.scss | 22 +++++ .../video-list/video-overview.component.ts | 56 +++++++++++ .../src/app/videos/videos-routing.module.ts | 10 ++ client/src/app/videos/videos.module.ts | 4 +- client/src/assets/images/menu/globe.svg | 18 ++++ client/src/sass/application.scss | 9 ++ server/controllers/api/index.ts | 2 + server/controllers/api/overviews.ts | 97 +++++++++++++++++++ server/initializers/constants.ts | 13 +++ server/models/video/tag.ts | 24 ++++- server/models/video/video.ts | 23 +++++ server/tests/api/videos/index.ts | 1 + server/tests/api/videos/videos-overview.ts | 96 ++++++++++++++++++ server/tests/utils/overviews/overviews.ts | 18 ++++ shared/models/index.ts | 1 + shared/models/overviews/index.ts | 1 + shared/models/overviews/videos-overview.ts | 18 ++++ shared/models/videos/video.model.ts | 41 ++++---- 32 files changed, 599 insertions(+), 61 deletions(-) create mode 100644 client/src/app/shared/overview/index.ts create mode 100644 client/src/app/shared/overview/overview.service.ts create mode 100644 client/src/app/shared/overview/videos-overview.model.ts create mode 100644 client/src/app/videos/video-list/video-overview.component.html create mode 100644 client/src/app/videos/video-list/video-overview.component.scss create mode 100644 client/src/app/videos/video-list/video-overview.component.ts create mode 100644 client/src/assets/images/menu/globe.svg create mode 100644 server/controllers/api/overviews.ts create mode 100644 server/tests/api/videos/videos-overview.ts create mode 100644 server/tests/utils/overviews/overviews.ts create mode 100644 shared/models/overviews/index.ts create mode 100644 shared/models/overviews/videos-overview.ts diff --git a/client/src/app/+video-channels/video-channels.component.html b/client/src/app/+video-channels/video-channels.component.html index 1941a2eab..e5a32dc92 100644 --- a/client/src/app/+video-channels/video-channels.component.html +++ b/client/src/app/+video-channels/video-channels.component.html @@ -9,7 +9,7 @@
{{ videoChannel.displayName }}
{{ videoChannel.nameWithHost }}
- +
{{ videoChannel.followersCount }} subscribers
diff --git a/client/src/app/+video-channels/video-channels.component.ts b/client/src/app/+video-channels/video-channels.component.ts index 57c55d286..ee2c86915 100644 --- a/client/src/app/+video-channels/video-channels.component.ts +++ b/client/src/app/+video-channels/video-channels.component.ts @@ -5,6 +5,7 @@ import { VideoChannelService } from '@app/shared/video-channel/video-channel.ser 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() + } } diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html index bd03af9b3..8fe6797d6 100644 --- a/client/src/app/menu/menu.component.html +++ b/client/src/app/menu/menu.component.html @@ -47,6 +47,11 @@ Subscriptions + + + Overview + + Trending diff --git a/client/src/app/menu/menu.component.scss b/client/src/app/menu/menu.component.scss index 606fea961..8539c0e56 100644 --- a/client/src/app/menu/menu.component.scss +++ b/client/src/app/menu/menu.component.scss @@ -141,6 +141,11 @@ menu { 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; diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html index d2ed1f881..b35a46ec9 100644 --- a/client/src/app/search/search.component.html +++ b/client/src/app/search/search.component.html @@ -22,7 +22,7 @@ -
+
No results found
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss index e5dfddcc5..f394099e2 100644 --- a/client/src/app/search/search.component.scss +++ b/client/src/app/search/search.component.scss @@ -1,15 +1,6 @@ @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 index 000000000..2f7e41298 --- /dev/null +++ b/client/src/app/shared/overview/index.ts @@ -0,0 +1 @@ +export * from './overview.service' diff --git a/client/src/app/shared/overview/overview.service.ts b/client/src/app/shared/overview/overview.service.ts new file mode 100644 index 000000000..4a4714af6 --- /dev/null +++ b/client/src/app/shared/overview/overview.service.ts @@ -0,0 +1,76 @@ +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 { + return this.authHttp + .get(OverviewService.BASE_OVERVIEW_URL + 'videos') + .pipe( + switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + private updateVideosOverview (serverVideosOverview: VideosOverviewServer): Observable { + const observables: Observable[] = [] + 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 index 000000000..cf02bdb3d --- /dev/null +++ b/client/src/app/shared/overview/videos-overview.model.ts @@ -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 + videos: Video[] + }[] + + tags: { + tag: string + videos: Video[] + }[] +} diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 2cbaaf4ae..b96a9aa41 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -52,6 +52,7 @@ import { ActionDropdownComponent } from '@app/shared/buttons/action-dropdown.com 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, diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html index d4b00c07c..0f48b9a64 100644 --- a/client/src/app/shared/video/abstract-video-list.html +++ b/client/src/app/shared/video/abstract-video-list.html @@ -4,7 +4,7 @@
-
No results.
+
No results.
- - +
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts index 558db9543..7cc98c77a 100644 --- a/client/src/app/shared/video/video.service.ts +++ b/client/src/app/shared/video/video.service.ts @@ -51,14 +51,6 @@ export class VideoService { ) } - viewVideo (uuid: string): Observable { - 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 diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html index 333c9d11b..2c8305777 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.html +++ b/client/src/app/videos/+video-watch/video-watch.component.html @@ -38,7 +38,7 @@ Published {{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views - +
{{ video.name }}
@@ -46,7 +46,7 @@
Published {{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views
-
+
@@ -56,57 +56,57 @@ >
- +
- +
Support
- +
Share
- +
- +
- +
diff --git a/client/src/app/videos/video-list/video-overview.component.html b/client/src/app/videos/video-list/video-overview.component.html new file mode 100644 index 000000000..9282dd59c --- /dev/null +++ b/client/src/app/videos/video-list/video-overview.component.html @@ -0,0 +1,35 @@ +
+ +
No results.
+ + + +
+ + +
+ +
+
+ + + +
diff --git a/client/src/app/videos/video-list/video-overview.component.scss b/client/src/app/videos/video-list/video-overview.component.scss new file mode 100644 index 000000000..8d66cf80a --- /dev/null +++ b/client/src/app/videos/video-list/video-overview.component.scss @@ -0,0 +1,22 @@ +@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 index 000000000..c758e115c --- /dev/null +++ b/client/src/app/videos/video-list/video-overview.component.ts @@ -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 + } +} diff --git a/client/src/app/videos/videos-routing.module.ts b/client/src/app/videos/videos-routing.module.ts index 18ed52570..58988ffd1 100644 --- a/client/src/app/videos/videos-routing.module.ts +++ b/client/src/app/videos/videos-routing.module.ts @@ -6,6 +6,7 @@ import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.c 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, diff --git a/client/src/app/videos/videos.module.ts b/client/src/app/videos/videos.module.ts index 3c3877273..5cf1e944f 100644 --- a/client/src/app/videos/videos.module.ts +++ b/client/src/app/videos/videos.module.ts @@ -6,6 +6,7 @@ import { VideoTrendingComponent } from './video-list/video-trending.component' 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 index 000000000..a4b3db9c5 --- /dev/null +++ b/client/src/assets/images/menu/globe.svg @@ -0,0 +1,18 @@ + + + + globe + Created with Sketch. + + + + + + + + + + + + + diff --git a/client/src/sass/application.scss b/client/src/sass/application.scss index 21df23c18..38b7ea8d4 100644 --- a/client/src/sass/application.scss +++ b/client/src/sass/application.scss @@ -293,6 +293,15 @@ table { } } +.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 { diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts index e928a7478..8a58b5466 100644 --- a/server/controllers/api/index.ts +++ b/server/controllers/api/index.ts @@ -10,6 +10,7 @@ import { badRequest } from '../../helpers/express-utils' 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 index 000000000..56f921ce5 --- /dev/null +++ b/server/controllers/api/overviews.ts @@ -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()) +} diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 5d93c6b82..16d8dca68 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -58,6 +58,9 @@ const ROUTE_CACHE_LIFETIME = { 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, diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts index 6d79a5575..e39a418cd 100644 --- a/server/models/video/tag.ts +++ b/server/models/video/tag.ts @@ -1,10 +1,11 @@ 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 { }) Videos: VideoModel[] - static findOrCreateTags (tags: string[], transaction: Transaction) { + static findOrCreateTags (tags: string[], transaction: Sequelize.Transaction) { if (tags === null) return [] const tasks: Bluebird[] = [] @@ -59,4 +60,23 @@ export class TagModel extends Model { return Promise.all(tasks) } + + // threshold corresponds to how many video the field should have to be returned + static getRandomSamples (threshold: number, count: number): Bluebird { + 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)) + } } diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 3410833c8..695990b17 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -1083,6 +1083,29 @@ export class VideoModel extends Model { }) } + // 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 = { + 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 { diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts index bc66a7824..8286ff356 100644 --- a/server/tests/api/videos/index.ts +++ b/server/tests/api/videos/index.ts @@ -13,3 +13,4 @@ import './video-nsfw' 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 index 000000000..1514d1bda --- /dev/null +++ b/server/tests/api/videos/videos-overview.ts @@ -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 index 000000000..23e3ceb1e --- /dev/null +++ b/server/tests/utils/overviews/overviews.ts @@ -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 } diff --git a/shared/models/index.ts b/shared/models/index.ts index 1db00c295..170f620e7 100644 --- a/shared/models/index.ts +++ b/shared/models/index.ts @@ -4,6 +4,7 @@ export * from './users' 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 index 000000000..376609efa --- /dev/null +++ b/shared/models/overviews/index.ts @@ -0,0 +1 @@ +export * from './videos-overview' diff --git a/shared/models/overviews/videos-overview.ts b/shared/models/overviews/videos-overview.ts new file mode 100644 index 000000000..ee009d94c --- /dev/null +++ b/shared/models/overviews/videos-overview.ts @@ -0,0 +1,18 @@ +import { Video, VideoChannelAttribute, VideoConstant } from '../videos' + +export interface VideosOverview { + channels: { + channel: VideoChannelAttribute + videos: Video[] + }[] + + categories: { + category: VideoConstant + videos: Video[] + }[] + + tags: { + tag: string + videos: Video[] + }[] +} diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts index 8e1fbe444..b47ab1ab8 100644 --- a/shared/models/videos/video.model.ts +++ b/shared/models/videos/video.model.ts @@ -17,6 +17,26 @@ export interface VideoFile { 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 { -- 2.41.0