aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/index.ts2
-rw-r--r--server/controllers/api/overviews.ts97
-rw-r--r--server/initializers/constants.ts13
-rw-r--r--server/models/video/tag.ts24
-rw-r--r--server/models/video/video.ts23
-rw-r--r--server/tests/api/videos/index.ts1
-rw-r--r--server/tests/api/videos/videos-overview.ts96
-rw-r--r--server/tests/utils/overviews/overviews.ts18
8 files changed, 272 insertions, 2 deletions
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'
10import { videoChannelRouter } from './video-channel' 10import { videoChannelRouter } from './video-channel'
11import * as cors from 'cors' 11import * as cors from 'cors'
12import { searchRouter } from './search' 12import { searchRouter } from './search'
13import { overviewsRouter } from './overviews'
13 14
14const apiRouter = express.Router() 15const apiRouter = express.Router()
15 16
@@ -28,6 +29,7 @@ apiRouter.use('/video-channels', videoChannelRouter)
28apiRouter.use('/videos', videosRouter) 29apiRouter.use('/videos', videosRouter)
29apiRouter.use('/jobs', jobsRouter) 30apiRouter.use('/jobs', jobsRouter)
30apiRouter.use('/search', searchRouter) 31apiRouter.use('/search', searchRouter)
32apiRouter.use('/overviews', overviewsRouter)
31apiRouter.use('/ping', pong) 33apiRouter.use('/ping', pong)
32apiRouter.use('/*', badRequest) 34apiRouter.use('/*', badRequest)
33 35
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 @@
1import * as express from 'express'
2import { buildNSFWFilter } from '../../helpers/express-utils'
3import { VideoModel } from '../../models/video/video'
4import { asyncMiddleware, executeIfActivityPub } from '../../middlewares'
5import { TagModel } from '../../models/video/tag'
6import { VideosOverview } from '../../../shared/models/overviews'
7import { OVERVIEWS, ROUTE_CACHE_LIFETIME } from '../../initializers'
8import { cacheRoute } from '../../middlewares/cache'
9
10const overviewsRouter = express.Router()
11
12overviewsRouter.get('/videos',
13 executeIfActivityPub(asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.OVERVIEWS.VIDEOS))),
14 asyncMiddleware(getVideosOverview)
15)
16
17// ---------------------------------------------------------------------------
18
19export { overviewsRouter }
20
21// ---------------------------------------------------------------------------
22
23// This endpoint could be quite long, but we cache it
24async function getVideosOverview (req: express.Request, res: express.Response) {
25 const attributes = await buildSamples()
26 const result: VideosOverview = {
27 categories: await Promise.all(attributes.categories.map(c => getVideosByCategory(c, res))),
28 channels: await Promise.all(attributes.channels.map(c => getVideosByChannel(c, res))),
29 tags: await Promise.all(attributes.tags.map(t => getVideosByTag(t, res)))
30 }
31
32 // Cleanup our object
33 for (const key of Object.keys(result)) {
34 result[key] = result[key].filter(v => v !== undefined)
35 }
36
37 return res.json(result)
38}
39
40async function buildSamples () {
41 const [ categories, channels, tags ] = await Promise.all([
42 VideoModel.getRandomFieldSamples('category', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT),
43 VideoModel.getRandomFieldSamples('channelId', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD ,OVERVIEWS.VIDEOS.SAMPLES_COUNT),
44 TagModel.getRandomSamples(OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT)
45 ])
46
47 return { categories, channels, tags }
48}
49
50async function getVideosByTag (tag: string, res: express.Response) {
51 const videos = await getVideos(res, { tagsOneOf: [ tag ] })
52
53 if (videos.length === 0) return undefined
54
55 return {
56 tag,
57 videos
58 }
59}
60
61async function getVideosByCategory (category: number, res: express.Response) {
62 const videos = await getVideos(res, { categoryOneOf: [ category ] })
63
64 if (videos.length === 0) return undefined
65
66 return {
67 category: videos[0].category,
68 videos
69 }
70}
71
72async function getVideosByChannel (channelId: number, res: express.Response) {
73 const videos = await getVideos(res, { videoChannelId: channelId })
74
75 if (videos.length === 0) return undefined
76
77 return {
78 channel: videos[0].channel,
79 videos
80 }
81}
82
83async function getVideos (
84 res: express.Response,
85 where: { videoChannelId?: number, tagsOneOf?: string[], categoryOneOf?: number[] }
86) {
87 const { data } = await VideoModel.listForApi(Object.assign({
88 start: 0,
89 count: 10,
90 sort: '-createdAt',
91 includeLocalVideos: true,
92 nsfw: buildNSFWFilter(res),
93 withFiles: false
94 }, where))
95
96 return data.map(d => d.toFormattedJSON())
97}
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 = {
58 ROBOTS: '2 hours', 58 ROBOTS: '2 hours',
59 NODEINFO: '10 minutes', 59 NODEINFO: '10 minutes',
60 DNT_POLICY: '1 week', 60 DNT_POLICY: '1 week',
61 OVERVIEWS: {
62 VIDEOS: '1 hour'
63 },
61 ACTIVITY_PUB: { 64 ACTIVITY_PUB: {
62 VIDEOS: '1 second' // 1 second, cache concurrent requests after a broadcast for example 65 VIDEOS: '1 second' // 1 second, cache concurrent requests after a broadcast for example
63 } 66 }
@@ -464,6 +467,15 @@ const TORRENT_MIMETYPE_EXT = {
464 467
465// --------------------------------------------------------------------------- 468// ---------------------------------------------------------------------------
466 469
470const OVERVIEWS = {
471 VIDEOS: {
472 SAMPLE_THRESHOLD: 4,
473 SAMPLES_COUNT: 2
474 }
475}
476
477// ---------------------------------------------------------------------------
478
467const SERVER_ACTOR_NAME = 'peertube' 479const SERVER_ACTOR_NAME = 'peertube'
468 480
469const ACTIVITY_PUB = { 481const ACTIVITY_PUB = {
@@ -666,6 +678,7 @@ export {
666 USER_PASSWORD_RESET_LIFETIME, 678 USER_PASSWORD_RESET_LIFETIME,
667 USER_EMAIL_VERIFY_LIFETIME, 679 USER_EMAIL_VERIFY_LIFETIME,
668 IMAGE_MIMETYPE_EXT, 680 IMAGE_MIMETYPE_EXT,
681 OVERVIEWS,
669 SCHEDULER_INTERVALS_MS, 682 SCHEDULER_INTERVALS_MS,
670 REPEAT_JOBS, 683 REPEAT_JOBS,
671 STATIC_DOWNLOAD_PATHS, 684 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 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { Transaction } from 'sequelize' 2import * as Sequelize from 'sequelize'
3import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 3import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
4import { isVideoTagValid } from '../../helpers/custom-validators/videos' 4import { isVideoTagValid } from '../../helpers/custom-validators/videos'
5import { throwIfNotValid } from '../utils' 5import { throwIfNotValid } from '../utils'
6import { VideoModel } from './video' 6import { VideoModel } from './video'
7import { VideoTagModel } from './video-tag' 7import { VideoTagModel } from './video-tag'
8import { VideoPrivacy, VideoState } from '../../../shared/models/videos'
8 9
9@Table({ 10@Table({
10 tableName: 'tag', 11 tableName: 'tag',
@@ -36,7 +37,7 @@ export class TagModel extends Model<TagModel> {
36 }) 37 })
37 Videos: VideoModel[] 38 Videos: VideoModel[]
38 39
39 static findOrCreateTags (tags: string[], transaction: Transaction) { 40 static findOrCreateTags (tags: string[], transaction: Sequelize.Transaction) {
40 if (tags === null) return [] 41 if (tags === null) return []
41 42
42 const tasks: Bluebird<TagModel>[] = [] 43 const tasks: Bluebird<TagModel>[] = []
@@ -59,4 +60,23 @@ export class TagModel extends Model<TagModel> {
59 60
60 return Promise.all(tasks) 61 return Promise.all(tasks)
61 } 62 }
63
64 // threshold corresponds to how many video the field should have to be returned
65 static getRandomSamples (threshold: number, count: number): Bluebird<string[]> {
66 const query = 'SELECT tag.name FROM tag ' +
67 'INNER JOIN "videoTag" ON "videoTag"."tagId" = tag.id ' +
68 'INNER JOIN video ON video.id = "videoTag"."videoId" ' +
69 'WHERE video.privacy = $videoPrivacy AND video.state = $videoState ' +
70 'GROUP BY tag.name HAVING COUNT(tag.name) >= $threshold ' +
71 'ORDER BY random() ' +
72 'LIMIT $count'
73
74 const options = {
75 bind: { threshold, count, videoPrivacy: VideoPrivacy.PUBLIC, videoState: VideoState.PUBLISHED },
76 type: Sequelize.QueryTypes.SELECT
77 }
78
79 return TagModel.sequelize.query(query, options)
80 .then(data => data.map(d => d.name))
81 }
62} 82}
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<VideoModel> {
1083 }) 1083 })
1084 } 1084 }
1085 1085
1086 // threshold corresponds to how many video the field should have to be returned
1087 static getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1088 const query: IFindOptions<VideoModel> = {
1089 attributes: [ field ],
1090 limit: count,
1091 group: field,
1092 having: Sequelize.where(Sequelize.fn('COUNT', Sequelize.col(field)), {
1093 [Sequelize.Op.gte]: threshold
1094 }) as any, // FIXME: typings
1095 where: {
1096 [field]: {
1097 [Sequelize.Op.not]: null,
1098 },
1099 privacy: VideoPrivacy.PUBLIC,
1100 state: VideoState.PUBLISHED
1101 },
1102 order: [ this.sequelize.random() ]
1103 }
1104
1105 return VideoModel.findAll(query)
1106 .then(rows => rows.map(r => r[field]))
1107 }
1108
1086 private static buildActorWhereWithFilter (filter?: VideoFilter) { 1109 private static buildActorWhereWithFilter (filter?: VideoFilter) {
1087 if (filter && filter === 'local') { 1110 if (filter && filter === 'local') {
1088 return { 1111 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'
13import './video-privacy' 13import './video-privacy'
14import './video-schedule-update' 14import './video-schedule-update'
15import './video-transcoder' 15import './video-transcoder'
16import './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 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { flushTests, killallServers, runServer, ServerInfo, setAccessTokensToServers, uploadVideo } from '../../utils'
6import { getVideosOverview } from '../../utils/overviews/overviews'
7import { VideosOverview } from '../../../../shared/models/overviews'
8
9const expect = chai.expect
10
11describe('Test a videos overview', function () {
12 let server: ServerInfo = null
13
14 before(async function () {
15 this.timeout(30000)
16
17 await flushTests()
18
19 server = await runServer(1)
20
21 await setAccessTokensToServers([ server ])
22 })
23
24 it('Should send empty overview', async function () {
25 const res = await getVideosOverview(server.url)
26
27 const overview: VideosOverview = res.body
28 expect(overview.tags).to.have.lengthOf(0)
29 expect(overview.categories).to.have.lengthOf(0)
30 expect(overview.channels).to.have.lengthOf(0)
31 })
32
33 it('Should upload 3 videos in a specific category, tag and channel but not include them in overview', async function () {
34 for (let i = 0; i < 3; i++) {
35 await uploadVideo(server.url, server.accessToken, {
36 name: 'video ' + i,
37 category: 3,
38 tags: [ 'coucou1', 'coucou2' ]
39 })
40 }
41
42 const res = await getVideosOverview(server.url)
43
44 const overview: VideosOverview = res.body
45 expect(overview.tags).to.have.lengthOf(0)
46 expect(overview.categories).to.have.lengthOf(0)
47 expect(overview.channels).to.have.lengthOf(0)
48 })
49
50 it('Should upload another video and include all videos in the overview', async function () {
51 await uploadVideo(server.url, server.accessToken, {
52 name: 'video 3',
53 category: 3,
54 tags: [ 'coucou1', 'coucou2' ]
55 })
56
57 const res = await getVideosOverview(server.url)
58
59 const overview: VideosOverview = res.body
60 expect(overview.tags).to.have.lengthOf(2)
61 expect(overview.categories).to.have.lengthOf(1)
62 expect(overview.channels).to.have.lengthOf(1)
63 })
64
65 it('Should have the correct overview', async function () {
66 const res = await getVideosOverview(server.url)
67
68 const overview: VideosOverview = res.body
69
70 for (const attr of [ 'tags', 'categories', 'channels' ]) {
71 const obj = overview[attr][0]
72
73 expect(obj.videos).to.have.lengthOf(4)
74 expect(obj.videos[0].name).to.equal('video 3')
75 expect(obj.videos[1].name).to.equal('video 2')
76 expect(obj.videos[2].name).to.equal('video 1')
77 expect(obj.videos[3].name).to.equal('video 0')
78 }
79
80 expect(overview.tags.find(t => t.tag === 'coucou1')).to.not.be.undefined
81 expect(overview.tags.find(t => t.tag === 'coucou2')).to.not.be.undefined
82
83 expect(overview.categories[0].category.id).to.equal(3)
84
85 expect(overview.channels[0].channel.name).to.equal('root_channel')
86 })
87
88 after(async function () {
89 killallServers([ server ])
90
91 // Keep the logs if the test failed
92 if (this['ok']) {
93 await flushTests()
94 }
95 })
96})
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 @@
1import { makeGetRequest } from '../requests/requests'
2
3function getVideosOverview (url: string, useCache = false) {
4 const path = '/api/v1/overviews/videos'
5
6 const query = {
7 t: useCache ? undefined : new Date().getTime()
8 }
9
10 return makeGetRequest({
11 url,
12 path,
13 query,
14 statusCodeExpected: 200
15 })
16}
17
18export { getVideosOverview }