Last active
March 16, 2023 15:46
-
-
Save andraaspar/ac57cafd9a0d772d6c15d29b0f7a965e to your computer and use it in GitHub Desktop.
Spotify ratings
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Spotify ratings | |
// @namespace 50edea63-8324-74b3-4019-81e0bdcd316a | |
// @version 0.2.0 | |
// @description Loads my ratings for an album. | |
// @author AP | |
// @match https://open.spotify.com/* | |
// @run-at document-idle | |
// ==/UserScript== | |
(() => { | |
/** @type {typeof document['createElement']} */ | |
const $ = document.createElement.bind(document); | |
const ratingFileNames = [ | |
"albums-0-of-3.json", | |
"albums-1-of-3.json", | |
"albums-2-of-3.json", | |
"albums-3-of-3.json", | |
]; | |
let ratingLists = []; | |
// let timeoutId; | |
const style = $("style"); | |
style.innerHTML = ` | |
.pir-rating { | |
color:#bf0000; | |
} | |
.pir-buttons { | |
display: flex !important; | |
flex-flow: row wrap; | |
padding: 1px; | |
gap: 1px; | |
} | |
.pir-buttons * { | |
font-size: 14px; | |
line-height: 1; | |
} | |
.pir-buttons > a, .pir-buttons > button { | |
display: flex; | |
flex-flow: row wrap; | |
align-items: center; | |
background: black; | |
color: white; | |
border: 1px solid gray; | |
padding: 0 1em; | |
cursor: pointer; | |
} | |
.pir-buttons input { | |
background: black; | |
color: white; | |
border: 1px solid gray; | |
border-radius: 3px; | |
} | |
`; | |
document.head.append(style); | |
const buttons = $("div"); | |
buttons.className = "pir-buttons"; | |
document.body.prepend(buttons); | |
const reloadRatingsButton = $("button"); | |
buttons.append(reloadRatingsButton); | |
reloadRatingsButton.innerText = `Reload Ratings`; | |
reloadRatingsButton.addEventListener("click", async (e) => { | |
try { | |
// clearTimeout(timeoutId); | |
for (const existingRating of document.querySelectorAll(`.pir-rating`)) { | |
existingRating.remove(); | |
} | |
for (const ratedItem of document.querySelectorAll( | |
`[data-pir-rating-for]` | |
)) { | |
delete ratedItem.dataset.pirRatingFor; | |
} | |
const res = await fetch( | |
`https://api.github.com/gists/9c9728852c2a187b8187b6753c12210e` | |
); | |
if (!res.ok) { | |
throw new Error(`Could not load ratings!`); | |
} | |
let info = await res.json(); | |
const responses = await Promise.all( | |
ratingFileNames.map((ratingFileName, i) => | |
fetch(info.files[ratingFileName].raw_url) | |
) | |
); | |
const lists = []; | |
for (const response of responses) { | |
if (response.ok) { | |
lists.push(await response.json()); | |
} else { | |
lists.push([]); | |
} | |
} | |
ratingLists = lists; | |
showRatings(); | |
} catch (e) { | |
window.alert(e); | |
} | |
}); | |
const updateRatingsButton = $("button"); | |
buttons.append(updateRatingsButton); | |
updateRatingsButton.innerText = `Update Ratings`; | |
updateRatingsButton.addEventListener("click", async (e) => { | |
try { | |
showRatings(); | |
} catch (e) { | |
window.alert(e); | |
} | |
}); | |
const copyAlbumJsonButton = $("button"); | |
buttons.append(copyAlbumJsonButton); | |
copyAlbumJsonButton.innerText = `Copy Album JSON`; | |
copyAlbumJsonButton.addEventListener("click", async (e) => { | |
const albumInfo = await getAlbumInfoFromAlbumPage(location.href); | |
navigator.clipboard.writeText( | |
`\n` + indent(JSON.stringify(albumInfo, undefined, "\t")) + `,` | |
); | |
}); | |
const copySongJsonButton = $("button"); | |
buttons.append(copySongJsonButton); | |
copySongJsonButton.innerText = `Copy Song JSON`; | |
copySongJsonButton.addEventListener("click", async (e) => { | |
try { | |
const albumHref = document.querySelector( | |
`a[data-testid="context-link"]` | |
).href; | |
if (typeof albumHref !== "string") { | |
throw new Error(`Album link not found!`); | |
} | |
const albumInfo = await getAlbumInfoFromAlbumPage(albumHref); | |
navigator.clipboard.writeText( | |
`\n` + | |
indent( | |
JSON.stringify( | |
{ | |
...albumInfo, | |
trackTitle: document.querySelector( | |
`a[data-testid="context-item-link"]` | |
).innerText, | |
}, | |
undefined, | |
"\t" | |
) | |
) + | |
`,` | |
); | |
} catch (e) { | |
window.alert(e); | |
} | |
}); | |
const dashboardLink = $("a"); | |
dashboardLink.href = `https://developer.spotify.com/dashboard/`; | |
dashboardLink.target = `_blank`; | |
buttons.append(dashboardLink); | |
dashboardLink.innerText = "Spotify Developer Dashboard"; | |
const clientIdInputLabel = $("label"); | |
buttons.append(clientIdInputLabel); | |
clientIdInputLabel.innerText = " Client ID: "; | |
const clientIdInput = $("input"); | |
clientIdInputLabel.append(clientIdInput); | |
clientIdInput.size = 32; | |
clientIdInput.value = localStorage["rrm9yz"] ?? ""; | |
clientIdInput.addEventListener("input", (e) => { | |
localStorage["rrm9yz"] = e.currentTarget.value; | |
}); | |
const clientSecretInputLabel = $("label"); | |
buttons.append(clientSecretInputLabel); | |
clientSecretInputLabel.innerText = " Client Secret: "; | |
const clientSecretInput = $("input"); | |
clientSecretInputLabel.append(clientSecretInput); | |
clientSecretInput.size = 32; | |
clientSecretInput.type = "password"; | |
clientSecretInput.value = localStorage["rrma0u"] ?? ""; | |
clientSecretInput.addEventListener("input", (e) => { | |
localStorage["rrma0u"] = e.currentTarget.value; | |
}); | |
async function showRatings() { | |
if (!ratingLists.length) return; | |
/** @type {NodeListOf<HTMLElement>} **/ | |
const albumTitles = document.querySelectorAll( | |
`a[href^="/album/"]:not([data-pir-rating-for])` | |
); | |
let token = ""; | |
try { | |
token = await oauth(); | |
} catch (e) { | |
console.error(e); | |
} | |
for (const albumTitle of albumTitles) { | |
if (!document.contains(albumTitle)) break; | |
const albumHref = albumTitle.href; | |
const albumInfo = await (token | |
? getAlbumInfoFromApi(token, albumHref) | |
: getAlbumInfoFromAlbumPage(albumHref)); | |
let rating = `???`; | |
let ratingJson = `{}`; | |
let ratingListIndex = -1; | |
findRating: for (const ratingList of ratingLists) { | |
ratingListIndex++; | |
for (const ratedAlbumInfo of ratingList) { | |
if ( | |
ratedAlbumInfo.albumTitle === albumInfo.albumTitle && | |
ratedAlbumInfo.artistName === albumInfo.artistName | |
) { | |
ratingJson = JSON.stringify(ratedAlbumInfo); | |
rating = ""; | |
for (let j = 0; j < ratingListIndex; j++) rating += "★"; | |
while (rating.length < 8 * 3) rating += "☆"; | |
break findRating; | |
} | |
} | |
} | |
const ratingElem = $("span"); | |
ratingElem.className = "pir-rating"; | |
ratingElem.innerHTML = " " + rating; | |
albumTitle.append(ratingElem); | |
albumTitle.dataset.pirRatingFor = ratingJson; | |
} | |
// timeoutId = setTimeout(showRatings, 2000); | |
} | |
/** | |
* @param {string} albumHref | |
* @returns {{artistName:string,albumTitle:string}} | |
*/ | |
async function getAlbumInfoFromAlbumPage(albumHref) { | |
const albumRes = await fetch(albumHref); | |
if (!albumRes.ok) { | |
throw new Error(`Could not load album info!`); | |
} | |
const albumHtml = await albumRes.text(); | |
const artistLink = albumHtml.replace( | |
/.*<meta name="music:musician" content="(.*?)".*/, | |
"$1" | |
); | |
const artistRes = await fetch(artistLink); | |
if (!artistRes.ok) { | |
throw new Error(`Could not load artist info!`); | |
} | |
const artistHtml = await artistRes.text(); | |
return { | |
artistName: JSON.parse( | |
artistHtml.replace( | |
/.*<script type="application\/ld\+json">(.*?)<\/script>.*/is, | |
"$1" | |
) | |
).name, | |
albumTitle: JSON.parse( | |
albumHtml.replace( | |
/.*<script type="application\/ld\+json">(.*?)<\/script>.*/is, | |
"$1" | |
) | |
).name, | |
}; | |
} | |
/** | |
* @returns {Promise<string>} | |
*/ | |
async function oauth() { | |
const data = new URLSearchParams(); | |
data.append("grant_type", "client_credentials"); | |
const res = await fetch(`https://accounts.spotify.com/api/token`, { | |
method: "POST", | |
headers: { | |
Authorization: | |
"Basic " + | |
btoa(localStorage["rrm9yz"] + ":" + localStorage["rrma0u"]), | |
"Content-Type": "application/x-www-form-urlencoded", | |
}, | |
body: data, | |
}); | |
if (!res.ok) throw new Error(`[rrm8el] Could not authenticate.`); | |
const response = await res.json(); | |
return response.access_token; | |
} | |
/** | |
* @param {string} token | |
* @param {string} albumHref | |
* @returns {{artistName:string,albumTitle:string}} | |
*/ | |
async function getAlbumInfoFromApi(token, albumHref) { | |
const id = new URL(albumHref).pathname.replace(/.*\//, ""); | |
const res = await fetch( | |
`https://api.spotify.com/v1/albums/${encodeURIComponent(id)}`, | |
{ | |
headers: { | |
Authorization: `Bearer ${token}`, | |
"Content-Type": "application/json", | |
}, | |
} | |
); | |
if (!res.ok) | |
throw new Error( | |
`[rrm8w1] Could not load album info for href: ${albumHref}` | |
); | |
const album = await res.json(); | |
return { | |
artistName: album.artists.reduce( | |
(all, it) => (all ? all + ", " + it.name : it.name), | |
"" | |
), | |
albumTitle: album.name, | |
}; | |
} | |
/** | |
* @param {string} s | |
* @returns {string} | |
*/ | |
function indent(s) { | |
return s.replace(/^/gm, "\t"); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment