diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml index 45dba2c19b..5ab6258194 100644 --- a/config/custom-environment-variables.yml +++ b/config/custom-environment-variables.yml @@ -98,3 +98,4 @@ private: wheelmap_token: 'WHEELMAP_TOKEN' influx_username: 'INFLUX_USERNAME' influx_password: 'INFLUX_PASSWORD' + youtube_api_key: 'YOUTUBE_API_KEY' diff --git a/config/local-shields-io-production.template.yml b/config/local-shields-io-production.template.yml index 6e021d35b0..e2b25630de 100644 --- a/config/local-shields-io-production.template.yml +++ b/config/local-shields-io-production.template.yml @@ -10,3 +10,4 @@ private: twitch_client_id: ... twitch_client_secret: ... wheelmap_token: ... + youtube_api_key: ... diff --git a/config/local.template.yml b/config/local.template.yml index ff9d531cc2..1a1e12a473 100644 --- a/config/local.template.yml +++ b/config/local.template.yml @@ -8,3 +8,4 @@ private: twitch_client_id: '...' twitch_client_secret: '...' wheelmap_token: '...' + youtube_api_key: '...' diff --git a/core/server/server.js b/core/server/server.js index adc9c75465..4fab514436 100644 --- a/core/server/server.js +++ b/core/server/server.js @@ -180,6 +180,7 @@ const privateConfigSchema = Joi.object({ wheelmap_token: Joi.string(), influx_username: Joi.string(), influx_password: Joi.string(), + youtube_api_key: Joi.string(), }).required() const privateMetricsInfluxConfigSchema = privateConfigSchema.append({ influx_username: Joi.string().required(), diff --git a/doc/production-hosting.md b/doc/production-hosting.md index 123dd3a00c..3096417e30 100644 --- a/doc/production-hosting.md +++ b/doc/production-hosting.md @@ -24,6 +24,7 @@ | Cloudflare | Admin access | @espadrine, @paulmelnikow | | GitHub | OAuth app | @espadrine ([could be transferred to the badges org][oauth transfer]) | | Twitch | OAuth app | @PyvesB | +| YouTube | Account owner | @PyvesB | | OpenStreetMap (for Wheelmap) | Account owner | @paulmelnikow | | DNS | Account owner | @olivierlacan | | DNS | Read-only account access | @espadrine, @paulmelnikow, @chris48s | diff --git a/doc/server-secrets.md b/doc/server-secrets.md index 1f75a12d02..55463eaa35 100644 --- a/doc/server-secrets.md +++ b/doc/server-secrets.md @@ -228,6 +228,16 @@ displayed on your profile page. [wheelmap token]: http://classic.wheelmap.org/en/users/sign_in +### YouTube + +- `YOUTUBE_API_KEY` (yml: `private.youtube_api_key`) + +The YouTube API requires authentication. To obtain an API key, +log in to a Google account, go to the [credentials page][youtube credentials], +and create an API key for the YouTube Data API v3. + +[youtube credentials]: https://console.developers.google.com/apis/credentials + ## Error reporting - `SENTRY_DSN` (yml: `private.sentry_dsn`) diff --git a/services/youtube/youtube-base.js b/services/youtube/youtube-base.js new file mode 100644 index 0000000000..b6e29ca12a --- /dev/null +++ b/services/youtube/youtube-base.js @@ -0,0 +1,72 @@ +'use strict' + +const Joi = require('@hapi/joi') +const { BaseJsonService, NotFound } = require('..') +const { metric } = require('../text-formatters') +const { nonNegativeInteger } = require('../validators') + +const schema = Joi.object({ + items: Joi.array() + .items( + Joi.object({ + statistics: Joi.object({ + viewCount: nonNegativeInteger, + likeCount: nonNegativeInteger, + dislikeCount: nonNegativeInteger, + commentCount: nonNegativeInteger, + }).required(), + }) + ) + .required(), +}).required() + +module.exports = class YouTubeBase extends BaseJsonService { + static get category() { + return 'social' + } + + static get auth() { + return { + passKey: 'youtube_api_key', + authorizedOrigins: ['https://www.googleapis.com'], + isRequired: true, + } + } + + static get defaultBadgeData() { + return { label: 'youtube', color: 'red', namedLogo: 'youtube' } + } + + static renderSingleStat({ statistics, statisticName, videoId }) { + return { + label: `${statisticName}s`, + message: metric(statistics[`${statisticName}Count`]), + style: 'social', + link: `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`, + } + } + + async fetch({ videoId }) { + return this._requestJson( + this.authHelper.withQueryStringAuth( + { passKey: 'key' }, + { + schema, + url: 'https://www.googleapis.com/youtube/v3/videos', + options: { + qs: { id: videoId, part: 'statistics' }, + }, + } + ) + ) + } + + async handle({ videoId }, queryParams) { + const json = await this.fetch({ videoId }) + if (json.items.length === 0) { + throw new NotFound({ prettyMessage: 'video not found' }) + } + const statistics = json.items[0].statistics + return this.constructor.render({ statistics, videoId }, queryParams) + } +} diff --git a/services/youtube/youtube-comments.service.js b/services/youtube/youtube-comments.service.js new file mode 100644 index 0000000000..d76ab18482 --- /dev/null +++ b/services/youtube/youtube-comments.service.js @@ -0,0 +1,36 @@ +'use strict' + +const YouTubeBase = require('./youtube-base') + +module.exports = class YouTubeComments extends YouTubeBase { + static get route() { + return { + base: 'youtube/comments', + pattern: ':videoId', + } + } + + static get examples() { + const preview = this.render({ + statistics: { commentCount: 209 }, + videoId: 'wGJHwc5ksMA', + }) + // link[] is not allowed in examples + delete preview.link + return [ + { + title: 'YouTube Video Comments', + namedParams: { videoId: 'wGJHwc5ksMA' }, + staticPreview: preview, + }, + ] + } + + static render({ statistics, videoId }) { + return super.renderSingleStat({ + statistics, + statisticName: 'comment', + videoId, + }) + } +} diff --git a/services/youtube/youtube-comments.tester.js b/services/youtube/youtube-comments.tester.js new file mode 100644 index 0000000000..0d9930a118 --- /dev/null +++ b/services/youtube/youtube-comments.tester.js @@ -0,0 +1,25 @@ +'use strict' + +const t = (module.exports = require('../tester').createServiceTester()) +const { noToken } = require('../test-helpers') +const { isMetric } = require('../test-validators') +const noYouTubeToken = noToken(require('./youtube-comments.service')) + +t.create('video comment count') + .skipWhen(noYouTubeToken) + .get('/wGJHwc5ksMA.json') + .expectBadge({ + label: 'comments', + message: isMetric, + color: 'red', + link: ['https://www.youtube.com/watch?v=wGJHwc5ksMA'], + }) + +t.create('video not found') + .skipWhen(noYouTubeToken) + .get('/doesnotexist.json') + .expectBadge({ + label: 'youtube', + message: 'video not found', + color: 'red', + }) diff --git a/services/youtube/youtube-likes.service.js b/services/youtube/youtube-likes.service.js new file mode 100644 index 0000000000..0087d45a4a --- /dev/null +++ b/services/youtube/youtube-likes.service.js @@ -0,0 +1,71 @@ +'use strict' + +const Joi = require('@hapi/joi') +const { metric } = require('../text-formatters') +const YouTubeBase = require('./youtube-base') + +const queryParamSchema = Joi.object({ + withDislikes: Joi.equal(''), +}).required() + +module.exports = class YouTubeLikes extends YouTubeBase { + static get route() { + return { + base: 'youtube/likes', + pattern: ':videoId', + queryParamSchema, + } + } + + static get examples() { + const previewLikes = this.render({ + statistics: { likeCount: 7 }, + videoId: 'abBdk8bSPKU', + }) + const previewVotes = this.render( + { + statistics: { likeCount: 10236, dislikeCount: 396 }, + videoId: 'pU9Q6oiQNd0', + }, + { + withDislikes: '', + } + ) + // link[] is not allowed in examples + delete previewLikes.link + delete previewVotes.link + return [ + { + title: 'YouTube Video Likes', + namedParams: { videoId: 'abBdk8bSPKU' }, + staticPreview: previewLikes, + }, + { + title: 'YouTube Video Votes', + namedParams: { videoId: 'pU9Q6oiQNd0' }, + staticPreview: previewVotes, + queryParams: { + withDislikes: null, + }, + }, + ] + } + + static render({ statistics, videoId }, queryParams) { + if (queryParams && typeof queryParams.withDislikes !== 'undefined') { + return { + label: 'votes', + message: `${metric(statistics.likeCount)} 👍 ${metric( + statistics.dislikeCount + )} 👎`, + style: 'social', + link: `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`, + } + } + return super.renderSingleStat({ + statistics, + statisticName: 'like', + videoId, + }) + } +} diff --git a/services/youtube/youtube-likes.tester.js b/services/youtube/youtube-likes.tester.js new file mode 100644 index 0000000000..9c50d1a9b6 --- /dev/null +++ b/services/youtube/youtube-likes.tester.js @@ -0,0 +1,38 @@ +'use strict' + +const Joi = require('@hapi/joi') +const t = (module.exports = require('../tester').createServiceTester()) +const { noToken } = require('../test-helpers') +const { isMetric } = require('../test-validators') +const noYouTubeToken = noToken(require('./youtube-likes.service')) + +t.create('video like count') + .skipWhen(noYouTubeToken) + .get('/pU9Q6oiQNd0.json') + .expectBadge({ + label: 'likes', + message: isMetric, + color: 'red', + link: ['https://www.youtube.com/watch?v=pU9Q6oiQNd0'], + }) + +t.create('video vote count') + .skipWhen(noYouTubeToken) + .get('/pU9Q6oiQNd0.json?withDislikes') + .expectBadge({ + label: 'votes', + message: Joi.string().regex( + /^([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) 👍 ([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) 👎$/ + ), + color: 'red', + link: ['https://www.youtube.com/watch?v=pU9Q6oiQNd0'], + }) + +t.create('video not found') + .skipWhen(noYouTubeToken) + .get('/doesnotexist.json?withDislikes') + .expectBadge({ + label: 'youtube', + message: 'video not found', + color: 'red', + }) diff --git a/services/youtube/youtube-views.service.js b/services/youtube/youtube-views.service.js new file mode 100644 index 0000000000..384053aed9 --- /dev/null +++ b/services/youtube/youtube-views.service.js @@ -0,0 +1,36 @@ +'use strict' + +const YouTubeBase = require('./youtube-base') + +module.exports = class YouTubeViews extends YouTubeBase { + static get route() { + return { + base: 'youtube/views', + pattern: ':videoId', + } + } + + static get examples() { + const preview = this.render({ + statistics: { viewCount: 14577 }, + videoId: 'abBdk8bSPKU', + }) + // link[] is not allowed in examples + delete preview.link + return [ + { + title: 'YouTube Video Views', + namedParams: { videoId: 'abBdk8bSPKU' }, + staticPreview: preview, + }, + ] + } + + static render({ statistics, videoId }) { + return super.renderSingleStat({ + statistics, + statisticName: 'view', + videoId, + }) + } +} diff --git a/services/youtube/youtube-views.tester.js b/services/youtube/youtube-views.tester.js new file mode 100644 index 0000000000..1c44b4b0ae --- /dev/null +++ b/services/youtube/youtube-views.tester.js @@ -0,0 +1,25 @@ +'use strict' + +const t = (module.exports = require('../tester').createServiceTester()) +const { noToken } = require('../test-helpers') +const { isMetric } = require('../test-validators') +const noYouTubeToken = noToken(require('./youtube-views.service')) + +t.create('video view count') + .skipWhen(noYouTubeToken) + .get('/abBdk8bSPKU.json') + .expectBadge({ + label: 'views', + message: isMetric, + color: 'red', + link: ['https://www.youtube.com/watch?v=abBdk8bSPKU'], + }) + +t.create('video not found') + .skipWhen(noYouTubeToken) + .get('/doesnotexist.json') + .expectBadge({ + label: 'youtube', + message: 'video not found', + color: 'red', + })