Greasy Fork

scRYMble

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">&nbsp;
      <label for="${this.selectAllOrNoneId}" style="font-size: 60%;">select&nbsp;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}">&nbsp;</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}">&nbsp;</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("&nbsp;");
    _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);
})();