- // ==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);
- })();