Skip to content

Instantly share code, notes, and snippets.

@chappy84
Last active September 4, 2024 20:59
Show Gist options
  • Save chappy84/86eb4aeeca7fff5f5ae79ee451ecc4cd to your computer and use it in GitHub Desktop.
Save chappy84/86eb4aeeca7fff5f5ae79ee451ecc4cd to your computer and use it in GitHub Desktop.
Provide last.fm links, and optionally scrobble stats, on albumoftheyear.org
// ==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