RYM: Track Ratings Average when Rating

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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