// ==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 // @downloadURL none // ==/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 (Live) 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(); } })();