//Imports import crypto from "crypto" //Supported providers const providers = { apple:{ name:"Apple Music", embed:/^https:..embed.music.apple.com.\w+.playlist/, }, spotify:{ name:"Spotify", embed:/^https:..open.spotify.com.embed.playlist/, }, lastfm:{ name:"Last.fm", embed:/^\b$/, }, youtube:{ name:"YouTube Music", embed:/^https:..music.youtube.com.playlist/, }, } //Supported modes const modes = { playlist:"Suggested tracks", recent:"Recently played", top:"Top played", } //Setup export default async function({login, imports, data, q, account}, {enabled = false, token = ""} = {}) { //Plugin execution try { //Check if plugin is enabled and requirements are met if ((!enabled) || (!q.music)) return null //Initialization const raw = { get provider() { return providers[provider]?.name ?? "" }, get mode() { return modes[mode] ?? "Unconfigured music plugin" }, } let tracks = null //Load inputs 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 (!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" } //Provider if (!(provider in providers)) throw {error:{message:provider ? `Unsupported provider "${provider}"` : "Missing provider"}, ...raw} //Mode if (!(mode in modes)) throw {error:{message:`Unsupported mode "${mode}"`}, ...raw} //Playlist mode if (mode === "playlist") { if (!playlist) throw {error:{message:"Missing playlist url"}, ...raw} if (!providers[provider].embed.test(playlist)) throw {error:{message:"Unsupported playlist url format"}, ...raw} } //Limit limit = Math.max(1, Math.min(100, Number(limit))) //Handle mode console.debug(`metrics/compute/${login}/plugins > music > processing mode ${mode} with provider ${provider}`) switch (mode) { //Playlist mode case "playlist": { //Start puppeteer and navigate to playlist console.debug(`metrics/compute/${login}/plugins > music > starting browser`) const browser = await imports.puppeteer.launch() console.debug(`metrics/compute/${login}/plugins > music > started ${await browser.version()}`) const page = await browser.newPage() console.debug(`metrics/compute/${login}/plugins > music > loading page`) await page.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36 Edg/96.0.1054.34") await page.goto(playlist) const frame = page.mainFrame() //Handle provider switch (provider) { //Apple music case "apple": { //Parse tracklist await frame.waitForSelector(".tracklist.playlist") tracks = [ ...await frame.evaluate(() => [...document.querySelectorAll(".tracklist li")].map(li => ({ name:li.querySelector(".tracklist__track__name").innerText, artist:li.querySelector(".tracklist__track__sub").innerText, artwork:li.querySelector(".tracklist__track__artwork img").src, })) ), ] break } //Spotify case "spotify": { //Parse tracklist await frame.waitForSelector("table") tracks = [ ...await frame.evaluate(() => [...document.querySelectorAll("table tr")].map(tr => ({ name:tr.querySelector("td:nth-child(2) div div:nth-child(1)").innerText, artist:tr.querySelector("td:nth-child(2) div div:nth-child(2)").innerText, //Spotify doesn't provide artworks so we fallback on playlist artwork instead artwork:window.getComputedStyle(document.querySelector("button[title=Play]").parentNode, null).backgroundImage.match(/^url\("(?https:...+)"\)$/)?.groups?.url ?? null, })) ), ] break } //YouTube Music case "youtube": { while (await frame.evaluate(() => document.querySelector("yt-next-continuation")?.children.length ?? 0)) await frame.evaluate(() => window.scrollBy(0, window.innerHeight)) //Parse tracklist tracks = [ ...await frame.evaluate(() => [...document.querySelectorAll("ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer")].map(item => ({ name:item.querySelector("yt-formatted-string.title > a")?.innerText ?? "", artist:item.querySelector(".secondary-flex-columns > yt-formatted-string > a")?.innerText ?? "", artwork:item.querySelector("img").src, })) ), ] break } //Unsupported default: throw {error:{message:`Unsupported mode "${mode}" for provider "${provider}"`}, ...raw} } //Close browser console.debug(`metrics/compute/${login}/plugins > music > closing browser`) await browser.close() //Format tracks if (Array.isArray(tracks)) { //Tracks console.debug(`metrics/compute/${login}/plugins > music > found ${tracks.length} tracks`) console.debug(imports.util.inspect(tracks, {depth:Infinity, maxStringLength:256})) //Shuffle tracks tracks = imports.shuffle(tracks) } break } //Recently played case "recent": { //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"}} //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 = [] for (let hours = .5; hours <= 24; hours++) { //Load track half-hour by half-hour const timestamp = Date.now() - hours * 60 * 60 * 1000 const loaded = (await imports.axios.get(`https://api.spotify.com/v1/me/player/recently-played?after=${timestamp}`, { headers:{ "Content-Type":"application/json", Accept:"application/json", Authorization:`Bearer ${access}`, }, })).data.items.map(({track, played_at}) => ({ name:track.name, artist:track.artists[0].name, artwork:track.album.images[0].url, played_at:played_at ? `${imports.format.date(played_at, {time:true})} on ${imports.format.date(played_at, {date:true})}` : null, })) //Ensure no duplicate are added for (const track of loaded) { if (!tracks.map(({name}) => name).includes(track.name)) tracks.push(track) } //Early break if (tracks.length >= limit) break } } //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`) tracks = (await imports.axios.get(`https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${user}&api_key=${token}&limit=${limit}&format=json`, { headers:{ "User-Agent":"lowlighter/metrics", Accept:"application/json", }, })).data.recenttracks.track.map(track => ({ name:track.name, artist:track.artist["#text"], 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 } case "youtube": { //Prepare credentials let date = new Date().getTime() let [, cookie] = token.split("; ").find(part => part.startsWith("SAPISID=")).split("=") let sha1 = str => crypto.createHash("sha1").update(str).digest("hex") let SAPISIDHASH = `SAPISIDHASH ${date}_${sha1(`${date} ${cookie} https://music.youtube.com`)}` //API call and parse tracklist try { //Request access token console.debug(`metrics/compute/${login}/plugins > music > requesting access token with youtube refresh token`) const res = await imports.axios.post("https://music.youtube.com/youtubei/v1/browse?alt=json&key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30", { browseEndpointContextSupportedConfigs:{ browseEndpointContextMusicConfig:{ pageType:"MUSIC_PAGE_TYPE_PLAYLIST", }, }, context:{ client:{ clientName:"WEB_REMIX", clientVersion:"1.20211129.00.01", gl:"US", hl:"en", }, }, browseId:"FEmusic_history", }, { headers:{ Authorization:SAPISIDHASH, Cookie:token, "x-origin":"https://music.youtube.com", }, }) //Retrieve tracks console.debug(`metrics/compute/${login}/plugins > music > querying youtube api`) tracks = [] let parsedHistory = get_all_with_key(res.data, "musicResponsiveListItemRenderer") for (let i = 0; i < parsedHistory.length; i++) { let track = parsedHistory[i] tracks.push({ name:track.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text, artist:track.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text, artwork:track.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url, }) //Early break if (tracks.length >= limit) break } } //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 } //Unsupported default: throw {error:{message:`Unsupported mode "${mode}" for provider "${provider}"`}, ...raw} } 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} } //Format tracks if (Array.isArray(tracks)) { //Limit tracklist if (limit > 0) { console.debug(`metrics/compute/${login}/plugins > music > keeping only ${limit} tracks`) tracks.splice(limit) } //Convert artworks to base64 console.debug(`metrics/compute/${login}/plugins > music > loading artworks`) for (const track of tracks) { console.debug(`metrics/compute/${login}/plugins > music > processing ${track.name}`) track.artwork = await imports.imgb64(track.artwork) } //Save results return {...raw, user, tracks, played_at} } //Unhandled error throw {error:{message:"An error occured (could not retrieve tracks)"}} } //Handle errors catch (error) { if (error.error?.message) throw error throw {error:{message:"An error occured", instance:error}} } } //get all objects that have the given key name with recursivity function get_all_with_key(obj, key) { const result = [] if (obj instanceof Object) { if (key in obj) result.push(obj[key]) for (const i in obj) result.push(...get_all_with_key(obj[i], key)) } return result }