Last active
September 4, 2024 20:59
-
-
Save chappy84/86eb4aeeca7fff5f5ae79ee451ecc4cd to your computer and use it in GitHub Desktop.
Provide last.fm links, and optionally scrobble stats, on albumoftheyear.org
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 Album of the Year Last.fm integration | |
// @namespace http://tampermonkey.net/ | |
// @version 0.1 | |
// @description Adds link to a user's scrobble pages for artist and album names | |
// @author tomc.dev | |
// @match https://www.albumoftheyear.org/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=albumoftheyear.org | |
// @downloadURL https://gist.github.com/chappy84/86eb4aeeca7fff5f5ae79ee451ecc4cd/raw/albumoftheyear.org-last.fm-integration.user.js | |
// @updateURL https://gist.github.com/chappy84/86eb4aeeca7fff5f5ae79ee451ecc4cd/raw/albumoftheyear.org-last.fm-integration.user.js | |
// @supportURL https://gist.github.com/chappy84/86eb4aeeca7fff5f5ae79ee451ecc4cd | |
// @grant none | |
// ==/UserScript== | |
(async function() { | |
'use strict'; | |
// Changeable config | |
const lastFmUsername = ''; | |
/** | |
* Last FM API Key. | |
* Register for your own!! https://www.last.fm/api/account/create | |
* | |
* Definitely don't source one from, say, the code of the Chrome Web Scrobbler extension: | |
* https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/scrobbler/lastfm/lastfm-scrobbler.ts#L21-L24 | |
*/ | |
const lastFmAPIKey = ''; | |
// Code config constants | |
const linkPrefix = `https://www.last.fm/${lastFmUsername ? `user/${lastFmUsername}/library/` : ''}music/`; | |
const apiBaseUrl = `https://ws.audioscrobbler.com/2.0/?username=${lastFmUsername}`; | |
const apiAlbumBaseUrl = `${apiBaseUrl}&method=album.getinfo&api_key=${lastFmAPIKey}&format=json`; // needs: &artist=<artist name>&album=<album name> | |
const apiArtistBaseUrl = `${apiBaseUrl}&method=artist.getinfo&api_key=${lastFmAPIKey}&format=json`; // needs: &artist=<artist name> | |
const linkTypeAlbum = 'album'; | |
const lunkTypeArtist = 'artist'; | |
const getPlayCounts = lastFmUsername && lastFmAPIKey; | |
// const apiTrackBaseUrl = `${apiBaseUrl}&method=track.getinfo&api_key=${lastFmAPIKey}&format=json`; // needs: &artist=<artist name>&track=<track name> | |
String.prototype.UCFirst = function() { | |
return this.charAt(0).toUpperCase() + this.substring(1); | |
} | |
function encodeForUrl(name) { | |
return encodeURIComponent(name).replace(/(%20)+/g, "+").replace(/%26amp%3B/g, '&'); | |
} | |
const cardinals = { | |
'B': Math.pow(10, 9), | |
'M': Math.pow(10, 6), | |
'K': Math.pow(10, 3), | |
}; | |
function formatPlayCount(playCount) { | |
for (const [suffix, divisor] of Object.entries(cardinals)) { | |
if (playCount >= divisor) { | |
return (playCount / divisor).toFixed(1) + suffix; | |
} | |
} | |
return playCount; | |
} | |
async function getJson(url) { | |
try { | |
const response = await fetch(url); | |
if (!response.ok) { | |
throw new Error(`Response status: ${response.status}`); | |
} | |
return await response.json(); | |
} catch (error) { | |
console.error(error.message); | |
return {}; | |
} | |
} | |
async function getArtistPlayCount(artistName) { | |
if (!getPlayCounts) { | |
return ''; | |
} | |
const json = await getJson(`${apiArtistBaseUrl}&artist=${encodeForUrl(artistName)}`); | |
return (json.artist && json.artist.stats && json.artist.stats.userplaycount | |
? formatPlayCount(json.artist.stats.userplaycount) : 0) | |
+ ''; | |
} | |
async function getAlbumPlayCount(artistName, albumName) { | |
if (!getPlayCounts) { | |
return ''; | |
} | |
const json = await getJson( | |
`${apiAlbumBaseUrl}&artist=${encodeForUrl(artistName)}&album=${encodeForUrl(albumName)}` | |
); | |
let playCount = (json.album && json.album.userplaycount ? json.album.userplaycount : 0) + ''; | |
if (json.album.tracks && json.album.tracks.track && json.album.tracks.track.length) { | |
playCount += ` / ${formatPlayCount(json.album.tracks.track.length || 0)}`; | |
} | |
return playCount; | |
} | |
function addItemPageLink(parentDiv, type, artistName, albumName) { | |
const isAlbumLink = type == linkTypeAlbum; | |
const linkTitle = `Last.fm${type ? ' ' + type.UCFirst() : ''}`; | |
const urlArtistName = encodeForUrl(artistName); | |
const urlAlbumName = encodeForUrl(albumName); | |
const link = document.createElement('div'); | |
link.className = 'albumLinksFlex'; | |
link.innerHTML = `<a href="${linkPrefix}${urlArtistName}${isAlbumLink ? `/${urlAlbumName}` : ''}" rel="nofollow" target="_blank" title="${linkTitle}"><div class="albumButton lastfm"> | |
<i class="fab fa-lastfm"></i>${type ? `<i class="fa-regular fa-${isAlbumLink ? 'album' : 'user-music'}"></i>` : ''} | |
<span>${linkTitle}</span> | |
</div></a>`; | |
parentDiv.appendChild(link); | |
return link; | |
} | |
function addPlayCountToItemPageLink(link, playCount, isAlbumLink) { | |
if (isAlbumLink && playCount.length && -1 == playCount.indexOf(' / ')) { | |
const albumLengthSelectors = ['.trackListTable > tbody > tr', '.trackList > ol > li']; | |
let albumLength = 0; | |
for (let i = 0; i < albumLengthSelectors.length; i++) { | |
albumLength = document.querySelectorAll(albumLengthSelectors[i]).length; | |
if (albumLength) { | |
break; | |
} | |
} | |
playCount += ` / ${albumLength}`; | |
} | |
const countSpan = document.createElement('span'); | |
countSpan.className = 'count'; | |
countSpan.innerHTML = playCount; | |
const linkText = link.querySelector('.lastfm > span'); | |
// Colon needs to be in span, so it's hidden on smaller screens | |
linkText.appendChild(document.createTextNode(': ')); | |
linkText.parentNode.appendChild(countSpan); | |
} | |
function addStyle() { | |
const style = document.createElement('style'); | |
style.textContent = ` | |
:root { | |
/* Colours sourced from last.fm website css | |
Default last.fm colour */ | |
--color-last-fm-scarlet: #b90000; | |
/* lighter last.fm colour to make ratings page links easier on the eyes */ | |
--color-last-fm-habanero: #f71414; | |
} | |
/* artist and album pages */ | |
.albumButton.lastfm:hover { | |
background-color: var(--color-last-fm-scarlet); | |
} | |
/* genre / ratings pages */ | |
.albumListLinks .lastfm:hover { | |
border-color: var(--color-last-fm-habanero); | |
color: var(--color-last-fm-habanero); | |
} | |
/* artist pages */ | |
.artistTopBox .socialRow .albumButton.lastfm { | |
padding: 0 10px; | |
} | |
/* Thinner displays */ | |
@media screen and (min-width: 0) and (max-width: 1023px) { | |
/* artist and album pages */ | |
.albumButton.lastfm .count { | |
display: inline; | |
line-height: 32px; | |
font-size: 12px; | |
} | |
/* artist pages */ | |
.artistTopBox .socialRow .albumButton.lastfm { | |
display: inline-block; | |
min-width: 100px; | |
margin: 0 1% 15px 0; | |
} | |
/* album pages */ | |
.albumLinksFlex a:has(.albumButton.lastfm):hover, | |
.albumLinksFlex .albumButton.lastfm:hover { | |
text-decoration: none; | |
} | |
.albumLinksFlex .albumButton.lastfm { | |
margin: 10px 0 0; | |
padding: 0; | |
display: block; | |
width: auto; | |
line-height: 32px; | |
height: 34px; | |
font-size: 12px; | |
} | |
/* genre / ratings pages */ | |
.albumListCover { | |
margin: 0 20px 15px 0; | |
} | |
.albumListLinks div { | |
margin-top: 5px; | |
} | |
/* resets touch scroll area on album list links | |
so we don't have to scroll to see last fm stats / links */ | |
.albumListLinks { | |
white-space: normal; | |
width: auto; | |
overflow-x: visible; | |
overflow-y: visible; | |
-webkit-overflow-scrolling: auto; | |
-ms-overflow-style: auto; | |
} | |
/* Little fixes for site css that makes these not display quite right on small screens | |
Extra 0.333% takes it up to 1/3 of the width, including the 1% L/R margin */ | |
.albumButton { | |
width: 31.333%; | |
} | |
/* These elements use :first-child in the site css, as as there's | |
up to two, but with only one, it doesn't look right */ | |
.albumLinksFlex { | |
margin-right: 10px; | |
} | |
.albumLinksFlex:last-child { | |
margin-right: 0; | |
} | |
} | |
/* end of year lists pages */ | |
@media screen and (min-width: 0) and (max-width: 480px) { | |
.pointsTable { | |
margin: 10px 0 15px; | |
} | |
} | |
@media screen and (min-width: 481px) { | |
.albumListLinks.listSummary { | |
margin-top: 5px; | |
} | |
} | |
`; | |
document.head.appendChild(style); | |
} | |
// iOS Safari caches pages after these have been applied sometimes, so check they don't already exist before starting our changes | |
if (!document.querySelector('.lastfm')) { | |
if (/\/album\//.test(document.location)) { | |
// Album page | |
addStyle(); | |
const buyButtons = document.querySelector('.thirdPartyLinks > .buyButtons'); | |
if (buyButtons) { | |
// Deliberately don't go for the anchor tag, as there can be multiple here when two artists have colaborated | |
const artistEl = document.querySelector('.artist span[itemprop="name"]'); | |
const albumLinks = document.createElement('div'); | |
albumLinks.className = 'albumLinks'; | |
buyButtons.appendChild(albumLinks); | |
if (artistEl) { | |
const artistName = artistEl.textContent.trim(); | |
const artistLink = addItemPageLink(albumLinks, 'artist', artistName); | |
if (getPlayCounts) { | |
(async function() { | |
addPlayCountToItemPageLink(artistLink, await getArtistPlayCount(artistName)); | |
})(); | |
} | |
const albumEl = document.querySelector('.albumTitle > span[itemprop="name"]'); | |
if (albumEl) { | |
const albumName = albumEl.textContent.trim(); | |
const albumLink = addItemPageLink(albumLinks, 'album', artistName, albumName); | |
if (getPlayCounts) { | |
(async function() { | |
addPlayCountToItemPageLink(albumLink, await getAlbumPlayCount(artistName, albumName), true); | |
})(); | |
} | |
} | |
} | |
} | |
} else if (/\/artist\//.test(document.location)) { | |
// Artist Page | |
addStyle(); | |
const artistHeadlineDiv = document.querySelector('.artistHeadline'); | |
const artistTopBoxDiv = document.querySelector('.artistTopBox'); | |
if (artistHeadlineDiv && artistTopBoxDiv) { | |
const socialRow = document.createElement('div'); | |
socialRow.className = 'socialRow'; | |
artistTopBoxDiv.appendChild(socialRow); | |
const artistName = artistHeadlineDiv.textContent.trim(); | |
const artistLink = addItemPageLink(socialRow, null, artistName); | |
if (getPlayCounts) { | |
(async function() { | |
addPlayCountToItemPageLink(artistLink, await getArtistPlayCount(artistName)); | |
})(); | |
} | |
} | |
} else if (/\/(genre|list|ratings)\/[0-9]/.test(document.location)) { | |
// Ratings pages | |
addStyle(); | |
const albumRows = document.querySelectorAll('.albumListRow'); // [id^="rank-"] doesn't work on list pages that have no rank | |
if (albumRows) { | |
for (let i = 0; i < albumRows.length; i++) { | |
const albumRow = albumRows[i]; | |
const albumDetails = albumRow.querySelector('.albumListTitle meta[itemprop="name"]').content; | |
const albumLinkCont = albumRow.querySelector('.albumListLinks'); | |
if (albumDetails && albumLinkCont) { | |
const albumDetailParts = albumDetails.match(/^(.+?) - (.+?)$/); | |
if (3 === albumDetailParts.length) { | |
const artistName = albumDetailParts[1]; | |
const urlArtistName = encodeForUrl(artistName); | |
const albumName = albumDetailParts[2]; | |
const urlAlbumName = encodeForUrl(albumName); | |
function createLink(href, title, iconSuffix) { | |
const lastFmLink = document.createElement('a'); | |
lastFmLink.href = href; | |
lastFmLink.rel = "nofollow"; | |
lastFmLink.target = "_blank"; | |
lastFmLink.innerHTML = `<div class="lastfm"><i class="fab fa-lastfm"></i><i class="fa-regular fa-${iconSuffix}"></i>${title}</div>`; | |
return lastFmLink; | |
} | |
const titlePrefix = 'Last.fm'; | |
const lastFmArtistLink = createLink(`${linkPrefix}${urlArtistName}`, `${titlePrefix} Artist`, 'user-music'); | |
albumLinkCont.appendChild(lastFmArtistLink); | |
const lastFmAlbumLink = createLink(`${linkPrefix}${urlArtistName}/${urlAlbumName}`, `${titlePrefix} Album`, 'album'); | |
albumLinkCont.appendChild(lastFmAlbumLink); | |
if (getPlayCounts) { | |
(async function () { | |
lastFmArtistLink.querySelector('.lastfm').appendChild( | |
document.createTextNode(`: ${await getArtistPlayCount(artistName)}`) | |
); | |
})(); | |
(async function () { | |
lastFmAlbumLink.querySelector('.lastfm').appendChild( | |
document.createTextNode(`: ${await getAlbumPlayCount(artistName, albumName)}`) | |
); | |
})(); | |
} | |
} | |
} | |
} | |
} | |
} else if (/\/list\/summary\//.test(document.location)) { | |
addStyle(); | |
const albumRows = document.querySelectorAll('.listSummaryRow'); | |
if (albumRows) { | |
for (let i = 0; i < albumRows.length; i++) { | |
const albumRow = albumRows[i]; | |
const artistCont = albumRow.querySelector('.artistTitle'); | |
const albumCont = albumRow.querySelector('.albumTitle'); | |
const albumLinkCont = albumRow.querySelector('.albumListLinks'); | |
if (artistCont && albumCont && albumLinkCont) { | |
const artistName = artistCont.textContent.trim(); | |
const urlArtistName = encodeForUrl(artistName); | |
const albumName = albumCont.textContent.trim(); | |
const urlAlbumName = encodeForUrl(albumName); | |
function createLink(href, title) { | |
const lastFmLink = document.createElement('a'); | |
lastFmLink.href = href; | |
lastFmLink.rel = "nofollow"; | |
lastFmLink.target = "_blank"; | |
lastFmLink.innerHTML = `<div>${title}</div>`; | |
return lastFmLink; | |
} | |
const titlePrefix = 'Last.fm'; | |
const lastFmArtistLink = createLink(`${linkPrefix}${urlArtistName}`, `${titlePrefix} Artist`); | |
albumLinkCont.appendChild(lastFmArtistLink); | |
const lastFmAlbumLink = createLink(`${linkPrefix}${urlArtistName}/${urlAlbumName}`, `${titlePrefix} Album`); | |
albumLinkCont.appendChild(lastFmAlbumLink); | |
if (getPlayCounts) { | |
(async function () { | |
lastFmArtistLink.querySelector('div').appendChild( | |
document.createTextNode(`: ${await getArtistPlayCount(artistName)}`) | |
); | |
})(); | |
(async function () { | |
lastFmAlbumLink.querySelector('div').appendChild( | |
document.createTextNode(`: ${await getAlbumPlayCount(artistName, albumName)}`) | |
); | |
})(); | |
} | |
} | |
} | |
} | |
} | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment