From 36b4cc5baac8bf4bd3bb9f4ddb459505fc567742 Mon Sep 17 00:00:00 2001
From: Outvi V <19144373+outloudvi@users.noreply.github.com>
Date: Fri, 4 Feb 2022 18:46:53 +0800
Subject: [PATCH] feat(route): add spotify (#8966)
* feat: /spotify/playlist/:id
* feat: /spotify/artist
* feat(spotify/artist): add to radar
* feat: /spotify/saved
* feat: /spotify/top/{tracks,artists}
* feat(spotify): add images for artist and playlist
* docs: /spotify/*
* docs/en: /spotify configurations
* chore(spotify): apiKey/Secret -> clientId/Secret
* fix(spotify/utils): genres can be empty
---
app.json | 9 ++++++
docs/en/install/README.md | 10 ++++++
docs/en/multimedia.md | 24 ++++++++++++++
docs/install/README.md | 10 ++++++
docs/multimedia.md | 22 +++++++++++++
lib/config.js | 5 +++
lib/v2/spotify/artist.js | 41 ++++++++++++++++++++++++
lib/v2/spotify/maintainer.js | 7 ++++
lib/v2/spotify/playlist.js | 31 ++++++++++++++++++
lib/v2/spotify/radar.js | 37 +++++++++++++++++++++
lib/v2/spotify/router.js | 7 ++++
lib/v2/spotify/saved.js | 30 +++++++++++++++++
lib/v2/spotify/top.js | 24 ++++++++++++++
lib/v2/spotify/utils.js | 62 ++++++++++++++++++++++++++++++++++++
14 files changed, 319 insertions(+)
create mode 100644 lib/v2/spotify/artist.js
create mode 100644 lib/v2/spotify/maintainer.js
create mode 100644 lib/v2/spotify/playlist.js
create mode 100644 lib/v2/spotify/radar.js
create mode 100644 lib/v2/spotify/router.js
create mode 100644 lib/v2/spotify/saved.js
create mode 100644 lib/v2/spotify/top.js
create mode 100644 lib/v2/spotify/utils.js
diff --git a/app.json b/app.json
index 564997c49..5232a9445 100644
--- a/app.json
+++ b/app.json
@@ -20,6 +20,15 @@
"DISQUS_API_KEY": {
"required": false
},
+ "SPOTIFY_CLIENT_ID": {
+ "required": false
+ },
+ "SPOTIFY_CLIENT_SECRET": {
+ "required": false
+ },
+ "SPOTIFY_REFRESHTOKEN": {
+ "required": false
+ },
"TWITTER_CONSUMER_KEY": {
"required": false
},
diff --git a/docs/en/install/README.md b/docs/en/install/README.md
index 2f8355f65..c6f7be0fa 100644
--- a/docs/en/install/README.md
+++ b/docs/en/install/README.md
@@ -538,6 +538,16 @@ See docs of the specified route and `lib/config.js` for detailed information.
- `GITHUB_ACCESS_TOKEN`: GitHub Access Token
+- spotify: [API key registration](https://developer.spotify.com)
+
+ - `SPOTIFY_CLIENT_ID`:Client ID of the application
+
+ - `SPOTIFY_CLIENT_SECRET`:Client secret of the application
+
+- spotify (user data related routes):
+
+ - `SPOTIFY_REFRESHTOKEN`:The refresh token of the user f the Spotify application. Check [this gist](https://gist.github.com/outloudvi/d1bbeb5e989db5385384a223a7263744) for detailed information.
+
- Instagram:
- `IG_USERNAME`: Your Instagram username
diff --git a/docs/en/multimedia.md b/docs/en/multimedia.md
index d8cb617a1..f369001b0 100644
--- a/docs/en/multimedia.md
+++ b/docs/en/multimedia.md
@@ -150,6 +150,30 @@ Refer to [Pornhub F.A.Qs](https://help.pornhub.com/hc/en-us/articles/36004432703
+## Spotify
+
+
+### Artist Albums
+
+
+
+### Playlist
+
+
+
+### Personal Saved Tracks
+
+
+
+### Personal Top Tracks
+
+
+
+### Personal Top Artists
+
+
+
+
## Trakt.tv
### User Collection
diff --git a/docs/install/README.md b/docs/install/README.md
index 7ccdfc612..6a251fa3b 100644
--- a/docs/install/README.md
+++ b/docs/install/README.md
@@ -548,6 +548,16 @@ RSSHub 支持使用访问密钥 / 码,白名单和黑名单三种方式进行
3. 点击 dynamic_new 请求,找到 Cookie
4. 视频和专栏只要求 `SESSDATA` 字段,动态需复制整段 Cookie
+- spotify 全部路由: [注册地址](https://developer.spotify.com)
+
+ - `SPOTIFY_CLIENT_ID`:Spotify 应用的 client ID
+
+ - `SPOTIFY_CLIENT_SECRET`:Spotify 应用的 client secret
+
+- spotify 用户相关路由
+
+ - `SPOTIFY_REFRESHTOKEN`:用户在此 Spotify 应用的 refresh token。可以利用 [此 gist](https://gist.github.com/outloudvi/d1bbeb5e989db5385384a223a7263744) 获取。
+
- 语雀 全部路由:[注册地址](https://www.yuque.com/register)
- `YUQUE_TOKEN`: 语雀 Token,[获取地址](https://www.yuque.com/settings/tokens)。语雀接口做了访问频率限制,为保证正常访问建议配置 Token,详见 [语雀开发者文档](https://www.yuque.com/yuque/developer/api#5b3a1535)。
diff --git a/docs/multimedia.md b/docs/multimedia.md
index f7c6f239b..c3a83b6ab 100644
--- a/docs/multimedia.md
+++ b/docs/multimedia.md
@@ -826,6 +826,28 @@ JavDB 有多个备用域名,本路由以 为默认域名
+## Spotify
+
+### 艺术家专辑
+
+
+
+### 播放列表
+
+
+
+### 个人 Saved Tracks
+
+
+
+### 个人 Top Tracks
+
+
+
+### 个人 Top Artists
+
+
+
## Sub HD
### 字幕
diff --git a/lib/config.js b/lib/config.js
index fce6c29db..583bb9e8f 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -63,6 +63,11 @@ const calculateValue = () => {
disqus: {
api_key: envs.DISQUS_API_KEY,
},
+ spotify: {
+ clientId: envs.SPOTIFY_CLIENT_ID,
+ clientSecret: envs.SPOTIFY_CLIENT_SECRET,
+ refreshToken: envs.SPOTIFY_REFRESHTOKEN,
+ },
twitter: {
consumer_key: envs.TWITTER_CONSUMER_KEY,
consumer_secret: envs.TWITTER_CONSUMER_SECRET,
diff --git a/lib/v2/spotify/artist.js b/lib/v2/spotify/artist.js
new file mode 100644
index 000000000..5fa03c037
--- /dev/null
+++ b/lib/v2/spotify/artist.js
@@ -0,0 +1,41 @@
+const utils = require('./utils');
+const got = require('@/utils/got');
+const { parseDate } = require('@/utils/parse-date');
+
+module.exports = async (ctx) => {
+ const token = await utils.getPublicToken();
+ const { id } = ctx.params;
+ const meta = await got
+ .get(`https://api.spotify.com/v1/artists/${id}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ })
+ .json();
+
+ const itemsResponse = await got
+ .get(`https://api.spotify.com/v1/artists/${id}/albums`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ })
+ .json();
+ const albums = itemsResponse.items;
+
+ ctx.state.data = {
+ title: `Albums of ${meta.name}`,
+ link: meta.external_urls.spotify,
+ allowEmpty: true,
+ item: albums.map((x) => ({
+ title: x.name,
+ author: x.artists.map((a) => a.name).join(', '),
+ description: `"${x.name}" by ${x.artists.map((a) => a.name).join(', ')}, released at ${x.release_date} with ${x.total_tracks} tracks.`,
+ pubDate: parseDate(x.release_date),
+ link: x.external_urls.spotify,
+ })),
+ };
+
+ if (meta.images.length) {
+ ctx.state.data.image = meta.images[0].url;
+ }
+};
diff --git a/lib/v2/spotify/maintainer.js b/lib/v2/spotify/maintainer.js
new file mode 100644
index 000000000..c83b3aad2
--- /dev/null
+++ b/lib/v2/spotify/maintainer.js
@@ -0,0 +1,7 @@
+module.exports = {
+ '/artist/:id': ['outloudvi'],
+ '/playlist/:id': ['outloudvi'],
+ '/saved/:limit?': ['outloudvi'],
+ '/top/tracks': ['outloudvi'],
+ '/top/artists': ['outloudvi'],
+};
diff --git a/lib/v2/spotify/playlist.js b/lib/v2/spotify/playlist.js
new file mode 100644
index 000000000..cfd806487
--- /dev/null
+++ b/lib/v2/spotify/playlist.js
@@ -0,0 +1,31 @@
+const utils = require('./utils');
+const got = require('@/utils/got');
+const { parseDate } = require('@/utils/parse-date');
+
+module.exports = async (ctx) => {
+ const token = await utils.getPublicToken();
+ const { id } = ctx.params;
+ const meta = await got
+ .get(`https://api.spotify.com/v1/playlists/${id}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ })
+ .json();
+ const tracks = meta.tracks.items;
+
+ ctx.state.data = {
+ title: meta.name,
+ link: meta.external_urls.spotify,
+ description: meta.description,
+ allowEmpty: true,
+ item: tracks.map((x) => ({
+ ...utils.parseTrack(x.track),
+ pubDate: parseDate(x.added_at),
+ })),
+ };
+
+ if (meta.images.length) {
+ ctx.state.data.image = meta.images[0].url;
+ }
+};
diff --git a/lib/v2/spotify/radar.js b/lib/v2/spotify/radar.js
new file mode 100644
index 000000000..14b893273
--- /dev/null
+++ b/lib/v2/spotify/radar.js
@@ -0,0 +1,37 @@
+module.exports = {
+ 'spotify.com': {
+ _name: 'Spotify',
+ open: [
+ {
+ title: '播放列表',
+ docs: 'https://docs.rsshub.app/multimedia.html#spotify',
+ source: ['/playlist/:id'],
+ target: '/spotify/playlist/:id',
+ },
+ {
+ title: '歌手专辑',
+ docs: 'https://docs.rsshub.app/multimedia.html#spotify',
+ source: ['/artist/:id'],
+ target: '/spotify/artist/:id',
+ },
+ {
+ title: '用户 Saved Tracks',
+ docs: 'https://docs.rsshub.app/multimedia.html#spotify',
+ source: ['/collection/tracks'],
+ target: '/spotify/saved',
+ },
+ {
+ title: '用户 Top Tracks',
+ docs: 'https://docs.rsshub.app/multimedia.html#spotify',
+ source: ['/'],
+ target: '/spotify/top/tracks',
+ },
+ {
+ title: '用户 Top Artists',
+ docs: 'https://docs.rsshub.app/multimedia.html#spotify',
+ source: ['/'],
+ target: '/spotify/top/artists',
+ },
+ ],
+ },
+};
diff --git a/lib/v2/spotify/router.js b/lib/v2/spotify/router.js
new file mode 100644
index 000000000..396994e6f
--- /dev/null
+++ b/lib/v2/spotify/router.js
@@ -0,0 +1,7 @@
+module.exports = function (router) {
+ router.get('/artist/:id', require('./artist'));
+ router.get('/playlist/:id', require('./playlist'));
+ router.get('/saved/:limit?', require('./saved'));
+ router.get('/top/tracks', require('./top')('tracks'));
+ router.get('/top/artists', require('./top')('artists'));
+};
diff --git a/lib/v2/spotify/saved.js b/lib/v2/spotify/saved.js
new file mode 100644
index 000000000..551f7d3ce
--- /dev/null
+++ b/lib/v2/spotify/saved.js
@@ -0,0 +1,30 @@
+const utils = require('./utils');
+const got = require('@/utils/got');
+const { parseDate } = require('@/utils/parse-date');
+
+module.exports = async (ctx) => {
+ const token = await utils.getPrivateToken();
+
+ const { limit } = ctx.params;
+ const pageSize = !isNaN(parseInt(limit)) ? parseInt(limit) : 50;
+
+ const itemsResponse = await got
+ .get(`https://api.spotify.com/v1/me/tracks?limit=${pageSize}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ })
+ .json();
+ const tracks = itemsResponse.items;
+
+ ctx.state.data = {
+ title: 'Spotify: My Saved Tracks',
+ link: 'https://open.spotify.com/collection/tracks',
+ description: `Latest ${pageSize} saved tracks on Spotify.`,
+ allowEmpty: true,
+ item: tracks.map((x) => ({
+ ...utils.parseTrack(x.track),
+ pubDate: parseDate(x.added_at),
+ })),
+ };
+};
diff --git a/lib/v2/spotify/top.js b/lib/v2/spotify/top.js
new file mode 100644
index 000000000..05361b1f4
--- /dev/null
+++ b/lib/v2/spotify/top.js
@@ -0,0 +1,24 @@
+const utils = require('./utils');
+const got = require('@/utils/got');
+
+module.exports = (type) => async (ctx) => {
+ if (type !== 'tracks' && type !== 'artists') {
+ throw `Invalid type: ${type}`;
+ }
+
+ const token = await utils.getPrivateToken();
+ const itemsResponse = await got
+ .get(`https://api.spotify.com/v1/me/top/${type}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ })
+ .json();
+ const items = itemsResponse.items;
+
+ ctx.state.data = {
+ title: `Spotify: My Top ${type[0].toUpperCase() + type.slice(1)}`,
+ allowEmpty: true,
+ item: type === 'tracks' ? items.map(utils.parseTrack) : items.map(utils.parseArtist),
+ };
+};
diff --git a/lib/v2/spotify/utils.js b/lib/v2/spotify/utils.js
new file mode 100644
index 000000000..70f7184aa
--- /dev/null
+++ b/lib/v2/spotify/utils.js
@@ -0,0 +1,62 @@
+const config = require('@/config').value;
+const got = require('@/utils/got');
+
+// Token used to retrieve public information.
+async function getPublicToken() {
+ if (!config.spotify || !config.spotify.clientId || !config.spotify.clientSecret) {
+ throw 'Spotify public RSS is disabled due to the lack of relevant config';
+ }
+
+ const { clientId, clientSecret } = config.spotify;
+
+ const tokenResponse = await got
+ .post('https://accounts.spotify.com/api/token', {
+ headers: {
+ Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
+ },
+ form: {
+ grant_type: 'client_credentials',
+ },
+ })
+ .json();
+ return tokenResponse.access_token;
+}
+
+// Token used to retrieve user-specific information.
+// Note that we don't use PKCE since the client secret shall be safe on the server.
+async function getPrivateToken() {
+ if (!config.spotify || !config.spotify.clientId || !config.spotify.clientSecret || !config.spotify.refreshToken) {
+ throw 'Spotify private RSS is disabled due to the lack of relevant config';
+ }
+
+ const { clientId, clientSecret, refreshToken } = config.spotify;
+
+ const tokenResponse = await got
+ .post('https://accounts.spotify.com/api/token', {
+ headers: {
+ Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
+ },
+ form: {
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken,
+ },
+ })
+ .json();
+ return tokenResponse.access_token;
+}
+
+const parseTrack = (x) => ({
+ title: x.name,
+ author: x.artists.map((a) => a.name).join(', '),
+ description: `"${x.name}" by ${x.artists.map((a) => a.name).join(', ')} from the album "${x.album.name}"`,
+ link: x.external_urls.spotify,
+});
+
+const parseArtist = (x) => ({ title: x.name, description: `${x.name}, with ${x.followers.total} followers`, link: x.external_urls.spotify });
+
+module.exports = {
+ getPublicToken,
+ getPrivateToken,
+ parseTrack,
+ parseArtist,
+};