Greasy Fork

RYM: Track Ratings Average when Rating

Displays your track rating averages (simple and time-weighted) in the track rating UI on the release page.

// ==UserScript==
// @name        RYM: Track Ratings Average when Rating
// @match       https://rateyourmusic.com/release/*
// @version     1.0
// @namespace   https://github.com/fauu
// @author      fau
// @description Displays your track rating averages (simple and time-weighted) in the track rating UI on the release page.
// @license     MIT
// @grant       GM.addStyle
// @run-at      document-end
// ==/UserScript==
"use strict";

/* !!! ADVANCED OPTIONAL EXTRA FEATURE: Scaled score !!!

  An extra scaled score can be displayed along with the averages.

  How it's calculated:
    1. The RYM ratings (0.5-5.0) are replaced with corresponding predefined values.
       This is in order to support non-linear rating scales (so that e.g. 3.5->4.0 could
       be defined to constitute a larger jump in terms of the score than 3.0->3.5).
    2. The simple and time-weighted averages for the replacements are calculated.
    3. These averages are normalized to a 100-point scale using a predefined value for the
       equivalent of 100 points.


  To ENABLE this feature, paste the entire following block into the browser developer
  console (Ctrl+Shift+K in Firefox, Ctrl+Shift+J in Chrome) in any rateyourmusic.com tab:

    localStorage.setItem("trawr_config", `
    {
      "scaledEnabled": true,
      "scaledNorm100": 2.9,
      "scaledMap": [
        [0.5, 0],
        [1,   0.6],
        [1.5, 1.1],
        [2,   1.5],
        [2.5, 1.75],
        [3,   2],
        [3.5, 2.4],
        [4,   2.7],
        [4.5, 2.9],
        [5,   3.3]
      ]
    }
    `)

  , replacing the example parameters with the desired ones. Then press Enter and refresh
  the page.

  Parameters:
    `scaledEnabled`: (true/false) Whether to display the scaled score.
    `scaledNorm100`: (number) The value of the average that will translate to the score of
                     100.
    `scaledMap`: (list of pairs of numbers) A transformation map with the original RYM rating
                 values on the left (must not be modified) and the corresponding replacement
                 values for the score calculation on the right.


  To revert the change and DISABLE this feature, issue this command in the developer console:

    localStorage.removeItem("trawr_config")
*/

const avgsDecimals = 2;
const scoresDecimals = 0;
const scopeName = "trawr";
const configKey = `${scopeName}_config`;
const cssPrefix = scopeName;
const avgContainerClass = `${cssPrefix}_avg-container`;
const avgLabelClass = `${cssPrefix}_avg-label`;
const avgValueClass = `${cssPrefix}_avg-value`;
const hiddenClass = `${cssPrefix}_hidden`;

const css = `
#track_rating_status:is(.saved, .saving) > .${avgContainerClass} {
  margin-left: 1.05em;
}

.${avgLabelClass} {
  color: var(--mono-7);
}

.${avgValueClass} {
  font-family: monospace;
}

.${hiddenClass} {
  display: none;
}
`.trim();

function main() {
  let trackLengths = [];
  for (let el of document.querySelectorAll("#tracks > .track > .tracklist_line")) {
    const durationEl = el.querySelector(".tracklist_duration");
    if (!durationEl) {
      trackLengths = [];
      break;
    }
    const secs = parseInt(durationEl.dataset.inseconds);
    if (secs > 0) {
      trackLengths.push(secs);
    }
  }

  const myTrackRatingsEl = document.getElementById("my_track_ratings");
  if (!myTrackRatingsEl) return;
  const trackRatingsEl = myTrackRatingsEl.querySelector("#track_ratings");
  const trackRatingEls = Array.from(trackRatingsEl.children);
  const numTracks = trackRatingEls.length;
  if (!numTracks) return;

  GM.addStyle(css);

  const config = loadConfig();

  if (trackLengths.length !== numTracks) {
    trackLengths = [];
  }

  let avgContainerEl, avgValueEl;
  const observer = new MutationObserver((muts) => {
    let [sum, count, scaledSum, weightedSum, scaledWeightedSum, sumWeights] = [0, 0, 0, 0, 0, 0];
    const calcWeighted = trackLengths.length > 0;
    trackRatingEls.forEach((el, i) => {
      const rating = parseFloat(el.querySelector(".rating_num").textContent);
      if (Number.isNaN(rating)) return;
      sum += rating;
      const scaledRating = config.scaledEnabled && config.scaledMap.get(rating);
      if (config.scaledEnabled) {
        scaledSum += scaledRating;
      }
      count++;
      if (calcWeighted) {
        const weight = trackLengths[i];
        weightedSum += rating * weight;
        if (config.scaledEnabled) {
          scaledWeightedSum += scaledRating * weight;
        }
        sumWeights += weight;
      }
    });
    const avg = sum / count;
    const scaledAvg = config.scaledEnabled
      ? normScaled(scaledSum / count, config.scaledNorm100)
      : null;
    let weightedAvg, scaledWeightedAvg;
    if (calcWeighted) {
      weightedAvg = weightedSum / sumWeights;
      scaledWeightedAvg =
        config.scaledEnabled && normScaled(scaledWeightedSum / sumWeights, config.scaledNorm100);
    }

    if (Number.isNaN(avg)) {
      if (avgContainerEl) avgContainerEl.classList.add(hiddenClass);
      return;
    }

    if (!avgContainerEl) {
      avgContainerEl = document.createElement("div");
      avgContainerEl.classList.add(avgContainerClass);
      const avgLabelEl = document.createElement("span");
      avgLabelEl.innerHTML = "Average:  ";
      avgLabelEl.classList.add(avgLabelClass);
      avgContainerEl.append(avgLabelEl);
      avgValueEl = document.createElement("span");
      avgValueEl.classList.add(avgValueClass);
      avgContainerEl.append(avgValueEl);
      const statusEl = myTrackRatingsEl.querySelector("#track_rating_status");
      statusEl.append(avgContainerEl);
    }

    const baseScaledPart = config.scaledEnabled ? `/${scaledAvg.toFixed(scoresDecimals)}` : "";
    const basePart = avg.toFixed(avgsDecimals) + baseScaledPart;

    const weightedScaledPart =
      weightedAvg && config.scaledEnabled ? `/${scaledWeightedAvg.toFixed(scoresDecimals)}` : "";
    const weightedPart = weightedAvg
      ? ` (${weightedAvg.toFixed(avgsDecimals)}${weightedScaledPart} weighted)`
      : "";

    avgValueEl.textContent = basePart + weightedPart;

    avgContainerEl.classList.remove(hiddenClass);
  });

  observer.observe(trackRatingsEl, { childList: true, subtree: true });
}

function loadConfig() {
  const config = JSON.parse(localStorage.getItem(configKey) || "{}");
  config.scaledEnabled ||= false;
  config.scaledMap ||= [];
  config.scaledMap = new Map(config.scaledMap);
  config.scaledNorm100 ||= null;
  return config;
}

function normScaled(x, norm) {
  return (x / norm) * 100;
}

main();