Tidal Last.fm Scrobbles and Likes

Tighter integration between Tidal and Last.fm

// ==UserScript==
// @name         Tidal Last.fm Scrobbles and Likes
// @namespace    tidal_lastfm_scrobbles_and_likes
// @version      60
// @description  Tighter integration between Tidal and Last.fm
// @match        https://listen.tidal.com/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.0/spark-md5.min.js
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_log
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    let lastFmApiKey = GM_getValue('lastFmApiKey', '371319cf7884f13ab8c2b93cbed670de');
    let lastFmApiSecret = GM_getValue('lastFmApiSecret', '0486033e3a77c3260fea6c4d70ce1f91');
    let lastFmApiSessionKey = GM_getValue('lastFmApiSessionKey', '');
    let lastFmUsername = GM_getValue('lastFmUsername', '');
    let playCountCache = {};
    let currentUrl = window.location.href;
    let currentPlayingTrackId = -1;
    let lastBlurTime = Date.now();
    let domObserver = new MutationObserver(handleMutations);

    GM_registerMenuCommand('Last.fm API Key', () => {
        lastFmApiKey = prompt('Please enter your Last.fm API key, or keep the default:', lastFmApiKey) || lastFmApiKey;
        GM_setValue('lastFmApiKey', lastFmApiKey);
    });

    GM_registerMenuCommand('Last.fm API Secret', () => {
        lastFmApiSecret = prompt('Please enter your Last.fm API secret, or keep the default:', lastFmApiSecret) || lastFmApiSecret;
        GM_setValue('lastFmApiSecret', lastFmApiSecret);
    });

    GM_registerMenuCommand('Last.fm Logout', () => {
        GM_setValue('lastFmApiSessionKey', '');
    });

    // If the page was not open for 5 minutes, force refetch stats, maybe user scrobbled something
    function handleVisibilityChange() {
        if (!document.hidden && (Date.now() - lastBlurTime) > 300000) { // 5 minutes
            // GM_log('Page was hidden for more than 5 minutes, force refetch stats');
            resetAndRedraw();
        } else {
            lastBlurTime = Date.now();
        }
    }
    document.addEventListener('visibilitychange', handleVisibilityChange);

    function startObserving() {
        domObserver.observe(document.querySelector('body'), { childList: true, subtree: true, attributes: true });
    }

    function stopObserving() {
        domObserver.disconnect();
    }

    // Clear cache, fetch everything again and redraw. Call when we think that something changed.
    function resetAndRedraw() {
        playCountCache = {};
        drawPlayCounts();
        lastBlurTime = Date.now();
    }

    function processLastFmResponse(response) {
        if (!response.ok) {
            throw new Error(`HTTP error! Status: ${response.status}`);
        }
        return response.json().then(data => {
            if (data && data.track && typeof data.track.userplaycount !== 'undefined') {
                return {
                    playCount: data.track.userplaycount,
                    loved: data.track.userloved
                };
            } else {
                throw new Error('Invalid response format or missing userplaycount');
            }
        });
    }

    function getCacheKey(artistName, trackName) {
        return `artist:${encodeURIComponent(artistName)},track:${encodeURIComponent(trackName)}`;
    }

    function delayedFetchPlayCount(artistName, trackName, forceFetch, delay) {
        setTimeout(() => {
            getCachedPlayCount(artistName, trackName, forceFetch); // Force fetch
        }, delay);
    }

    function getCachedPlayCount(artistName, trackName, forceFetch) {
        const cacheKey = getCacheKey(artistName, trackName);
        // GM_log(`forceFetch: ${forceFetch}, hasCache: ${!playCountCache.hasOwnProperty(cacheKey)}`);
        if (forceFetch || !playCountCache.hasOwnProperty(cacheKey)) {
            playCountCache[cacheKey] = {
                playCount: -1,
                loved: -1
            };

            const url = `https://ws.audioscrobbler.com/2.0/?method=track.getInfo&api_key=${lastFmApiKey}&artist=${encodeURIComponent(artistName)}&track=${encodeURIComponent(trackName)}&username=${lastFmUsername}&autocorrect=0&format=json`;

            fetch(url).then(processLastFmResponse).then(result => {
                // GM_log(`${artistName} - ${trackName}  ${url} -> ${result.playCount}, ${result.loved}`);

                playCountCache[cacheKey] = result;
                drawPlayCounts(); // Update the UI with the new value
            }).catch(error => {
                console.error('Error fetching play count from Last.fm: ', error);
                GM_log(`Error fetching play count from Last.fm: ${error}`);
                delete playCountCache[cacheKey];
                delayedFetchPlayCount(artistName, trackName, /*forceFetch*/ false, 5000);
            });
        }

        return playCountCache[cacheKey];
    }

    // Unlove logic is implemented, but not used for simplicity
    async function setLastFmLoveStatus(trackName, artistName, shouldLove) {
        const cacheKey = getCacheKey(artistName, trackName);
        // Fetching play count should happen before that function. Otherwise weird race conditions can happen.
        if (!playCountCache.hasOwnProperty(cacheKey) || playCountCache[cacheKey].loved == -1 || playCountCache[cacheKey].loved == shouldLove) {
            return;
        }
        playCountCache[cacheKey].loved = -1;
        // GM_log(`Toggling Last.fm love status for ${artistName} - ${trackName} to ${shouldLove ? 'love' : 'unlove'}`);

        // Determine the method based on whether we are loving or unloving the track
        const method = shouldLove ? 'track.love' : 'track.unlove';

        // Prepare the parameters for the API call
        const params = {
            api_key: lastFmApiKey,
            method: method,
            track: trackName,
            artist: artistName,
            sk: lastFmApiSessionKey // The session key you've previously obtained
        };

        // Generate the API signature
        const apiSig = generateApiSignature(params, lastFmApiSecret); // Assuming you have a function to generate the signature

        // Add the api_sig and format to the parameters
        params.api_sig = apiSig;
        params.format = 'json';

        // Make the API call to love/unlove the track
        const url = 'https://ws.audioscrobbler.com/2.0/';
        const formData = new URLSearchParams();
        for (const [key, value] of Object.entries(params)) {
            formData.append(key, value);
        }

        try {
            const response = await fetch(url, {
                method: 'POST',
                body: formData
            });
            const data = await response.json();
            GM_log(`Last.fm ${method} request: ${JSON.stringify(data)}`);

            if (data.error) {
                throw new Error(`Error in Last.fm ${method} request: ${data.message}`);
            }
            playCountCache[cacheKey].loved = shouldLove;
            drawPlayCounts();
        } catch (error) {
            GM_log(`Error in Last.fm ${method} request: ${error}`);
            delete playCountCache[cacheKey];
            delayedFetchPlayCount(artistName, trackName, /*forceFetch*/ false, 1000);
            console.error('Last.fm request failed:', error);
        }
    }

    // Get currently played track ID from the footer
    function getNowPlayingTrackId() {
        const trackInfoElement = document.querySelector('[data-test="left-column-footer-player"]');
        const nowPlayingTrackId = trackInfoElement && trackInfoElement.hasAttribute('data-track--content-id')
            ? trackInfoElement.getAttribute('data-track--content-id')
            : -1;
        return nowPlayingTrackId;
    }

    // We redraw everything on url change, or on currently played track change
    function manageCache() {
        if (currentUrl !== window.location.href) {
            // GM_log(`URL changed, resetting cache and redrawing.`);
            resetAndRedraw();
            currentUrl = window.location.href;
        }

        // In the footer, where current played track is, it's hard to find actual track name
        // Because some tracks have version and it displayed there, for example "Spectra (Live)"
        const nowPlayingTrackId = getNowPlayingTrackId();
        // GM_log(`Now playing track ID: ${nowPlayingTrackId}`);

        if (nowPlayingTrackId !== currentPlayingTrackId) {
            if (currentPlayingTrackId !== -1) {
                // GM_log(`Track changed, refreshing play count after delay.`);
                setTimeout(() => {
                    // GM_log(`Track changed, refreshing play count.`);
                    resetAndRedraw();
                }, 3000); // Last.fm updates stats only after a second or two
            }
            currentPlayingTrackId = nowPlayingTrackId;
        }
    }

    function getTrackLink(artistName, trackName) {
        return `https://www.last.fm/user/${lastFmUsername}/library/music/${encodeURIComponent(artistName)}/_/${encodeURIComponent(trackName)}`;
    }

    // Html element with play count to be inserted into the DOM
    function createPlayCountElement(artistName, trackName) {
        const container = document.createElement('a');
        container.className = 'play-count-container';
        // container.href can change, when a tracks move in the list, so we set it in drawPlayCounts()
        container.target = '_blank'; // Open in a new tab

        // Flex container for consistent sizing
        const flexElement = document.createElement('div');
        flexElement.style.display = 'flex';
        flexElement.style.justifyContent = 'center';
        flexElement.style.alignItems = 'center';
        flexElement.style.width = '1em'; // Set fixed width to 1em
        flexElement.style.height = '100%';
        flexElement.style.marginLeft = '4px'; // Add 4px left margin

        // Add text content
        const textElement = document.createElement('span');
        flexElement.appendChild(textElement);

        container.appendChild(flexElement);
        return container;
    }

    function createOrGetPlayCountElement(track, artistName, trackName) {
        let playCountElement = track.querySelector('.play-count-container');
        if (playCountElement) {
            return playCountElement;
        }

        playCountElement = createPlayCountElement(artistName, trackName);
        const favoriteButton = track.querySelector('[data-test="add-to-favorites-button"]');
        if (favoriteButton) {
            favoriteButton.parentNode.insertBefore(playCountElement, favoriteButton);

            // Make cell around favorite button big enough to accomodate play count
            const flexContainer = favoriteButton.closest('div[role="cell"]');
            if (flexContainer) {
                flexContainer.style.flex = '0 0 130px';
            }
        }
        return playCountElement;
    }

    function getArtistAndTrackNames(track) {
        const trackNameElement = track.querySelector('[data-test="table-cell-title"]');
        const artistNameElements = track.querySelectorAll('[data-test="track-row-artist"] a');

        // childNodes[0] to ignore something like that: Spectra <span>(Live)</span>
        const trackName = trackNameElement ? trackNameElement.childNodes[0].textContent.trim() : '';
        const artistName = artistNameElements.length > 0 ? Array.from(artistNameElements).map(el => el.textContent.trim()).join(', ') : '';

        return { artistName, trackName };
    }

    function drawPlayCounts() {
        stopObserving(); // to not trigger mutation observer with our changes

        const isOnFavoritesPage = window.location.href === 'https://listen.tidal.com/my-collection/tracks';

        document.querySelectorAll('[data-track-id]').forEach(track => {
            const favoriteButton = track.querySelector('[data-test="add-to-favorites-button"]');
            if (!favoriteButton) {
                return;
            }

            const { artistName, trackName } = getArtistAndTrackNames(track);
            const playCountElement = createOrGetPlayCountElement(track, artistName, trackName);
            const { playCount, loved } = getCachedPlayCount(artistName, trackName, /*forceFetch*/ false);

            playCountElement.href = getTrackLink(artistName, trackName);
            const textElement = playCountElement.querySelector('span');
            textElement.textContent = playCount === -1 ? '?' : (playCount > 0 ? playCount.toString() : '\u00A0\u00A0');

            if (loved == 1) {
                favoriteButton.classList.add('make-it-red');
            } else if (loved == 0) {
                favoriteButton.classList.remove('make-it-red');
            }

            const favorited = favoriteButton.getAttribute('aria-checked') === 'true';
            if (favorited) {
                // We don't unlove Last.fm tracks ever for simplicity
                setLastFmLoveStatus(trackName, artistName, favorited);
            }
        });

        startObserving();
    }

    function handleMutations(mutations) {
        mutations.forEach(mutation => {
            if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                manageCache();
                drawPlayCounts();
            }
            // When we like/unlike a track, the mutation above isn't triggered, but this one is
            if (mutation.type === 'attributes' && mutation.attributeName === 'aria-checked') {
                drawPlayCounts();
            }
        });
    }

    // Function to fetch the session key
    async function fetchSessionKey(apiKey, secret, token) {
        const params = {
            api_key: apiKey,
            method: 'auth.getSession',
            token: token // The token received from the callback URL
        };

        const apiSig = await generateApiSignature(params, secret);
        params.api_sig = apiSig;

        const url = 'https://ws.audioscrobbler.com/2.0/';
        const formData = new URLSearchParams();
        for (const [key, value] of Object.entries(params)) {
            formData.append(key, value);
        }
        formData.append('format', 'json'); // Specify the response format

        const response = await fetch(url, {
            method: 'POST',
            body: formData
        });
        const data = await response.json();

        if (data.error) {
            throw new Error(`Error fetching session key: ${data.error}, message: ${data.message}`);
        }

        lastFmApiSessionKey = data.session.key;
        GM_setValue('lastFmApiSessionKey', lastFmApiSessionKey);

        lastFmUsername = data.session.name;
        GM_setValue('lastFmUsername', lastFmUsername);
    }

    function generateApiSignature(params, secret) {
        const orderedParams = {};
        Object.keys(params).sort().forEach(function (key) {
            orderedParams[key] = params[key];
        });

        let concatenatedParams = '';
        for (let key in orderedParams) {
            concatenatedParams += key + orderedParams[key];
        }

        concatenatedParams += secret;

        return md5(concatenatedParams);
    }

    function md5(message) {
        return SparkMD5.hash(message);
    }

    function authenticateWithLastFm() {
        const callbackUrl = encodeURIComponent(window.location.href.split('#')[0]); // Remove any existing hash
        const authUrl = `http://www.last.fm/api/auth/?api_key=${lastFmApiKey}&cb=${callbackUrl}`;
        window.location.href = authUrl;
    }

    // Function to check for a token in the URL parameters and remove it
    function checkForToken() {
        const urlParams = new URLSearchParams(window.location.search);
        const token = urlParams.get('token');
        if (token) {
            window.history.pushState({}, document.title, window.location.pathname + window.location.hash);

            fetchSessionKey(lastFmApiKey, lastFmApiSecret, token)
                .then(() => {
                    startTrackingMutations();
                })
                .catch(error => {
                    GM_log(`Error fetching session key: ${error}`);
                    authenticateWithLastFm();
                });

            return true;
        }
        return false;
    }

    function startTrackingMutations() {
        manageCache();
        drawPlayCounts();
    }

    const style = document.createElement('style');
    style.textContent = `
        .make-it-red svg {
            color: #f88 !important;
        }
    `;
    document.head.appendChild(style);

    if (lastFmApiSessionKey) {
        // GM_log(`Last.fm session key: ${lastFmApiSessionKey}`);
        startTrackingMutations();
    } else if (!checkForToken()) {
        GM_log('Last.fm session key not found, authenticating...');
        authenticateWithLastFm();
    }

})();