|
// ==UserScript== |
|
// @name LastFM automated duplicate scrobble deletion script |
|
// @namespace https://gist.github.com/muhdiboy |
|
// @version 1.5.0 |
|
// @downloadURL https://gist.github.com/muhdiboy/a293cbff355af750e3b8f45ec816d1f1/raw/lastfm-automated-remove-duplicates.user.js |
|
// @updateURL https://gist.github.com/muhdiboy/a293cbff355af750e3b8f45ec816d1f1/raw/lastfm-automated-remove-duplicates.user.js |
|
// @description Based on https://gist.github.com/sk22/39cc280840f9d82df574c15d6eda6629#gistcomment-3046698, thanks to previous contributors (@CennoxX, @mattsson, @gms8994, @huw and @sk22) and new contributors (@Eiron). |
|
// @author muhdiboy |
|
// @match https://www.last.fm/*user/* |
|
// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw== |
|
// @grant none |
|
// ==/UserScript== |
|
|
|
(function() { |
|
// Variables |
|
let runScript = localStorage.getItem('runScript') === 'true'; |
|
let rmOnlyRealDup = localStorage.getItem('rmOnlyRealDup') !== 'false'; // Set checkbox to checked/true as default |
|
let numTracks = localStorage.getItem('numTracks') || "5"; |
|
let stopAtPage = localStorage.getItem('stopAtPage') || "0"; |
|
let currentPage = extractQueryParam('page', window.location.href) || 1; |
|
|
|
// Update currentPage when a click event occurs on the page |
|
window.addEventListener('click', function () { |
|
currentPage = extractQueryParam('page', window.location.href) || 1; |
|
}); |
|
|
|
// Functions |
|
let pageURLCheckInterval = setInterval (function () { |
|
// Check the URL at regular intervals to add or remove buttons |
|
if ( this.lastPathStr !== location.pathname || this.lastQueryStr !== location.search) { |
|
this.lastPathStr = location.pathname; |
|
this.lastQueryStr = location.search; |
|
if (shouldMatchPage()) createButtons(); else removeButtons(); |
|
} |
|
}, 125); |
|
|
|
function replaceQueryParam(param, newval, search) { |
|
// Function to replace a query parameter in the URL |
|
const regex = new RegExp("([?;&])" + param + "[^&;]*[;&]?"); |
|
const query = search.replace(regex, "$1").replace(/&$/, ''); |
|
return (query.length > 2 ? query + "&" : "?") + (newval ? param + "=" + newval : ''); |
|
} |
|
|
|
function extractQueryParam(name, url) { |
|
// Function to extract a query parameter from the URL |
|
if (!url) url = window.location.href; |
|
name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]"); |
|
const regexS = "[\\?&]" + name + "=([^&#]*)"; |
|
const regex = new RegExp(regexS); |
|
const results = regex.exec(url); |
|
return results == null ? null : results[1]; |
|
} |
|
|
|
function shouldMatchPage() { |
|
// Function to determine if the current page should be matched |
|
const url = window.location.href; |
|
const excludeUrlRegex = /^https:\/\/www\.last\.fm\/.*user\/.*\/library\/(albums|artists|tracks|music).*/; |
|
const matchUrlRegex = /^https:\/\/www\.last\.fm\/.*user\/.*\/library.*/; |
|
return matchUrlRegex.test(url) && !excludeUrlRegex.test(url); |
|
} |
|
|
|
function removeButtons() { |
|
// Function to remove all buttons from the page |
|
const cancelButton = document.getElementById('cancelButton'); |
|
const buttonContainer = document.getElementById('buttonContainer'); |
|
|
|
if (cancelButton) cancelButton.remove(); |
|
if (buttonContainer) buttonContainer.remove(); |
|
} |
|
|
|
// Buttons |
|
function createButtons() { |
|
// Create the cancel button to stop the script |
|
const cancelButton = document.createElement('button'); |
|
cancelButton.id = 'cancelButton'; |
|
cancelButton.innerHTML = 'Cancel<br>Script'; |
|
cancelButton.style.position = 'fixed'; |
|
cancelButton.style.bottom = '50%'; |
|
cancelButton.style.left = '0'; |
|
cancelButton.style.zIndex = '9999'; |
|
cancelButton.style.fontFamily = 'monospace'; |
|
cancelButton.style.padding = '3px'; |
|
cancelButton.style.border = 'dashed'; |
|
if (runScript) { |
|
cancelButton.style.color = 'white'; |
|
cancelButton.style.background = 'red'; |
|
cancelButton.style.borderColor = 'firebrick'; |
|
cancelButton.addEventListener('click', function() { |
|
localStorage.setItem('runScript', 'false'); |
|
localStorage.removeItem('savedUrl'); |
|
location.reload(); |
|
}); |
|
} else { |
|
cancelButton.style.color = 'gray'; |
|
cancelButton.style.background = 'lightgray'; |
|
cancelButton.style.borderColor = 'lightgray'; |
|
cancelButton.style.cursor = 'default'; |
|
} |
|
document.body.appendChild(cancelButton); |
|
|
|
if (!runScript) { |
|
// Create the main button to trigger the script |
|
const runButton = document.createElement('button'); |
|
runButton.textContent = 'Remove Duplicates'; |
|
runButton.style.position = 'relative'; |
|
runButton.style.display = 'block'; |
|
runButton.style.marginBottom = '5px'; |
|
runButton.style.fontFamily = 'sans-serif'; |
|
runButton.style.fontSize = 'initial'; |
|
runButton.style.border = 'outset'; |
|
runButton.style.borderColor = 'crimson'; |
|
runButton.style.color = 'white'; |
|
runButton.style.background = 'firebrick'; |
|
runButton.style.padding = '5px'; |
|
runButton.addEventListener('click', function () { |
|
let stopLoop = false; |
|
|
|
while (!stopLoop) { |
|
const inputStopAtPage = prompt("Stop at which page?", stopAtPage); |
|
if (inputStopAtPage === null) return; |
|
if (!/^\d+$/.test(inputStopAtPage)) { |
|
alert("Invalid input. Please enter a valid number."); |
|
continue; |
|
} |
|
if (inputStopAtPage > currentPage && !(currentPage === 0 && inputStopAtPage === 1)) { |
|
alert("Please enter a page less than or equal to your current page: " + currentPage); |
|
continue; |
|
} |
|
localStorage.setItem('stopAtPage', inputStopAtPage); |
|
stopLoop = true; |
|
} |
|
|
|
if (!rmOnlyRealDup) { |
|
stopLoop = false; |
|
while (!stopLoop) { |
|
const inputNumTracks = prompt("Enter the number of tracks to check for duplicates\n(between 2 and 50):", numTracks); |
|
if (inputNumTracks === null) return; |
|
if (!/^\d+$/.test(inputNumTracks) || inputNumTracks < 2 || inputNumTracks > 50) { |
|
alert("Invalid input. Please enter a valid number between 2 and 50."); |
|
continue; |
|
} |
|
localStorage.setItem('numTracks', inputNumTracks); |
|
stopLoop = true; |
|
} |
|
} |
|
|
|
localStorage.setItem('runScript', 'true'); |
|
location.reload(); |
|
}); |
|
|
|
// Create the checkbox element |
|
const rmOnlyRealDupCheckbox = document.createElement('input'); |
|
rmOnlyRealDupCheckbox.type = 'checkbox'; |
|
rmOnlyRealDupCheckbox.id = 'rmOnlyRealDupCheckbox'; |
|
rmOnlyRealDupCheckbox.style.position = 'relative'; |
|
rmOnlyRealDupCheckbox.style.marginLeft = '10px'; |
|
rmOnlyRealDupCheckbox.checked = rmOnlyRealDup; |
|
rmOnlyRealDupCheckbox.addEventListener('change', function () { |
|
localStorage.setItem('rmOnlyRealDup', rmOnlyRealDupCheckbox.checked); |
|
}); |
|
|
|
// Create the label for the checkbox |
|
const rmOnlyRealDupCheckboxLabel = document.createElement('label'); |
|
rmOnlyRealDupCheckboxLabel.style.position = 'relative'; |
|
rmOnlyRealDupCheckboxLabel.style.marginLeft = '5px'; |
|
rmOnlyRealDupCheckboxLabel.style.fontFamily = 'sans-serif'; |
|
rmOnlyRealDupCheckboxLabel.style.fontSize = 'medium'; |
|
rmOnlyRealDupCheckboxLabel.setAttribute('for', 'rmOnlyRealDupCheckbox'); |
|
rmOnlyRealDupCheckboxLabel.textContent = 'Only compare real duplicates?'; |
|
|
|
// Create a container element to hold the main button and checkbox |
|
const buttonContainer = document.createElement('div'); |
|
buttonContainer.id = 'buttonContainer'; |
|
buttonContainer.style.position = 'absolute'; |
|
buttonContainer.style.top = '370px'; |
|
buttonContainer.style.left = '50%'; |
|
buttonContainer.style.transform = 'translate(-50%, -50%)'; |
|
buttonContainer.appendChild(runButton); |
|
buttonContainer.appendChild(rmOnlyRealDupCheckboxLabel); |
|
buttonContainer.appendChild(rmOnlyRealDupCheckbox); |
|
const metaElement = document.querySelector('.content-top-has-nav'); |
|
metaElement.insertAdjacentElement('afterend', buttonContainer); |
|
} |
|
} |
|
|
|
// Automated duplicate scrobble deletion and navigation logic |
|
if (shouldMatchPage()) { |
|
window.addEventListener('load', function () { |
|
// Event listener for when the page has finished loading |
|
if (runScript) { |
|
let found = 0; |
|
const initialRefreshTimeout = 5000; // Interval for reloading the page |
|
let refreshTimeout = parseInt(localStorage.getItem('refreshTimeout')) || initialRefreshTimeout; |
|
const sections = Array.from(document.getElementsByTagName("tbody")); |
|
const savedUrl = localStorage.getItem('savedUrl') || window.location.href; |
|
|
|
// If the URL contains "delete", go back to last functioning site |
|
if (window.location.toString().includes("delete")) window.location.href = savedUrl; |
|
|
|
// If the current page doesn't contain tracks, go back to last functioning site (useful to detect rate-limiting page) |
|
if (!document.querySelector('.chartlist')) { |
|
setTimeout(function() { |
|
window.location.href = savedUrl; |
|
}, refreshTimeout); |
|
localStorage.setItem('refreshTimeout', refreshTimeout + 5000); |
|
return; |
|
|
|
} else { |
|
refreshTimeout = initialRefreshTimeout; |
|
localStorage.setItem('refreshTimeout', initialRefreshTimeout); |
|
localStorage.setItem('savedUrl', window.location.href); |
|
} |
|
|
|
sections.forEach(function (section) { |
|
// Loop through each section |
|
const els = Array.from(section.rows); |
|
const names = els.map(function (el) { |
|
const nmEl = el.querySelector('.chartlist-name'); |
|
const artEl = el.querySelector('.chartlist-artist'); |
|
if (rmOnlyRealDup) { |
|
const tstEl = el.querySelector('.chartlist-timestamp'); |
|
// Construct the name string including track name, artist and timestamp |
|
return nmEl && artEl && tstEl && nmEl.textContent.replace(/\s+/g, ' ').trim() + ':' + artEl.textContent.replace(/\s+/g, ' ').trim() + ':' + tstEl.textContent.replace(/\s+/g, ' ').trim(); |
|
} else { |
|
// Construct the name string including only track name and artist |
|
return nmEl && artEl && nmEl.textContent.replace(/\s+/g, ' ').trim() + ':' + artEl.textContent.replace(/\s+/g, ' ').trim(); |
|
} |
|
}); |
|
|
|
names.forEach(function (name, i) { |
|
// Loop through each name in the section |
|
if (!names.slice(i + 1, i + 1 + parseInt(numTracks)).includes(name)) return; |
|
// Check if the current name has duplicates within the specified range |
|
const delBtn = els[i].querySelector('[data-ajax-form-sets-state="deleted"]'); |
|
// If a delete button is found, click it and increment the counter |
|
if (delBtn) { delBtn.click(); found++; } |
|
}); |
|
}); |
|
|
|
if (found > 0) { |
|
// If duplicates were found, reload the page after refreshTimeout |
|
setTimeout(function() { |
|
location.reload(); |
|
}, refreshTimeout); |
|
return; |
|
} |
|
|
|
if (currentPage <= stopAtPage) { |
|
// Stop the Script and message user |
|
localStorage.setItem('runScript', 'false'); |
|
localStorage.removeItem('savedUrl'); |
|
alert("LastFM duplicate deletion complete."); |
|
location.reload(); |
|
} |
|
|
|
// Go up one page |
|
else window.location.href = window.location.pathname + replaceQueryParam('page', extractQueryParam('page', window.location.href) - 1, window.location.search); |
|
} |
|
}); |
|
} |
|
})(); |
Hey @RudeySH, thanks for your findings!
I can confirm the script's misbehavior. This issue only started occurring recently.
Unless you plan to monitor the entire process manually and stop/restart the script (which temporarily resets the problem), we need a fix to handle this.
Potential solutions:
Increase Delay Before Reloading:
Detect Text on the Page:
Also, I couldn’t find any clear documentation on LastFM’s rate-limiting rules, which makes this trickier.
If we had more information, it could help refine the script and avoid hitting the limit.
Something different I wanted to write down:
I've noticed an issue when deleting duplicates: sometimes the "405 - Method Not Allowed" page appears.
Currently, the method used to handle this is by navigating back to the previous page in the browser history.
However, this only works if the "405" page doesn't reappear consecutively, as it may cause the history to point to an incorrect page (e.g., a wrong library page).
To improve this, the method should be updated to store the active page's URL (or at least the page number) and return to this stored page whenever a "405" page is detected.
This would ensure that the script can always return to the last functioning page and continue its operation smoothly.
I'll work on fixing these issues over the following days and will update you via a comment to keep you informed.
Any further suggestions or ideas are welcome and greatly appreciated.