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
This commit is contained in:
Outvi V
2022-02-04 18:46:53 +08:00
committed by GitHub
parent 8f33790045
commit 36b4cc5baa
14 changed files with 319 additions and 0 deletions
+9
View File
@@ -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
},
+10
View File
@@ -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
+24
View File
@@ -150,6 +150,30 @@ Refer to [Pornhub F.A.Qs](https://help.pornhub.com/hc/en-us/articles/36004432703
<RouteEn author="fallenhh" example="/soundcloud/tracks/angeart" path="/soundcloud/tracks/:user" :paramsDesc="['User name']" />
## Spotify
### Artist Albums
<RouteEn author="outloudvi" example="/spotify/artist/6k9TBCxyr4bXwZ8Y21Kwn1" path="/spotify/artist/:id" :paramsDesc="['Artist ID']" />
### Playlist
<RouteEn author="outloudvi" example="/spotify/playlist/4UBVy1LttvodwivPUuwJk2" path="/spotify/playlist/:id" :paramsDesc="['Playlist ID']" />
### Personal Saved Tracks
<RouteEn author="outloudvi" example="/spotify/saved/50" path="/spotify/saved/:limit?" :paramsDesc="['Track count, 50 by default']" />
### Personal Top Tracks
<RouteEn author="outloudvi" example="/spotify/top/tracks" path="/spotify/top/tracks" />
### Personal Top Artists
<RouteEn author="outloudvi" example="/spotify/top/artists" path="/spotify/top/artists" />
## Trakt.tv
### User Collection
+10
View File
@@ -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)。
+22
View File
@@ -826,6 +826,28 @@ JavDB 有多个备用域名,本路由以 <https://javdb7.com> 为默认域名
<Route author="fallenhh" example="/soundcloud/tracks/angeart" path="/soundcloud/tracks/:user" :paramsDesc="['用户名']" />
## Spotify
### 艺术家专辑
<Route author="outloudvi" example="/spotify/artist/6k9TBCxyr4bXwZ8Y21Kwn1" path="/spotify/artist/:id" :paramsDesc="['艺术家 ID']" />
### 播放列表
<Route author="outloudvi" example="/spotify/playlist/4UBVy1LttvodwivPUuwJk2" path="/spotify/playlist/:id" :paramsDesc="['播放列表 ID']" />
### 个人 Saved Tracks
<Route author="outloudvi" example="/spotify/saved/50" path="/spotify/saved/:limit?" :paramsDesc="['歌曲数量,默认为 50']" />
### 个人 Top Tracks
<Route author="outloudvi" example="/spotify/top/tracks" path="/spotify/top/tracks" />
### 个人 Top Artists
<Route author="outloudvi" example="/spotify/top/artists" path="/spotify/top/artists" />
## Sub HD
### 字幕
+5
View File
@@ -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,
+41
View File
@@ -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;
}
};
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
'/artist/:id': ['outloudvi'],
'/playlist/:id': ['outloudvi'],
'/saved/:limit?': ['outloudvi'],
'/top/tracks': ['outloudvi'],
'/top/artists': ['outloudvi'],
};
+31
View File
@@ -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;
}
};
+37
View File
@@ -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',
},
],
},
};
+7
View File
@@ -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'));
};
+30
View File
@@ -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),
})),
};
};
+24
View File
@@ -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),
};
};
+62
View File
@@ -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 <a href="https://docs.rsshub.app/install/#pei-zhi-bu-fen-rss-mo-kuai-pei-zhi">relevant config</a>';
}
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 <a href="https://docs.rsshub.app/install/#pei-zhi-bu-fen-rss-mo-kuai-pei-zhi">relevant config</a>';
}
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,
};