Visit a release page on rateyourmusic.com and scrobble the songs you see!
// ==UserScript== // @name scRYMble // @license MIT // @version 2.20250109031512 // @description Visit a release page on rateyourmusic.com and scrobble the songs you see! // @author fidwell // @icon https://e.snmc.io/2.5/img/sonemic.png // @namespace https://github.com/fidwell/scRYMble // @include https://rateyourmusic.com/release/* // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @require https://update.greasyfork.org/scripts/130/10066/Portable%20MD5%20Function.js // ==/UserScript== 'use strict'; class HttpResponse { constructor(raw) { this.status = raw.status; this.statusText = raw.statusText; this.responseText = raw.responseText; this.lines = raw.responseText.split("\n"); } get isOkStatus() { return this.lines[0] === "OK"; } get sessionId() { return this.lines[1]; } get nowPlayingUrl() { return this.lines[2]; } get submitUrl() { return this.lines[3]; } } function httpGet(url, onload) { GM_xmlhttpRequest({ method: "GET", url, headers: { "User-agent": "Mozilla/4.0 (compatible) Greasemonkey" }, onload: (responseRaw) => onload(new HttpResponse(responseRaw)) }); } function httpPost(url, data, onload) { GM_xmlhttpRequest({ method: "POST", url, data, headers: { "User-agent": "Mozilla/4.0 (compatible) Greasemonkey", "Content-type": "application/x-www-form-urlencoded" }, onload: (responseRaw) => onload(new HttpResponse(responseRaw)) }); } function fetch_unix_timestamp() { return parseInt(new Date().getTime().toString().substring(0, 10)); } function stripAndClean(input) { input = input .replace("&", "") .replace("\n", " "); while (input.indexOf(" ") >= 0) { input = input.replace(" ", " "); } while (input.startsWith(" - ")) { input = input.substring(3); } while (input.startsWith("- ")) { input = input.substring(2); } return input.trim(); } function handshake(ui, callback) { const username = ui.username; const password = ui.password; GM_setValue("user", username); GM_setValue("pass", password); const timestamp = fetch_unix_timestamp(); const auth = hex_md5(`${hex_md5(password)}${timestamp}`); const handshakeURL = `http://post.audioscrobbler.com/?hs=true&p=1.2&c=scr&v=1.0&u=${username}&t=${timestamp}&a=${auth}`; httpGet(handshakeURL, callback); } class rymUi { constructor() { this.albumTitleClass = ".album_title"; this.byArtistProperty = "byArtist"; this.creditedNameClass = "credited_name"; this.trackElementId = "tracks"; this.tracklistDurationClass = ".tracklist_duration"; this.tracklistLineClass = "tracklist_line"; this.tracklistNumClass = ".tracklist_num"; this.tracklistTitleClass = ".tracklist_title"; this.tracklistArtistClass = ".artist"; this.tracklistRenderedTextClass = ".rendered_text"; //#endregion } get isVariousArtists() { const artist = this.pageArtist; return artist.indexOf("Various Artists") > -1 || artist.indexOf(" / ") > -1; } get pageArtist() { var _a; return (_a = this.multipleByArtists) !== null && _a !== void 0 ? _a : this.singleByArtist; } get pageAlbum() { var _a, _b; // Not using innerText because it doesn't work with Jest tests. const element = document.querySelector(this.albumTitleClass); return ((_b = (_a = element.firstChild) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : "").trim(); } get multipleByArtists() { return Array.from(document.getElementsByClassName(this.creditedNameClass)) .map(x => x) .map(x => { var _a; return (_a = x.innerText) !== null && _a !== void 0 ? _a : ""; })[1]; } get singleByArtist() { return Array.from(document.querySelectorAll(`span[itemprop='${this.byArtistProperty}'] > a`)) .map(e => this.parseArtistLink(e)) .join(" / "); } parseArtistLink(element) { return Array.from(element.childNodes) .filter(node => node.nodeType === 3) // Node.TEXT_NODE .map(node => { var _a, _b; return (_b = (_a = node.textContent) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : ""; }) .join(""); } hasTrackNumber(tracklistLine) { var _a, _b; return ((_b = (_a = tracklistLine.querySelector(this.tracklistNumClass)) === null || _a === void 0 ? void 0 : _a.innerHTML) !== null && _b !== void 0 ? _b : "").trim().length > 0; } //#region Element getters get trackListDiv() { return document.getElementById(this.trackElementId); } get tracklistLines() { var _a; return Array.from((_a = this.trackListDiv.getElementsByClassName(this.tracklistLineClass)) !== null && _a !== void 0 ? _a : []) .map(l => l); } tracklistLine(checkbox) { var _a; return (_a = checkbox.parentElement) === null || _a === void 0 ? void 0 : _a.parentElement; } trackName(tracklistLine) { var _a, _b; let songTitle = ""; const songTags = tracklistLine === null || tracklistLine === void 0 ? void 0 : tracklistLine.querySelectorAll("[itemprop=name]"); if (songTags.length > 0) { const lastSongTag = songTags[songTags.length - 1]; songTitle = ((_a = lastSongTag === null || lastSongTag === void 0 ? void 0 : lastSongTag.textContent) !== null && _a !== void 0 ? _a : "").replace(/\n/g, " "); // Check if the tag is hiding any artist links; if so, strip them out const artistLinks = lastSongTag.querySelectorAll(this.tracklistArtistClass); if (artistLinks.length > 0) { const renderedTextSpan = lastSongTag.querySelector(this.tracklistRenderedTextClass); songTitle = renderedTextSpan.innerHTML.replace(/<a[^>]*>.*?<\/a>/g, " ").trim(); } } else { const renderedTextSpan = tracklistLine === null || tracklistLine === void 0 ? void 0 : tracklistLine.querySelector(this.tracklistRenderedTextClass); songTitle = (_b = renderedTextSpan === null || renderedTextSpan === void 0 ? void 0 : renderedTextSpan.textContent) !== null && _b !== void 0 ? _b : ""; } return stripAndClean(songTitle); } trackArtist(tracklistLine) { var _a, _b; const artistTags = tracklistLine === null || tracklistLine === void 0 ? void 0 : tracklistLine.querySelectorAll(this.tracklistArtistClass); if (artistTags.length === 0) return ""; if (artistTags.length === 1) { return (_a = artistTags[0].textContent) !== null && _a !== void 0 ? _a : ""; } // Multiple artists const entireSpan = tracklistLine.querySelector(this.tracklistTitleClass); const entireText = ((_b = entireSpan.textContent) !== null && _b !== void 0 ? _b : "").replace(/\n/g, " "); const dashIndex = entireText.indexOf(" - "); return entireText.substring(0, dashIndex); } trackDuration(tracklistLine) { var _a; const durationElement = tracklistLine === null || tracklistLine === void 0 ? void 0 : tracklistLine.querySelector(this.tracklistDurationClass); return ((_a = durationElement.textContent) !== null && _a !== void 0 ? _a : "").trim(); } } class scRYMbleUi { constructor(rymUi) { var _a, _b; this.enabled = false; this.marqueeId = "scrymblemarquee"; this.progBarId = "progbar"; this.scrobbleNowId = "scrobblenow"; this.scrobbleThenId = "scrobblethen"; this.testId = "scrobbletest"; this.checkboxClass = "scrymblechk"; this.selectAllOrNoneId = "allornone"; this.usernameId = "scrobbleusername"; this.passwordId = "scrobblepassword"; this._rymUi = rymUi; if (((_b = (_a = this._rymUi.trackListDiv) === null || _a === void 0 ? void 0 : _a.children.length) !== null && _b !== void 0 ? _b : 0) === 0) { console.log("scRYMble: No track list found."); } else { this.enabled = true; this.createCheckboxes(); this.createControls(); } } get isEnabled() { return this.enabled; } get username() { return this.usernameInput.value; } get password() { return this.passwordInput.value; } createCheckboxes() { const checkboxTemplate = `<input type="checkbox" class="${this.checkboxClass}" checked="checked">`; for (const tracklistLine of this._rymUi.tracklistLines) { if (this._rymUi.hasTrackNumber(tracklistLine)) { const thisCheckboxElement = document.createElement("span"); thisCheckboxElement.style.float = "left"; thisCheckboxElement.innerHTML = checkboxTemplate; tracklistLine.prepend(thisCheckboxElement); } } } createControls() { var _a; const eleButtonDiv = document.createElement("div"); eleButtonDiv.innerHTML = ` <table style="border: 0;" cellpadding="0" cellspacing="2px"> <tr> <td style="width: 112px;"> <input type="checkbox" name="${this.selectAllOrNoneId}" id="${this.selectAllOrNoneId}" style="vertical-align: middle;" checked="checked"> <label for="${this.selectAllOrNoneId}" style="font-size: 60%;">select all/none</label> <br/> <table border="2" cellpadding="0" cellspacing="0"> <tr> <td style="height: 50px; width: 103px; background: url(https://cdn.last.fm/flatness/logo_black.3.png) no-repeat; color: #fff;"> <div class="marquee" style="position: relative; top: 17px; overflow: hidden; white-space: nowrap;"> <span style="font-size: 80%; width: 88px; display: inline-block; animation: marquee 5s linear infinite;" id="${this.marqueeId}"> </span> </div> </td> </tr> <tr> <td style="background-color: #003;"> <div style="position: relative; background-color: #f00; width: 0; max-height: 5px; left: 0; top: 0;" id="${this.progBarId}"> </div> </td> </tr> </table> </td> <td>user: <input type="text" size="16" id="${this.usernameId}" value="${GM_getValue("user", "")}" /><br /> pass: <input type="password" size="16" id="${this.passwordId}" value="${GM_getValue("pass", "")}"></input><br /> <input type="button" id="${this.scrobbleNowId}" value="Scrobble in real-time" /> <input type="button" id="${this.scrobbleThenId}" value="Scrobble a previous play" /> <input type="button" id="${this.testId}" value="Test tracklist parsing" style="display: none;" /> </td> </tr> </table>`; eleButtonDiv.style.textAlign = "right"; (_a = this._rymUi.trackListDiv) === null || _a === void 0 ? void 0 : _a.after(eleButtonDiv); this.allOrNoneCheckbox.addEventListener("click", () => this.allOrNoneClick(), true); const marqueeStyle = document.createElement("style"); document.head.appendChild(marqueeStyle); marqueeStyle.textContent = ` @keyframes marquee { 0% { transform: translateX(100%); } 100% { transform: translateX(-100%); } }`; } hookUpScrobbleNow(startScrobble) { this.scrobbleNowButton.addEventListener("click", startScrobble, true); } hookUpScrobbleThen(handshakeBatch) { this.scrobbleThenButton.addEventListener("click", handshakeBatch, true); } hookUpScrobbleTest(callback) { this.scrobbleTestButton.addEventListener("click", callback, true); } setMarquee(value) { this.marquee.innerHTML = value; } setProgressBar(percentage) { if (percentage >= 0 && percentage <= 100) { this.progressBar.style.width = `${percentage}%`; } } allOrNoneClick() { window.setTimeout(() => this.allOrNoneAction(), 10); } allOrNoneAction() { for (const checkbox of this.checkboxes) { checkbox.checked = this.allOrNoneCheckbox.checked; } } elementsOnAndOff(state) { if (state) { this.scrobbleNowButton.removeAttribute("disabled"); this.usernameInput.removeAttribute("disabled"); this.passwordInput.removeAttribute("disabled"); } else { this.scrobbleNowButton.setAttribute("disabled", "disabled"); this.usernameInput.setAttribute("disabled", "disabled"); this.passwordInput.setAttribute("disabled", "disabled"); } for (const checkbox of this.checkboxes) { if (state) { checkbox.removeAttribute("disabled"); } else { checkbox.setAttribute("disabled", "disabled"); } } } elementsOff() { this.elementsOnAndOff(false); } elementsOn() { this.elementsOnAndOff(true); } //#region Element getters get allOrNoneCheckbox() { return document.getElementById(this.selectAllOrNoneId); } get scrobbleNowButton() { return document.getElementById(this.scrobbleNowId); } get scrobbleThenButton() { return document.getElementById(this.scrobbleThenId); } get scrobbleTestButton() { return document.getElementById(this.testId); } get marquee() { return document.getElementById(this.marqueeId); } get progressBar() { return document.getElementById(this.progBarId); } get usernameInput() { return document.getElementById(this.usernameId); } get passwordInput() { return document.getElementById(this.passwordId); } get checkboxes() { return document.getElementsByClassName(this.checkboxClass); } } class ScrobbleRecord { constructor(trackName, artist, duration) { this.artist = artist; this.trackName = trackName; const durastr = duration.trim(); const colon = durastr.indexOf(":"); if (colon !== -1) { const minutes = parseInt(durastr.substring(0, colon)); const seconds = parseInt(durastr.substring(colon + 1)); this.duration = minutes * 60 + seconds; } else { this.duration = 180; } this.time = 0; } } function buildListOfSongsToScrobble(_rymUi, _scRYMbleUi) { const toScrobble = []; Array.from(_scRYMbleUi.checkboxes).forEach(checkbox => { if (checkbox.checked) { toScrobble[toScrobble.length] = parseTracklistLine(_rymUi, checkbox); } }); return toScrobble; } function parseTracklistLine(rymUi, checkbox) { const tracklistLine = rymUi.tracklistLine(checkbox); const pageArtist = rymUi.pageArtist; let songTitle = rymUi.trackName(tracklistLine); let artist = pageArtist; const duration = rymUi.trackDuration(tracklistLine); if (rymUi.isVariousArtists) { artist = rymUi.trackArtist(tracklistLine); if (artist.length === 0) { artist = pageArtist.indexOf("Various Artists") > -1 ? rymUi.pageAlbum : pageArtist; // Probably a collaboration release, like a classical work. } } else { const trackArtist = rymUi.trackArtist(tracklistLine); if (trackArtist.length > 0) { artist = trackArtist; } } if (songTitle.toLowerCase() === "untitled" || songTitle.toLowerCase() === "untitled track" || songTitle === "") { songTitle = "[untitled]"; } return new ScrobbleRecord(songTitle, artist, duration); } const _rymUi = new rymUi(); const _scRYMbleUi = new scRYMbleUi(_rymUi); let toScrobble = []; let currentlyScrobbling = -1; let sessID = ""; let submitURL = ""; let npURL = ""; let currTrackDuration = 0; let currTrackPlayTime = 0; function confirmBrowseAway(oEvent) { if (currentlyScrobbling !== -1) { oEvent.preventDefault(); return "You are currently scrobbling a record. Leaving the page now will prevent future tracks from this release from scrobbling."; } return ""; } function acceptSubmitResponse(responseDetails, isBatch) { if (responseDetails.status === 200) { if (!responseDetails.isOkStatus) { alertSubmitFailed(responseDetails); } } else { alertSubmitFailed(responseDetails); } if (isBatch) { _scRYMbleUi.setMarquee("Scrobbled OK!"); } else { scrobbleNextSong(); } } function alertSubmitFailed(responseDetails) { alert(`Track submit failed: ${responseDetails.status} ${responseDetails.statusText}\n\nData:\n${responseDetails.responseText}`); } function acceptSubmitResponseSingle(responseDetails) { acceptSubmitResponse(responseDetails, false); } function acceptSubmitResponseBatch(responseDetails) { acceptSubmitResponse(responseDetails, true); } function acceptNPResponse(responseDetails) { if (responseDetails.status === 200) { if (!responseDetails.isOkStatus) { alertSubmitFailed(responseDetails); } } else { alertSubmitFailed(responseDetails); } } function submitTracksBatch(sessID, submitURL) { toScrobble = buildListOfSongsToScrobble(_rymUi, _scRYMbleUi); if (toScrobble === null) return; let currTime = fetch_unix_timestamp(); const hoursFudgeStr = prompt("How many hours ago did you listen to this?"); if (hoursFudgeStr !== null) { const album = _rymUi.pageAlbum; const hoursFudge = parseFloat(hoursFudgeStr); if (!isNaN(hoursFudge)) { currTime = currTime - hoursFudge * 60 * 60; } for (let i = toScrobble.length - 1; i >= 0; i--) { currTime = currTime * 1 - toScrobble[i].duration * 1; toScrobble[i].time = currTime; } let outstr = `Artist: ${_rymUi.pageArtist}\nAlbum: ${album}\n`; for (const song of toScrobble) { outstr = `${outstr}${song.trackName} (${song.duration})\n`; } const postdata = {}; for (let i = 0; i < toScrobble.length; i++) { postdata[`a[${i}]`] = toScrobble[i].artist; postdata[`t[${i}]`] = toScrobble[i].trackName; postdata[`b[${i}]`] = album; postdata[`n[${i}]`] = `${i + 1}`; postdata[`l[${i}]`] = `${toScrobble[i].duration}`; postdata[`i[${i}]`] = `${toScrobble[i].time}`; postdata[`o[${i}]`] = "P"; postdata[`r[${i}]`] = ""; postdata[`m[${i}]`] = ""; } postdata["s"] = sessID; const postdataStr = Object.entries(postdata) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join("&"); httpPost(submitURL, postdataStr, acceptSubmitResponseBatch); } } function startScrobble() { currentlyScrobbling = -1; currTrackDuration = 0; currTrackPlayTime = 0; _scRYMbleUi.elementsOff(); toScrobble = buildListOfSongsToScrobble(_rymUi, _scRYMbleUi); scrobbleNextSong(); } function resetScrobbler() { currentlyScrobbling = -1; currTrackDuration = 0; currTrackPlayTime = 0; _scRYMbleUi.setMarquee(" "); _scRYMbleUi.setProgressBar(0); toScrobble = []; _scRYMbleUi.elementsOn(); } function scrobbleNextSong() { currentlyScrobbling++; if (currentlyScrobbling === toScrobble.length) { resetScrobbler(); } else { window.setTimeout(timertick, 10); handshake(_scRYMbleUi, acceptHandshakeSingle); } } function submitThisTrack() { const postdata = {}; const i = 0; const currTime = fetch_unix_timestamp(); postdata[`a[${i}]`] = toScrobble[currentlyScrobbling].artist; postdata[`t[${i}]`] = toScrobble[currentlyScrobbling].trackName; postdata[`b[${i}]`] = _rymUi.pageAlbum; postdata[`n[${i}]`] = `${currentlyScrobbling + 1}`; postdata[`l[${i}]`] = `${toScrobble[currentlyScrobbling].duration}`; postdata[`i[${i}]`] = `${currTime - toScrobble[currentlyScrobbling].duration}`; postdata[`o[${i}]`] = "P"; postdata[`r[${i}]`] = ""; postdata[`m[${i}]`] = ""; postdata["s"] = sessID; const postdataStr = Object.entries(postdata) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join("&"); httpPost(submitURL, postdataStr, acceptSubmitResponseSingle); } function npNextTrack() { const postdata = {}; postdata["a"] = toScrobble[currentlyScrobbling].artist; postdata["t"] = toScrobble[currentlyScrobbling].trackName; postdata["b"] = _rymUi.pageAlbum; postdata["n"] = `${currentlyScrobbling + 1}`; postdata["l"] = `${toScrobble[currentlyScrobbling].duration}`; postdata["m"] = ""; postdata["s"] = sessID; currTrackDuration = toScrobble[currentlyScrobbling].duration; currTrackPlayTime = 0; _scRYMbleUi.setMarquee(toScrobble[currentlyScrobbling].trackName); const postdataStr = Object.entries(postdata) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join("&"); httpPost(npURL, postdataStr, acceptNPResponse); } function timertick() { let again = true; if (currentlyScrobbling !== -1) { if (currTrackDuration !== 0) { _scRYMbleUi.setProgressBar(100 * currTrackPlayTime / currTrackDuration); } currTrackPlayTime++; if (currTrackPlayTime === currTrackDuration) { submitThisTrack(); again = false; } } if (again) { window.setTimeout(timertick, 1000); } } function acceptHandshakeSingle(responseDetails) { acceptHandshake(responseDetails, false); } function acceptHandshakeBatch(responseDetails) { acceptHandshake(responseDetails, true); } function acceptHandshake(responseDetails, isBatch) { if (responseDetails.status === 200) { if (!responseDetails.isOkStatus) { alertHandshakeFailed(responseDetails); } else { sessID = responseDetails.sessionId; npURL = responseDetails.nowPlayingUrl; submitURL = responseDetails.submitUrl; if (isBatch) { submitTracksBatch(sessID, submitURL); } else { npNextTrack(); } } } else { alertHandshakeFailed(responseDetails); } } function alertHandshakeFailed(responseDetails) { alert(`Handshake failed: ${responseDetails.status} ${responseDetails.statusText}\n\nData:\n${responseDetails.responseText}`); } function handshakeBatch() { handshake(_scRYMbleUi, acceptHandshakeBatch); } function scrobbleTest() { console.log(_rymUi.pageAlbum); toScrobble = buildListOfSongsToScrobble(_rymUi, _scRYMbleUi); toScrobble.forEach((song, i) => { const minutes = Math.floor(song.duration / 60); const seconds = song.duration % 60; const secondsStr = `00${seconds}`.slice(-2); console.log(`${i + 1}. ${song.artist} — ${song.trackName} (${minutes}:${secondsStr})`); }); } (function () { if (!_scRYMbleUi.isEnabled) { return; } _scRYMbleUi.hookUpScrobbleNow(startScrobble); _scRYMbleUi.hookUpScrobbleThen(handshakeBatch); _scRYMbleUi.hookUpScrobbleTest(scrobbleTest); window.addEventListener("beforeunload", confirmBrowseAway, true); })();