Last.fm Mark Corrections

Highlights auto-corrected artist names and track titles in scrobbles lists.

// ==UserScript==
// @name Last.fm Mark Corrections
// @namespace https://github.com/hummingme
// @description Highlights auto-corrected artist names and track titles in scrobbles lists.
// @version 1.0.0
// @author hummingme
// @license MIT
// @match https://www.last.fm/*
// @noframes
// @grant none
// @homepageURL https://github.com/hummingme/lastfm-mark-corrections
// @supportURL https://github.com/hummingme/lastfm-mark-corrections/issues
// @icon https://raw.githubusercontent.com/hummingme/lastfm-mark-corrections/refs/heads/main/assets/lastfm-icon.png
// ==/UserScript==

if (hasPro()) {
    run();
}

function run() {
    processChartlists(document.documentElement);
    observer().observe(document.body, {
        childList: true,
        subtree: true
    });
}

function observer() {
    const userLink = document.querySelector('a.auth-link')?.href;
    return new MutationObserver(mutations => {
        if (!location.href.startsWith(userLink)) {
            return; // not part of the user library
        }
        for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
                if (node instanceof HTMLTableRowElement) {
                    // with 'automatic refreshing of recent tracks' enabled
                    highlightAutocorrections([node]);
                } else if (
                    node instanceof HTMLElement &&
                    ['DIV', 'SECTION'].includes(node.tagName)
                ) {
                    processChartlists(node);
                }
            }
        }
    });
}

function processChartlists(node) {
    const tables = node.querySelectorAll('table.chartlist');
    Array.from(tables).forEach((table) => {
        if (isScrobbleList(table)) {
            const rows = table.querySelectorAll('tr.chartlist-row');
            highlightAutocorrections([...rows]);
        }
    });
}

function highlightAutocorrections(rows) {
    rows.forEach((row) => {
        if (isScrobbleRow(row)) {
            const artist = getValueFromInputField(row, 'artist_name');
            const track = getValueFromInputField(row, 'track_name');
            const { value: artist2, node: artistNode } = getValueFromTextNode(row, 'artist');
            const { value: track2, node: trackNode } =  getValueFromTextNode(row, 'name');
            if (artist && artist2 && artist.toLowerCase() !== artist2.toLowerCase()) {
                highlight(artistNode, artist);
            }
            if (track && track2 && track.toLowerCase() !== track2.toLowerCase()) {
                highlight(trackNode, track);
            }
        }
    });
};

function hasPro() {
    return !!document.querySelector('span.user-status-subscriber');
}

function isScrobbleList(table) {
    return table.classList.contains('chartlist--with-bar') === false;
}

function isScrobbleRow(row) {
    return row.querySelector('form[data-edit-scrobble]') instanceof HTMLFormElement;
}

function getValueFromInputField(row, name) {
    return row.querySelector(`form[data-edit-scrobble] input[name="${name}"]`)?.value;
}

function getValueFromTextNode(row, name) {
    const node = row.querySelector(`td.chartlist-${name} a`);
    const value = node?.innerText;
    return { value, node };
}

function highlight(node, corrected) {
    Object.assign(node.style, {
        textDecorationLine: 'underline',
        textDecorationStyle: 'wavy',
        textDecorationColor: '#d92323'
    });
    node.title = `auto-corrected from: ${corrected}`;
}