diff --git a/source/app/mocks/api/axios/get/lastfm.mjs b/source/app/mocks/api/axios/get/lastfm.mjs index 71ddcee8..d4cb6f0a 100644 --- a/source/app/mocks/api/axios/get/lastfm.mjs +++ b/source/app/mocks/api/axios/get/lastfm.mjs @@ -62,5 +62,99 @@ export default function({faker, url, options, login = faker.internet.userName()} }, }) } + else if (/user.gettoptracks/.test(url)) { + console.debug(`metrics/compute/mocks > mocking lastfm api result > ${url}`) + const artist = faker.random.word() + const track = faker.random.words(5) + return ({ + status:200, + data:{ + toptracks:{ + "@attr":{ + page:"1", + perPage:"1", + user:"RJ", + total:"100", + pages:"100", + }, + track:[ + { + artist:{ + mbid:"", + name:artist, + }, + image:[ + { + size:"small", + "#text":faker.image.abstract(), + }, + { + size:"medium", + "#text":faker.image.abstract(), + }, + { + size:"large", + "#text":faker.image.abstract(), + }, + { + size:"extralarge", + "#text":faker.image.abstract(), + }, + ], + url:faker.internet.url(), + name:track, + mbid:"", + }, + ], + }, + }, + }) + } + else if (/user.gettopartists/.test(url)) { + console.debug(`metrics/compute/mocks > mocking lastfm api result > ${url}`) + const artist = faker.random.word() + const playcount = faker.random.number() + return ({ + status:200, + data:{ + topartists:{ + "@attr":{ + page:"1", + perPage:"1", + user:"RJ", + total:"100", + pages:"100", + }, + artist:[ + { + image:[ + { + size:"small", + "#text":faker.image.abstract(), + }, + { + size:"medium", + "#text":faker.image.abstract(), + }, + { + size:"large", + "#text":faker.image.abstract(), + }, + { + size:"extralarge", + "#text":faker.image.abstract(), + }, + ], + streamable:"0", + playcount, + url:faker.internet.url(), + name:artist, + mbid:"", + }, + ], + }, + }, + }) + } } } diff --git a/source/app/mocks/api/axios/get/spotify.mjs b/source/app/mocks/api/axios/get/spotify.mjs index 1ef898fb..ace55daf 100644 --- a/source/app/mocks/api/axios/get/spotify.mjs +++ b/source/app/mocks/api/axios/get/spotify.mjs @@ -61,5 +61,91 @@ export default function({faker, url, options, login = faker.internet.userName()} }, }) } + else if (/me.top.tracks/.test(url) && (options?.headers?.Authorization === "Bearer MOCKED_TOKEN_ACCESS")) { + console.debug(`metrics/compute/mocks > mocking spotify api result > ${url}`) + const artist = faker.random.words() + const track = faker.random.words(5) + return ({ + status:200, + data:{ + items:[ + { + album:{ + album_type:"single", + artists:[ + { + name:artist, + type:"artist", + }, + ], + images:[ + { + height:640, + url:faker.image.abstract(), + width:640, + }, + { + height:300, + url:faker.image.abstract(), + width:300, + }, + { + height:64, + url:faker.image.abstract(), + width:64, + }, + ], + name:track, + release_date:`${faker.date.past()}`.substring(0, 10), + type:"album", + }, + artists:[ + { + name:artist, + type:"artist", + }, + ], + name:track, + preview_url:faker.internet.url(), + type:"track", + }, + ], + }, + }) + } + else if (/me.top.artists/.test(url) && (options?.headers?.Authorization === "Bearer MOCKED_TOKEN_ACCESS")) { + console.debug(`metrics/compute/mocks > mocking spotify api result > ${url}`) + const genre = faker.random.words() + const track = faker.random.words(5) + return ({ + status:200, + data:{ + items:[ + { + genres: [genre], + images:[ + { + height:640, + url:faker.image.abstract(), + width:640, + }, + { + height:300, + url:faker.image.abstract(), + width:300, + }, + { + height:64, + url:faker.image.abstract(), + width:64, + }, + ], + name:track, + type:"artist", + }, + ], + }, + }) + } } } diff --git a/source/plugins/music/README.md b/source/plugins/music/README.md index 83df4e56..b6a5c95d 100644 --- a/source/plugins/music/README.md +++ b/source/plugins/music/README.md @@ -81,9 +81,11 @@ This mode is not supported for now. # (plugin_music_provider and plugin_music_mode will be set automatically) ``` -### Recently played mode +### Recently played & top modes -Display tracks you have played recently. +Recently played: Display tracks you have played recently. + +Top: Display your top artists/tracks for a certain time period. Select a music provider below for additional instructions. @@ -113,7 +115,7 @@ Open the settings and add a new *Redirect url*. Normally it is used to setup cal Forge the authorization url with your `client_id` and the encoded `redirect_uri` you whitelisted, and access it from your browser: ``` -https://accounts.spotify.com/authorize?client_id=********&response_type=code&scope=user-read-recently-played&redirect_uri=https%3A%2F%2Flocalhost +https://accounts.spotify.com/authorize?client_id=********&response_type=code&scope=user-read-recently-played%20user-top-read&redirect_uri=https%3A%2F%2Flocalhost ``` When prompted, authorize your application. @@ -147,7 +149,7 @@ It should return a JSON response with the following content: { "access_token":"********", "expires_in": 3600, - "scope":"user-read-recently-played", + "scope":"user-read-recently-played user-top-read", "token_type":"Bearer", "refresh_token":"********" } @@ -172,6 +174,8 @@ Register your API key to finish setup. [➡️ Available options for this plugin](metadata.yml) +##### Recent + ```yaml - uses: lowlighter/metrics@latest with: @@ -196,3 +200,33 @@ Register your API key to finish setup. plugin_music_token: ${{ secrets.LASTFM_API_KEY }} ``` + +##### Top + +```yaml +- uses: lowlighter/metrics@latest + with: + # ... other options + plugin_music: yes + plugin_music_provider: spotify # Use Spotify as provider + plugin_music_mode: top # Set plugin mode + plugin_music_top_type: tracks # Set type for "top" mode; either tracks or artists + plugin_music_limit: 4 # Limit to 4 entries, maximum is 50 for "top" mode with spotify + plugin_music_time_range: short # Set time range for "top" mode; either short (4 weeks), medium (6 months) or long (several years) + plugin_music_token: "${{ secrets.SPOTIFY_CLIENT_ID }}, ${{ secrets.SPOTIFY_CLIENT_SECRET }}, ${{ secrets.SPOTIFY_REFRESH_TOKEN }}" +``` + +```yaml +- uses: lowlighter/metrics@latest + with: + # ... other options + plugin_music: yes + plugin_music_provider: lastfm # Use Last.fm as provider + plugin_music_mode: top # Set plugin mode + plugin_music_top_type: artists # Set type for "top" mode; either tracks or artists + plugin_music_limit: 4 # Limit to 4 entries + plugin_music_time_range: long # Set time range for "top" mode; either short (4 weeks), medium (6 months) or long (several years) + plugin_music_user: .user.login # Use same username as GitHub login + plugin_music_token: ${{ secrets.LASTFM_API_KEY }} + +``` diff --git a/source/plugins/music/index.mjs b/source/plugins/music/index.mjs index cc051ed4..059e7fe3 100644 --- a/source/plugins/music/index.mjs +++ b/source/plugins/music/index.mjs @@ -17,6 +17,7 @@ const providers = { const modes = { playlist:"Suggested tracks", recent:"Recently played", + top:"Top played", } //Setup @@ -39,18 +40,23 @@ export default async function({login, imports, data, q, account}, {enabled = fal let tracks = null //Load inputs - let {provider, mode, playlist, limit, user, "played.at":played_at} = imports.metadata.plugins.music.inputs({data, account, q}) + let {provider, mode, playlist, limit, user, "played.at":played_at, "time.range":time_range, "top.type":top_type} = imports.metadata.plugins.music.inputs({data, account, q}) //Auto-guess parameters - if ((playlist) && (!mode)) - mode = "playlist" - if ((playlist) && (!provider)) { - for (const [name, {embed}] of Object.entries(providers)) { - if (embed.test(playlist)) - provider = name + if (!mode) { + if (playlist) { + mode = "playlist" + if (!provider) { + for (const [name, {embed}] of Object.entries(providers)) { + if (embed.test(playlist)) + provider = name + } + } } + else if ("music.top.type" in q || "music.time.range" in q) + mode = "top" + else + mode = "recent" } - if (!mode) - mode = "recent" //Provider if (!(provider in providers)) throw {error:{message:provider ? `Unsupported provider "${provider}"` : "Missing provider"}, ...raw} @@ -224,6 +230,172 @@ export default async function({login, imports, data, q, account}, {enabled = fal } break } + case "top": { + let time_msg + switch (time_range) { + case "short": + time_msg = "from the last month" + break + case "medium": + time_msg = "from the last 6 months" + break + case "long": + time_msg = "overall" + break + default: + throw {error:{message:`Unsupported time range "${time_range}"`}, ...raw} + } + + if (top_type === "artists") { + Object.defineProperty(modes, "top", { + get() { + return `Top played artists ${time_msg}` + } + }) + } + else { + Object.defineProperty(modes, "top", { + get() { + return `Top played tracks ${time_msg}` + } + }) + } + + //Handle provider + switch (provider) { + //Spotify + case "spotify": { + //Prepare credentials + const [client_id, client_secret, refresh_token] = token.split(",").map(part => part.trim()) + if ((!client_id) || (!client_secret) || (!refresh_token)) + throw { error: { message: "Spotify token must contain client id/secret and refresh token" } } + else if (limit > 50) + throw {error:{message:"Spotify top limit cannot be greater than 50"}} + + //API call and parse tracklist + try { + //Request access token + console.debug(`metrics/compute/${login}/plugins > music > requesting access token with spotify refresh token`) + const {data:{access_token:access}} = await imports.axios.post("https://accounts.spotify.com/api/token", `${new imports.url.URLSearchParams({grant_type:"refresh_token", refresh_token, client_id, client_secret})}`, { + headers:{ + "Content-Type":"application/x-www-form-urlencoded", + }, + }) + console.debug(`metrics/compute/${login}/plugins > music > got access token`) + //Retrieve tracks + console.debug(`metrics/compute/${login}/plugins > music > querying spotify api`) + tracks = [] + const loaded = + top_type === "artists" + ? ( + await imports.axios.get( + `https://api.spotify.com/v1/me/top/artists?time_range=${time_range}_term&limit=${limit}`, + { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${access}`, + }, + } + ) + ).data.items.map(({ name, genres, images }) => ({ + name, + artist: genres.join(" • "), + artwork: images[0].url, + })) + : ( + await imports.axios.get( + `https://api.spotify.com/v1/me/top/tracks?time_range=${time_range}_term&limit=${limit}`, + { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${access}`, + }, + } + ) + ).data.items.map(({ name, artists, album }) => ({ + name, + artist: artists[0].name, + artwork: album.images[0].url, + })) + //Ensure no duplicate are added + for (const track of loaded) { + if (!tracks.map(({name}) => name).includes(track.name)) + tracks.push(track) + } + } + //Handle errors + catch (error) { + if (error.isAxiosError) { + const status = error.response?.status + const description = error.response.data?.error_description ?? null + const message = `API returned ${status}${description ? ` (${description})` : ""}` + error = error.response?.data ?? null + throw {error:{message, instance:error}, ...raw} + } + throw error + } + break + } + //Last.fm + case "lastfm": { + //API call and parse tracklist + try { + console.debug(`metrics/compute/${login}/plugins > music > querying lastfm api`) + const period = time_range === "short" ? "1month" : time_range === "medium" ? "6month" : "overall" + tracks = + top_type === "artists" + ? ( + await imports.axios.get( + `https://ws.audioscrobbler.com/2.0/?method=user.gettopartists&user=${user}&api_key=${token}&limit=${limit}&period=${period}&format=json`, + { + headers: { + "User-Agent": "lowlighter/metrics", + Accept: "application/json", + }, + } + ) + ).data.topartists.artist.map(artist => ({ + name: artist.name, + artist: `Play count: ${artist.playcount}`, + artwork: artist.image.reverse()[0]["#text"], + })) + : ( + await imports.axios.get( + `https://ws.audioscrobbler.com/2.0/?method=user.gettoptracks&user=${user}&api_key=${token}&limit=${limit}&period=${period}&format=json`, + { + headers: { + "User-Agent": "lowlighter/metrics", + Accept: "application/json", + }, + } + ) + ).data.toptracks.track.map(track => ({ + name: track.name, + artist: track.artist.name, + artwork: track.image.reverse()[0]["#text"], + })) + } + //Handle errors + catch (error) { + if (error.isAxiosError) { + const status = error.response?.status + const description = error.response.data?.message ?? null + const message = `API returned ${status}${description ? ` (${description})` : ""}` + error = error.response?.data ?? null + throw {error:{message, instance:error}, ...raw} + } + throw error + } + break + } + //Unsupported + default: + throw {error:{message:`Unsupported mode "${mode}" for provider "${provider}"`}, ...raw} + } + break + } //Unsupported default: throw {error:{message:`Unsupported mode "${mode}"`}, ...raw} diff --git a/source/plugins/music/metadata.yml b/source/plugins/music/metadata.yml index 796f31c9..2f38fcb3 100644 --- a/source/plugins/music/metadata.yml +++ b/source/plugins/music/metadata.yml @@ -28,7 +28,7 @@ inputs: # Music provider token # This may be required depending on music provider used and plugin mode # - "apple" : not required - # - "spotify" : required for "recent" mode, format is "client_id, client_secret, refresh_token" + # - "spotify" : required for "recent" or "top" mode, format is "client_id, client_secret, refresh_token" # - "lastfm" : required, format is "api_key" plugin_music_token: description: Music provider personal token @@ -43,6 +43,7 @@ inputs: values: - playlist # Display tracks from an embed playlist randomly - recent # Display recently listened tracks + - top # Display top listened artists/tracks # Embed playlist url (i.e. url used by music player iframes) plugin_music_playlist: @@ -65,6 +66,25 @@ inputs: type: boolean default: no + # Time range for "top" mode + plugin_music_time_range: + description: Time period for top mode + type: string + default: short # Defaults to "short" (4 weeks) + values: + - short # Top artists/tracks from past 4 weeks + - medium # Top artists/tracks from past 6 months + - long # Top artists/tracks from several years + + # Option for "top" mode to select tracks or artists + plugin_music_top_type: + description: Whether to show tracks or artists in top mode + type: string + default: tracks + values: + - tracks + - artists + # Username on music provider service plugin_music_user: description: Music provider username diff --git a/source/plugins/music/tests.yml b/source/plugins/music/tests.yml index 4d7411b0..80dc68b2 100644 --- a/source/plugins/music/tests.yml +++ b/source/plugins/music/tests.yml @@ -29,3 +29,25 @@ plugin_music: yes plugin_music_provider: lastfm plugin_music_user: RJ + +- name: Music plugin (top - spotify - tracks) + uses: lowlighter/metrics@latest + with: + token: NOT_NEEDED + plugin_music_token: MOCKED_CLIENT_ID, MOCKED_CLIENT_SECRET, MOCKED_REFRESH_TOKEN + plugin_music: yes + plugin_music_mode: top + plugin_music_provider: spotify + plugin_music_time_range: short + plugin_music_top_type: tracks + +- name: Music plugin (top - spotify - artists) + uses: lowlighter/metrics@latest + with: + token: NOT_NEEDED + plugin_music_token: MOCKED_CLIENT_ID, MOCKED_CLIENT_SECRET, MOCKED_REFRESH_TOKEN + plugin_music: yes + plugin_music_mode: top + plugin_music_provider: spotify + plugin_music_time_range: long + plugin_music_top_type: artists diff --git a/source/templates/classic/partials/music.ejs b/source/templates/classic/partials/music.ejs index e0f58a03..cc0d6f07 100644 --- a/source/templates/classic/partials/music.ejs +++ b/source/templates/classic/partials/music.ejs @@ -22,11 +22,11 @@
<% for (const {name = "", artist = "", artwork = "", played_at = ""} of plugins.music.tracks) { %>
- +
<%= name %>
<%= artist %>
- <% if (plugins.music.played_at) { %> + <% if (plugins.music.played_at && played_at) { %>
Played at <%= played_at %>
<% } %>