Album of the Year Last.fm integration

Adds link to a user's scrobble pages for artist and album names

// ==UserScript==
// @name         Album of the Year Last.fm integration
// @namespace    https://www.albumoftheyear.org/
// @version      0.2.1
// @description  Adds link to a user's scrobble pages for artist and album names
// @author       tomc.dev
// @match        https://www.albumoftheyear.org/*
// @license      MIT
// @icon         https://www.google.com/s2/favicons?sz=64&domain=albumoftheyear.org
// @supportURL   https://gist.github.com/chappy84/86eb4aeeca7fff5f5ae79ee451ecc4cd
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addValueChangeListener
// ==/UserScript==

(async function() {
    'use strict';

    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, '&');
    }

    // GM_ functions aren't defined in Userscripts on iOS, so fall back to localStorage instead.
    if (typeof GM_getValue == 'undefined') {
        // defining by reference to localStorage.getItem breaks iOS, thus wrapper arrow function
        window.GM_getValue = (key) => {
            return localStorage.getItem(key);
        };
    }
    if (typeof GM_setValue == 'undefined') {
        window.GM_setValue = (key, val) => {
            localStorage.setItem(key, val);
        };
    }

    // Code Configuration
    class LastFmConfig {
        #storageKeyApiKey = 'lastFmApiKey';
        #storageKeyUsername = 'lastFmUsername';

        constructor(remoteChangeCallback) {
            // If the user changes the values in another tab, we want to ensure everything is ok in this tab too.
            if (remoteChangeCallback && typeof GM_addValueChangeListener != 'undefined') {
                GM_addValueChangeListener(this.#storageKeyUsername, (key, oldValue, newValue, remote) => {
                    if (remote) {
                        remoteChangeCallback();
                    }
                });
                GM_addValueChangeListener(this.#storageKeyApiKey, (key, oldValue, newValue, remote) => {
                    if (remote) {
                        remoteChangeCallback();
                    }
                });
            }
        }

        get username() {
            return GM_getValue(this.#storageKeyUsername);
        }

        set username(username) {
            GM_setValue(this.#storageKeyUsername, username);
        }

        /**
         * 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
         */
        get apiKey() {
            return GM_getValue(this.#storageKeyApiKey);
        }

        set apiKey(apiKey) {
            GM_setValue(this.#storageKeyApiKey, apiKey);
        }

        get webBaseUrl() {
            return `https://www.last.fm/${this.username ? `user/${this.username}/library/` : ''}music/`;
        }

        getWebUrl(artist, album, track) {
            return `${this.webBaseUrl}${encodeForUrl(artist)}${album != undefined ? `/${encodeForUrl(album)}` : ''}`;
        }

        get apiBaseUrl() {
            return `https://ws.audioscrobbler.com/2.0/?username=${this.username}`;
        }

        getApiAlbumUrl(artist, album) {
            return this.getApiUrl('album.getinfo', {artist: artist, album: album});
        }

        getApiArtistUrl(artist) {
            return this.getApiUrl('artist.getinfo', {artist: artist});
        }

        // getApiTrackUrl(artist, track) {
        //     return this.getApiUrl('track.getinfo', {artist: artist, track: track});
        // }

        getApiUrl(method, params) {
            let baseUrl = `${this.apiBaseUrl}&method=${method}&api_key=${this.apiKey}&format=json`;
            if (params != undefined) {
                let paramArr = [];
                for (const [name, value] of Object.entries(params)) {
                    paramArr.push(`${encodeForUrl(name)}=${encodeForUrl(value)}`);
                }
                baseUrl += `&${paramArr.join('&')}`;
            }
            return baseUrl;
        }

        get showPlayCounts() {
            return this.username && this.apiKey;
        }
    }

    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;
    }

    const linkTypeAlbum = 'album';
    const linkTypeArtist = 'artist';
    const iconSuffixArtist = 'user-music';

    const lastFmConfig = new LastFmConfig(refreshPageLinks);

    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 (!lastFmConfig.showPlayCounts) {
            return '';
        }
        const json = await getJson(lastFmConfig.getApiArtistUrl(artistName));
        return (json.artist && json.artist.stats && json.artist.stats.userplaycount
                ? formatPlayCount(json.artist.stats.userplaycount) : 0)
            + '';
    }

    async function getAlbumPlayCount(artistName, albumName) {
        if (!lastFmConfig.showPlayCounts) {
            return '';
        }
        const json = await getJson(
            lastFmConfig.getApiAlbumUrl(artistName, albumName)
        );
        let playCount = (json.album && json.album.userplaycount ? json.album.userplaycount : 0) + '';
        if (json.album && 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 link = document.createElement('div');
        link.dataset.lastFm = type;
        link.className = 'albumLinksFlex';
        link.innerHTML = `<a href="${lastFmConfig.getWebUrl(artistName, albumName)}" rel="nofollow" target="_blank" title="${linkTitle}"><div class="albumButton lastfm">
            <i class="fab fa-lastfm"></i>${type ? `<i class="fa-regular fa-${isAlbumLink ? linkTypeAlbum : iconSuffixArtist}"></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 addCommonStyle() {
        const pagesStyleId = 'lastFmCommonStyle';
        if (!document.getElementById(pagesStyleId)) {
            const style = document.createElement('style');
            style.id = pagesStyleId;
            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;
                }
                @media screen and (min-width: 0) and (max-width: 1023px) {
                    /* 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;
                    }
                }
            `;
            document.head.appendChild(style);
        }
    }

    function addPrefsDialogBox() {
        const dialogElementId = 'lastfmSaveDialog';
        if (!document.getElementById(dialogElementId)) {

            const style = document.createElement('style');
            style.id = 'lastFmDialogStyle';
            style.textContent = `
                /* Ensure the rest of the page can't be interacted with when the dialog is displayed */
                *:has(~ dialog#lastfmSaveDialog[open]):first-of-type::after {
                    content: "";
                    position: fixed;
                    top: 0;
                    left: 0;
                    bottom: 0;
                    right: 0;
                    display: block;
                    background-color: rgba(50, 50, 50, 0.4);
                    backdrop-filter: blur(5px);
                    z-index: 2147483648;  /* 1 more than the page header (2^31) */
                }
                .dark *:has(~ dialog#lastfmSaveDialog[open]):first-of-type::after {
                    background-color: rgba(128, 128, 128, 0.4);
                }
                #lastfmSaveDialog {
                    min-width: 30%;
                    margin: auto;
                    padding: 20px;
                    z-index: 2147483649; /* 2 more than the page header (one over 2^31) */
                    top: 0;
                    right: 0;
                    bottom: 0;
                    left: 0;
                    border-color: #323232;
                    border-radius: 10px;
                }
                #lastfmSaveDialog #lastfmClose {
                    position: absolute;
                    top: 20px;
                    right: 20px;
                    border-radius: 50%;
                    width: 2em;
                    height: 2em;
                    /* Fixes poor positioning of x on iOS */
                    padding: 0;
                }
                #lastfmSaveDialog label,
                #lastfmSaveDialog input,
                #lastfmSaveDialog div {
                    display: block;
                    width: 100%;
                }
                #lastfmSaveDialog h4 {
                    font-size: 1.75em;
                    margin: 0 0 25px;
                }
                #lastfmSaveDialog label {
                    font-size: 1.25em;
                    margin: 15px 0 5px;
                }
                #lastfmSaveDialog input {
                    box-sizing: border-box;
                    padding: 5px;
                }
                #lastfmSaveDialog p {
                    text-align: right;
                    margin: 0.1ex;
                    font-size: 0.8em;
                }
                #lastfmSaveDialog #lastfmSave {
                    margin-left: auto;
                    margin-right: 0;
                    margin-top: 15px;
                    display: block;
                }
                .dark #lastfmSaveDialog {
                    background-color: #2F3136;
                    border-color: #202225;
                    color: #DCDDDE;
                }
                .mobileLastfm {
                    display: none;
                }
                @media screen and (min-width: 0) and (max-width: 480px) {
                    .mobileLastfm {
                        height: 40px;
                        width: 40px;
                        display: inline-block;
                        right: 140px;
                        top: 10px;
                        position: absolute;
                        font-size: 4ex;
                    }
                }
            `;
            document.head.appendChild(style);

            const navBar = document.querySelector('.nav #content');
            const lastFmPrefsNavBar = document.createElement('div');
            lastFmPrefsNavBar.className = 'navBlock lastfm';
            lastFmPrefsNavBar.innerHTML = '<a href="javascript:;">Last.fm Prefs</a>';
            navBar.insertBefore(lastFmPrefsNavBar, navBar.querySelector('.navBlock.signIn'));

            const header = document.querySelector('.header #content');
            const lastFmPrefsHeader = document.createElement('div');
            lastFmPrefsHeader.className = 'mobileLastfm';
            lastFmPrefsHeader.innerHTML = '<a href="javascript:;"><i class="fab fa-lastfm"></i></a>';
            header.insertBefore(lastFmPrefsHeader, header.querySelector('.mobileProfile'));

            const dialogBox = document.createElement('dialog');
            dialogBox.id = dialogElementId;

            dialogBox.innerHTML = `
                <button type="button" id="lastfmClose"><i class="fa fa-xmark"></i></button>
                <h4>Last.fm Preferences</h4>
                <div>
                    <label for="lastfmUsername">Username</label>
                    <input type="text" id="lastfmUsername" placeholder="Last.fm Username" value="${lastFmConfig.username}"/>
                    <p>Make Last.fm links point to your library, rather than the generic pages</p>
                </div>
                <div>
                    <label for="lastfmApiKey">API Key</label>
                    <input type="text" id="lastfmApiKey" placeholder="Last.fm API Key" value="${lastFmConfig.apiKey}"/>
                    <p>Required to show your scrobble counts. Register for your own <a target="_blank" href="https://www.last.fm/api/account/create">here</a></p>
                </div>
                <button type="button" id="lastfmSave">Save</button>
            `;
            document.body.appendChild(dialogBox);

            const showLastfmPrefs = () => {
                document.getElementById('lastfmUsername').value = lastFmConfig.username;
                document.getElementById('lastfmApiKey').value = lastFmConfig.apiKey;
                dialogBox.show();
            };
            lastFmPrefsNavBar.addEventListener('click', showLastfmPrefs);
            lastFmPrefsHeader.addEventListener('click', showLastfmPrefs);

            document.getElementById('lastfmClose').addEventListener('click', () => { dialogBox.close(); });
            document.getElementById('lastfmSave').addEventListener('click', () => {
                lastFmConfig.username = document.getElementById('lastfmUsername').value.trim();
                lastFmConfig.apiKey = document.getElementById('lastfmApiKey').value.trim();

                dialogBox.close();

                refreshPageLinks();
            });
        }
    }

    function addPagesStyle() {
        const pagesStyleId = 'lastFmLinkPageStyle';
        if (!document.getElementById(pagesStyleId)) {
            const style = document.createElement('style');
            style.id = pagesStyleId;
            style.textContent = `
                /* artist and album pages */
                .albumButton.lastfm:hover {
                    background-color: var(--color-last-fm-scarlet);
                    color: white;
                }
                .dark .albumButton.lastfm:hover {
                    color: #DCDDDE;
                }
                /* genre / ratings pages */
                .albumListLinks .lastfm:hover {
                    border-color: var(--color-last-fm-scarlet);
                    color: var(--color-last-fm-scarlet);
                }
                .dark .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;
                    }
                }
                /* 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);
        }
    }

    function addAlbumPageLinks() {
        const buyButtons = document.querySelector('.thirdPartyLinks > .buyButtons');
        if (buyButtons) {
            // Deliberately don't go for the anchor tag, as there can be multiple when two artists have colaborated
            const artistEl = document.querySelector('.artist span[itemprop="name"]');
            const albumLinks = document.createElement('div');
            albumLinks.dataset.lastFm = 'links';
            albumLinks.className = 'albumLinks';
            buyButtons.appendChild(albumLinks);
            if (artistEl) {
                const artistName = artistEl.textContent.trim();
                const artistLink = addItemPageLink(albumLinks, linkTypeArtist, artistName);
                if (lastFmConfig.showPlayCounts) {
                    (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, linkTypeAlbum, artistName, albumName);
                    if (lastFmConfig.showPlayCounts) {
                        (async function() {
                            addPlayCountToItemPageLink(
                                albumLink,
                                await getAlbumPlayCount(artistName, albumName),
                                true
                            );
                        })();
                    }
                }
            }
        }
    }

    function addArtistPageLink() {
        const artistHeadlineDiv = document.querySelector('.artistHeadline');
        const artistTopBoxDiv = document.querySelector('.artistTopBox');
        if (artistHeadlineDiv && artistTopBoxDiv) {
            const socialRow = document.createElement('div');
            socialRow.dataset.lastFm = 'social';
            socialRow.className = 'socialRow';
            artistTopBoxDiv.appendChild(socialRow);
            const artistName = artistHeadlineDiv.textContent.trim();
            const artistLink = addItemPageLink(socialRow, linkTypeArtist, artistName);
            if (lastFmConfig.showPlayCounts) {
                (async function() {
                    addPlayCountToItemPageLink(artistLink, await getArtistPlayCount(artistName));
                })();
            }
        }
    }

    function addListPageLinks() {
        // [id^="rank-"] doesn't work for selector on list pages that have no rank
        const albumRows = document.querySelectorAll('.albumListRow');
        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 albumName = albumDetailParts[2];

                        function createAnchorLink(href, title, type) {
                            const iconSuffix = type === linkTypeAlbum ? type : iconSuffixArtist;
                            const lastFmLink = document.createElement('a');
                            lastFmLink.dataset.lastFm = type;
                            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 = createAnchorLink(
                            lastFmConfig.getWebUrl(artistName),
                            `${titlePrefix} Artist`,
                            linkTypeArtist
                        );
                        albumLinkCont.appendChild(lastFmArtistLink);
                        const lastFmAlbumLink = createAnchorLink(
                            lastFmConfig.getWebUrl(artistName, albumName),
                            `${titlePrefix} Album`,
                            linkTypeAlbum
                        );
                        albumLinkCont.appendChild(lastFmAlbumLink);

                        if (lastFmConfig.showPlayCounts) {
                            (async function () {
                                lastFmArtistLink.querySelector('.lastfm').appendChild(
                                    document.createTextNode(`: ${await getArtistPlayCount(artistName)}`)
                                );
                            })();
                            (async function () {
                                lastFmAlbumLink.querySelector('.lastfm').appendChild(
                                    document.createTextNode(`: ${await getAlbumPlayCount(artistName, albumName)}`)
                                );
                            })();
                        }
                    }
                }
            }
        }
    }

    function addSummaryPageLinks() {
        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 albumName = albumCont.textContent.trim();

                    function createAnchorLink(href, title, type) {
                        const lastFmLink = document.createElement('a');
                        lastFmLink.dataset.lastFm = type;
                        lastFmLink.href = href;
                        lastFmLink.rel = 'nofollow';
                        lastFmLink.target = '_blank';
                        lastFmLink.innerHTML = `<div>${title}</div>`;
                        return lastFmLink;
                    }

                    const titlePrefix = 'Last.fm';
                    const lastFmArtistLink = createAnchorLink(
                        lastFmConfig.getWebUrl(artistName),
                        `${titlePrefix} Artist`,
                        linkTypeArtist
                    );
                    albumLinkCont.appendChild(lastFmArtistLink);
                    const lastFmAlbumLink = createAnchorLink(
                        lastFmConfig.getWebUrl(artistName, albumName),
                        `${titlePrefix} Album`,
                        linkTypeAlbum
                    );
                    albumLinkCont.appendChild(lastFmAlbumLink);

                    if (lastFmConfig.showPlayCounts) {
                        (async function () {
                            lastFmArtistLink.querySelector('div').appendChild(
                                document.createTextNode(`: ${await getArtistPlayCount(artistName)}`)
                            );
                        })();
                        (async function () {
                            lastFmAlbumLink.querySelector('div').appendChild(
                                document.createTextNode(`: ${await getAlbumPlayCount(artistName, albumName)}`)
                            );
                        })();
                    }
                }
            }
        }
    }

    function addPageLinks() {
        let addLinksFunction;
        if (/\/album\//.test(document.location)) {
            // Album page
            addLinksFunction = addAlbumPageLinks;
        } else if (/\/artist\//.test(document.location)) {
            // Artist Page
            addLinksFunction = addArtistPageLink;
        } else if (/\/(genre|list|ratings)\/[0-9]/.test(document.location)) {
            // Ratings pages
            addLinksFunction = addListPageLinks;
        } else if (/\/list\/summary\//.test(document.location)) {
            addLinksFunction = addSummaryPageLinks;
        }
        if (addLinksFunction) {
            addPagesStyle();
            addLinksFunction();
        }
    }

    function refreshPageLinks() {
        const existingLastFmElements = document.querySelectorAll('*[data-last-fm]');
        existingLastFmElements.forEach((lastFmEl) => { lastFmEl.remove(); });
        addPageLinks();
    }

    // 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')) {
        addCommonStyle();
        addPrefsDialogBox();
        addPageLinks();
    }
})();