diff options
Diffstat (limited to 'server')
-rw-r--r-- | server/controllers/api/index.ts | 2 | ||||
-rw-r--r-- | server/controllers/api/overviews.ts | 97 | ||||
-rw-r--r-- | server/initializers/constants.ts | 13 | ||||
-rw-r--r-- | server/models/video/tag.ts | 24 | ||||
-rw-r--r-- | server/models/video/video.ts | 23 | ||||
-rw-r--r-- | server/tests/api/videos/index.ts | 1 | ||||
-rw-r--r-- | server/tests/api/videos/videos-overview.ts | 96 | ||||
-rw-r--r-- | server/tests/utils/overviews/overviews.ts | 18 |
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' | |||
10 | import { videoChannelRouter } from './video-channel' | 10 | import { videoChannelRouter } from './video-channel' |
11 | import * as cors from 'cors' | 11 | import * as cors from 'cors' |
12 | import { searchRouter } from './search' | 12 | import { searchRouter } from './search' |
13 | import { overviewsRouter } from './overviews' | ||
13 | 14 | ||
14 | const apiRouter = express.Router() | 15 | const apiRouter = express.Router() |
15 | 16 | ||
@@ -28,6 +29,7 @@ apiRouter.use('/video-channels', videoChannelRouter) | |||
28 | apiRouter.use('/videos', videosRouter) | 29 | apiRouter.use('/videos', videosRouter) |
29 | apiRouter.use('/jobs', jobsRouter) | 30 | apiRouter.use('/jobs', jobsRouter) |
30 | apiRouter.use('/search', searchRouter) | 31 | apiRouter.use('/search', searchRouter) |
32 | apiRouter.use('/overviews', overviewsRouter) | ||
31 | apiRouter.use('/ping', pong) | 33 | apiRouter.use('/ping', pong) |
32 | apiRouter.use('/*', badRequest) | 34 | apiRouter.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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { buildNSFWFilter } from '../../helpers/express-utils' | ||
3 | import { VideoModel } from '../../models/video/video' | ||
4 | import { asyncMiddleware, executeIfActivityPub } from '../../middlewares' | ||
5 | import { TagModel } from '../../models/video/tag' | ||
6 | import { VideosOverview } from '../../../shared/models/overviews' | ||
7 | import { OVERVIEWS, ROUTE_CACHE_LIFETIME } from '../../initializers' | ||
8 | import { cacheRoute } from '../../middlewares/cache' | ||
9 | |||
10 | const overviewsRouter = express.Router() | ||
11 | |||
12 | overviewsRouter.get('/videos', | ||
13 | executeIfActivityPub(asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.OVERVIEWS.VIDEOS))), | ||
14 | asyncMiddleware(getVideosOverview) | ||
15 | ) | ||
16 | |||
17 | // --------------------------------------------------------------------------- | ||
18 | |||
19 | export { overviewsRouter } | ||
20 | |||
21 | // --------------------------------------------------------------------------- | ||
22 | |||
23 | // This endpoint could be quite long, but we cache it | ||
24 | async 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 | |||
40 | async 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 | |||
50 | async 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 | |||
61 | async 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 | |||
72 | async 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 | |||
83 | async 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 | ||
470 | const OVERVIEWS = { | ||
471 | VIDEOS: { | ||
472 | SAMPLE_THRESHOLD: 4, | ||
473 | SAMPLES_COUNT: 2 | ||
474 | } | ||
475 | } | ||
476 | |||
477 | // --------------------------------------------------------------------------- | ||
478 | |||
467 | const SERVER_ACTOR_NAME = 'peertube' | 479 | const SERVER_ACTOR_NAME = 'peertube' |
468 | 480 | ||
469 | const ACTIVITY_PUB = { | 481 | const 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 @@ | |||
1 | import * as Bluebird from 'bluebird' | 1 | import * as Bluebird from 'bluebird' |
2 | import { Transaction } from 'sequelize' | 2 | import * as Sequelize from 'sequelize' |
3 | import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | 3 | import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' |
4 | import { isVideoTagValid } from '../../helpers/custom-validators/videos' | 4 | import { isVideoTagValid } from '../../helpers/custom-validators/videos' |
5 | import { throwIfNotValid } from '../utils' | 5 | import { throwIfNotValid } from '../utils' |
6 | import { VideoModel } from './video' | 6 | import { VideoModel } from './video' |
7 | import { VideoTagModel } from './video-tag' | 7 | import { VideoTagModel } from './video-tag' |
8 | import { 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' | |||
13 | import './video-privacy' | 13 | import './video-privacy' |
14 | import './video-schedule-update' | 14 | import './video-schedule-update' |
15 | import './video-transcoder' | 15 | import './video-transcoder' |
16 | 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 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { flushTests, killallServers, runServer, ServerInfo, setAccessTokensToServers, uploadVideo } from '../../utils' | ||
6 | import { getVideosOverview } from '../../utils/overviews/overviews' | ||
7 | import { VideosOverview } from '../../../../shared/models/overviews' | ||
8 | |||
9 | const expect = chai.expect | ||
10 | |||
11 | describe('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 @@ | |||
1 | import { makeGetRequest } from '../requests/requests' | ||
2 | |||
3 | function 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 | |||
18 | export { getVideosOverview } | ||