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, +};