SB100 / PTP Better Ratings Box

// ==UserScript==
// @namespace    https://openuserjs.org/users/SB100
// @name         PTP Better Ratings Box
// @description  Replace the current ratings box on movie pages with one that has more details in it
// @version      2.3.5
// @author       SB100
// @copyright    2021, SB100 (https://openuserjs.org/users/SB100)
// @updateURL    https://openuserjs.org/meta/SB100/PTP_Better_Ratings_Box.meta.js
// @license      MIT
// @grant        GM.xmlHttpRequest
// @match        https://passthepopcorn.me/torrents.php?id=*
// @connect      imdb.com
// @connect      metacritic.com
// @connect      rottentomatoes.com
// @connect      letterboxd.com
// ==/UserScript==

// ==OpenUserJS==
// @author SB100
// ==/OpenUserJS==

/* jshint esversion: 9 */

/**
 * =============================
 * ADVANCED OPTIONS
 * =============================
 */

// show debug logs in the browser console
const SETTING_DEBUG = true;

// Chose which providers you'd like to see, and in what order
// Valid options are: ['IMDb', 'Metacritic', 'Rotten Tomatoes', 'PTP', 'Letterboxd'];
const SETTING_RATING_PROVIDER_ORDER = [
  'IMDb',
  'Metacritic',
  'Rotten Tomatoes',
  'Letterboxd',
  'PTP',
];

// Should we show an average of all user / critic results? If so, what is the minimum number of providers that must be present before an average is shown? 0 to turn off
const SETTING_CRITIC_AVG_PROVIDER_NUM = 2;
const SETTING_USER_AVG_PROVIDER_NUM = 2;

// Which providers to include in the Average/Combined score. Note that this provider must have a % based rating (e.g. Letterboxd "critics" doesn't have % scores)
// For each provider, the number denotes how many votes must have been cast before it is counted towards the average
const SETTING_CRITIC_AVG_PROVIDER_INCLUDE = {
  IMDb: 1,
  Metacritic: 1,
  'Rotten Tomatoes': 1,
};
const SETTING_USER_AVG_PROVIDER_INCLUDE = {
  IMDb: 1,
  Metacritic: 1,
  'Rotten Tomatoes': 1,
  Letterboxd: 1,
  PTP: 1,
};

// If you have rotten tomatoes enabled above, this setting will also pull the critic consensus quote and display it below all the ratings
const SETTING_SHOW_RT_CRITIC_CONSENSUS = true;

// Greyscale filter to apply to PTP icon if you haven't rated the movie.
// 0% = disable
// 100% = Fully gray (default)
const SETTING_GRAYSCALE_UNRATED_PTP_FILMS = '100%';

// Allows you to set the color of the rating at different thresholds.
// add a "percent: color" entry to SETTING_THRESHOLDS to set custom colors
const SETTING_ENABLE_THRESHOLD = false;
const SETTING_THRESHOLDS = {
  0: '#ff0000',
  60: '#009000',
};

/**
 * =============================
 * END ADVANCED OPTIONS
 * DO NOT MODIFY BELOW THIS LINE
 * =============================
 */

/**
 * Print a debug message, if enabled
 */
function debug(strOrStrArray) {
  if (!SETTING_DEBUG) return;
  // eslint-disable-next-line no-console
  console.log(
    `[PTP Better Ratings Box] ${
      Array.isArray(strOrStrArray) ? strOrStrArray.join(' - ') : strOrStrArray
    }`
  );
}

/**
 * Turn a HTML string into a HTML element so that we can run querySelector calls against it
 */
function htmlToElement(html) {
  const template = document.createElement('template');
  template.innerHTML = html.trim();
  return template.content;
}

/**
 * Query a url for its HTML
 */
function query(url) {
  let resolver;
  let rejecter;
  const p = new Promise((resolveFn, rejectFn) => {
    resolver = resolveFn;
    rejecter = rejectFn;
  });

  GM.xmlHttpRequest({
    method: 'get',
    url,
    timeout: 10000,
    onloadstart: () => {},
    onload: (result) => {
      if (result.status !== 200) {
        rejecter(
          new Error(
            `[PTP Better Ratings Box] Error received from remote call: ${url}`
          )
        );
        return;
      }

      if (typeof result.response === 'undefined') {
        rejecter(
          new Error(`[PTP Better Ratings Box] No result received from ${url}`)
        );
        return;
      }

      resolver(htmlToElement(result.response));
    },
    onerror: (result) => {
      rejecter(result);
    },
    ontimeout: (result) => {
      rejecter(result);
    },
  });

  return p;
}

/**
 * Process a PTP page, and turn into a JSON object of information
 */
function processPtp(page, data) {
  try {
    const userRating = parseInt(
      page.getElementById('user_rating').textContent,
      10
    );
    const userCount = parseInt(
      page.getElementById('user_total').textContent,
      10
    );
    const ratingUrl = new URL(data.url);
    ratingUrl.search += '&action=ratings';

    const personalRating =
      parseInt(
        page
        .getElementById('ptp_your_rating')
        .textContent.replace(/[^\d]+/g, ''),
        10
      ) || null;

    return {
      name: 'PTP',
      icon: 'https://ptpimg.me/v1b634.png',
      url: data.url.toString(),
      user: {
        score: userRating,
        count: userCount,
        url: ratingUrl.toString(),
        display: 'Users',
        countType: 'vote',
      },
      critic: {
        score: personalRating,
        count: null,
        url: null,
        display: 'Personal',
        countType: 'vote',
      },
    };
  }
  catch (e) {
    debug(['[PTP]', e]);
    return null;
  }
}

/**
 * Process IMDb details from PTP and turn into a JSON object of information
 * @deprecated - we're now parsing from IMDb itself
 */
// eslint-disable-next-line no-unused-vars
function processImdbFromPTP(page, data) {
  try {
    const td =
      page.getElementById('imdb-title-link').parentNode.parentNode
      .nextElementSibling;
    const score = parseInt(
      parseFloat(td.querySelector('.rating').textContent) * 10,
      10
    );

    Array.from(td.querySelectorAll('span, br')).map((node) => node.remove());
    const count = parseInt(td.textContent.replace(/[^\d]+/g, ''), 10);

    const ratingUrl = new URL(data.url);
    ratingUrl.pathname += '/ratings';

    return {
      name: 'IMDb',
      icon: 'https://m.media-amazon.com/images/G/01/IMDb/brand/guidelines/imdb/IMDb_Logo_Square_Gold.png',
      url: data.url.toString(),
      user: {
        score,
        count,
        url: ratingUrl.toString(),
        display: 'Users',
        countType: 'vote',
      },
      critic: null,
    };
  }
  catch (e) {
    debug(['[IMDb]', e]);
    return null;
  }
}

/**
 * Process an IMDb page and turn into a JSON object of information
 */
function processImdb(page, data) {
  try {
    const json = JSON.parse(page.getElementById('__NEXT_DATA__').innerHTML);
    const userScore =
      json.props.pageProps.contentData.entityMetadata.ratingsSummary
      .aggregateRating * 10 || null;
    const userCount =
      json.props.pageProps.contentData.entityMetadata.ratingsSummary
      .voteCount || 0;
    const userUrl = new URL(data.url);
    userUrl.search += '?demo=imdb_users';

    return {
      name: 'IMDb',
      icon: 'https://m.media-amazon.com/images/G/01/IMDb/brand/guidelines/imdb/IMDb_Logo_Square_Gold.png',
      url: data.url.toString().replace(/ratings$/, ''),
      user: {
        score: userScore,
        count: userCount,
        url: userUrl.toString(),
        display: 'Users',
        countType: 'vote',
      },
      critic: {
        score: null,
        count: null,
        url: userUrl.toString(),
        display: 'Top 1k',
        countType: 'vote',
      },
    };
  }
  catch (e) {
    debug(['[IMDb]', e]);
    return null;
  }
}

/**
 * Process a Metacritic page, and turn into a JSON object of information
 */
function processMetacritic(page, data) {
  try {
    const userScore =
      parseInt(
        parseFloat(
          page.querySelector('.c-siteReviewScore_user span').innerText
        ) * 10,
        10
      ) || null;
    const userCount =
      parseInt(
        page
        .querySelectorAll('span.c-productScoreInfo_reviewsTotal span')?.[1]
        ?.innerText?.replace(/\D/g, ''),
        10
      ) || 0;
    const userUrl = new URL(data.url);
    userUrl.pathname += '/user-reviews';

    const criticScore =
      parseInt(
        page.querySelector('div.c-siteReviewScore[aria-label^="Metascore"]')
        .innerText,
        10
      ) || null;
    const criticCount =
      parseInt(
        page
        .querySelector('span.c-productScoreInfo_reviewsTotal span')
        ?.innerText?.match(/\d+/),
        10
      ) || 0;
    const criticUrl = new URL(data.url);
    criticUrl.pathname += '/critic-reviews';

    return {
      name: 'Metacritic',
      icon: 'https://www.metacritic.com/images/icons/metacritic-icon.svg',
      url: data.url.toString(),
      user: {
        score: userScore,
        count: userCount,
        url: userUrl.toString(),
        display: 'Users',
        countType: 'vote',
      },
      critic: {
        score: criticScore,
        count: criticCount,
        url: criticUrl.toString(),
        display: 'Critics',
        countType: 'vote',
      },
    };
  }
  catch (e) {
    debug(['[Metacritic]', e]);
    return null;
  }
}

/**
 * Process a Letterboxd page and turn into a JSON object of information
 */
async function processLetterboxd(page, data) {
  try {
    const jsonString = page
      .querySelector('script[type="application/ld+json"]')
      .innerText.replace(/\/\*.*?\*\//g, '')
      .trim();
    const json = JSON.parse(jsonString);

    const userScore = parseInt(json.aggregateRating.ratingValue * 20, 10); // * 20 to make it a %
    const userCount = json.aggregateRating.ratingCount;

    const membersPage = await query(`${json['@id']}members`);

    const likes =
      parseInt(
        membersPage
        .querySelector('.js-route-likes a')
        ?.title?.replace(/[^\d]/g, ''),
        10
      ) || 0;
    const fans =
      parseInt(
        membersPage
        .querySelector('.js-route-fans a')
        ?.title?.replace(/[^\d]/g, ''),
        10
      ) || 0;

    return {
      name: 'Letterboxd',
      icon: 'https://a.ltrbxd.com/logos/letterboxd-decal-dots-neg-rgb.svg',
      url: data.url.toString(),
      user: {
        score: userScore,
        count: userCount,
        url: `${json['@id']}ratings`,
        display: 'Users',
        countType: 'vote',
      },
      custom: {
        numbers: [likes, fans],
        urls: [`${json['@id']}likes`, `${json['@id']}fans`],
        displays: ['Likes', 'Fans'],
      },
    };
  }
  catch (e) {
    debug(['[Letterboxd]', e]);
    return null;
  }
}

/**
 * Process a Rotten Tomatoes page, and turn into a JSON object of information
 */
function processRottenTomatoes(page, data) {
  const jsonElem = page.getElementById('media-scorecard-json');
  try {
    const json = JSON.parse(jsonElem.textContent);
    const userUrl = new URL(data.url);
    userUrl.pathname += '/reviews';
    userUrl.search = 'type=user';
    const criticUrl = new URL(data.url);
    criticUrl.pathname += '/reviews';

    return {
      name: 'Rotten Tomatoes',
      // eslint-disable-next-line no-use-before-define
      icon: getRottenTomatoesLogo(
        parseInt(json.criticsScore.score, 10),
        json.criticsScore.certified
      ),
      url: data.url.toString(),
      user: {
        score: parseInt(json.audienceScore.score, 10),
        count: json.audienceScore.likedCount,
        url: userUrl.toString(),
        display: 'Users',
        countType: 'vote',
      },
      critic: {
        score: parseInt(json.criticsScore.score, 10),
        count: json.criticsScore.ratingCount,
        url: criticUrl.toString(),
        display: 'Critics',
        countType: 'vote',
      },
      custom: {
        criticConsensus: page.querySelector('#critics-consensus p')
          ?.textContent,
        // eslint-disable-next-line no-use-before-define
        criticConsensusIcon: getRottenTomatoesLogo(
          parseInt(json.criticsScore.score, 10),
          json.criticsScore.certified,
          false
        ),
      },
    };
  }
  catch (e) {
    debug(['[Rotten Tomatoes]', e]);
    return null;
  }
}

/**
 * Get the Rotten Tomatoes fresh logo, depending on the movie status
 */
function getRottenTomatoesLogo(criticScore, isCertified, isLarge = true) {
  if (Number.isNaN(criticScore)) {
    // empty
    return 'https://www.rottentomatoes.com/assets/pizza-pie/images/icons/tomatometer/tomatometer-empty.cd930dab34a.svg';
  }

  if (criticScore < 60) {
    // rotten
    return 'https://www.rottentomatoes.com/assets/pizza-pie/images/icons/tomatometer/tomatometer-rotten.f1ef4f02ce3.svg';
  }

  // fresh
  if (isCertified) {
    if (isLarge) {
      return 'https://www.rottentomatoes.com/assets/pizza-pie/images/icons/tomatometer/certified_fresh.75211285dbb.svg';
    }

    return 'https://www.rottentomatoes.com/assets/pizza-pie/images/icons/tomatometer/certified_fresh-notext.56a89734a59.svg';
  }

  return 'https://www.rottentomatoes.com/assets/pizza-pie/images/icons/tomatometer/tomatometer-fresh.149b5e8adc3.svg';
}

/**
 * Get the rotten tomatoes critic consensus text and icon
 */
function getRottenTomatoesCriticConsensus(details, numCells) {
  if (!SETTING_SHOW_RT_CRITIC_CONSENSUS) {
    return '';
  }

  let text;
  let icon;

  for (let i = 0, len = SETTING_RATING_PROVIDER_ORDER.length; i < len; i += 1) {
    const detail = details.find(
      (d) => d.name === SETTING_RATING_PROVIDER_ORDER[i]
    );
    if (!detail) {
      continue;
    }

    if (detail.name === 'Rotten Tomatoes') {
      text = detail.custom.criticConsensus;
      icon = detail.custom.criticConsensusIcon;
      break;
    }
  }

  if (!text || !icon) {
    return '';
  }

  return `<tr><td colspan='${numCells}' style='padding-top: 20px;'>
        <table style='max-width: 85%; margin: 0 auto;'>
            <tr>
                <td style='padding-right: 10px;'><img src='${icon}' style='height: 20px!important; width: 20px!important; margin:0; padding: 0;' /></td>
                <td style='font-size: 0.85em; opacity: 0.75;'><em>${text}</em></td>
            </tr>
        </table>
    </td></tr>`;
}

/**
 * Returns the provider depending on the domain found in the link
 */
function getProviderFromLink(link) {
  if (link.includes('imdb.com')) return 'imdb';
  if (link.includes('metacritic.com')) return 'metacritic';
  if (link.includes('rottentomatoes.com')) return 'rottentomatoes';
  return 'Unknown';
}

/**
 * Links a provider to a processing function
 */
function getProcessor(provider) {
  switch (provider) {
    case 'ptp':
      return processPtp;
    case 'imdb':
      return processImdb;
    case 'metacritic':
      return processMetacritic;
    case 'rottentomatoes':
      return processRottenTomatoes;
    case 'letterboxd':
      return processLetterboxd;
    default:
      return () => null;
  }
}

/**
 * Decides whether we should pass the PTP page to the processing function, or process the site directly
 */
function shouldProcessFromPTPMoviePage(provider) {
  switch (provider) {
    case 'ptp':
      return true;
    case 'imdb':
      return false;
    case 'metacritic':
      return false;
    case 'rottentomatoes':
      return false;
    case 'letterboxd':
      return false;
    default:
      return true;
  }
}

/**
 * Returns an array of obejcts for all the ratings links on a movie page
 */
function getLinks(ratingsTable, imdbId) {
  const results = Array.from(
      ratingsTable.querySelectorAll('a[target="_blank"]')
    )
    .map((elem) => {
      let {
        href
      } = elem;

      const provider = getProviderFromLink(href);
      if (!provider) return null;

      // for imdb, we want to parse the ratings page, not the main page
      if (provider === 'imdb') {
        href += 'ratings';
      }

      return {
        name: provider,
        url: new URL(href.replace(/(\?.*$|\/$)/, '')),
        processor: getProcessor(provider),
        shouldProcessFromPTPMoviePage: shouldProcessFromPTPMoviePage(provider),
      };
    })
    .filter((result) => result !== null)
    .concat({
      name: 'ptp',
      url: new URL(window.location.href),
      processor: getProcessor('ptp'),
      shouldProcessFromPTPMoviePage: shouldProcessFromPTPMoviePage('ptp'),
      imdbId,
    });

  if (imdbId) {
    results.push({
      name: 'letterboxd',
      url: new URL(`https://letterboxd.com/imdb/${imdbId}`),
      processor: getProcessor('letterboxd'),
      shouldProcessFromPTPMoviePage: shouldProcessFromPTPMoviePage('letterboxd'),
      imdbId,
    });
  }

  return results;
}

/**
 * Queries each link, processes the page returned, and turns each page into a JSON object of information
 */
async function getDetails(links) {
  const promises = links.map((data) => {
    if (data.shouldProcessFromPTPMoviePage) {
      return data.processor(document, data);
    }

    return (
      query(data.url.toString())
      .then((page) => data.processor(page, data))
      // log error and return null so we can filter it out below
      // This is to catch errors in `query` - the processors should catch their own and return null
      .catch((e) => {
        // eslint-disable-next-line no-console
        console.error(e);
        return null;
      })
    );
  });

  return Promise.all(promises).then((res) => res.filter((p) => p !== null));
}

/**
 * Gets the color the score should be depending on the thresholds defined in the settings
 */
function getThresholdColor(score) {
  if (!SETTING_ENABLE_THRESHOLD) {
    return 'inherit';
  }

  const threshold = Object.keys(SETTING_THRESHOLDS)
    .sort((a, b) => a - b)
    .reduce((result, curr) => {
      if (score >= curr) return curr;
      return result;
    });

  return SETTING_THRESHOLDS[threshold];
}

/**
 * Calculates the average score for the combined average cell
 */
function getAverageScore(details) {
  const settingUserProviders = Object.keys(SETTING_USER_AVG_PROVIDER_INCLUDE);
  const settingCriticProviders = Object.keys(
    SETTING_CRITIC_AVG_PROVIDER_INCLUDE
  );

  const providerUserDetails = [];
  const providerCriticDetails = [];
  for (let i = 0, len = SETTING_RATING_PROVIDER_ORDER.length; i < len; i += 1) {
    const providerName = SETTING_RATING_PROVIDER_ORDER[i];

    const detail = details.find((d) => d.name === providerName);
    if (!detail) {
      continue;
    }

    if (
      settingUserProviders.includes(detail.name) &&
      detail.user?.count !== null &&
      detail.user?.count >= SETTING_USER_AVG_PROVIDER_INCLUDE[providerName] &&
      detail.user?.score
    ) {
      providerUserDetails.push(detail);
    }

    if (
      settingCriticProviders.includes(detail.name) &&
      detail.critic?.count !== null &&
      detail.critic?.count >=
      SETTING_CRITIC_AVG_PROVIDER_INCLUDE[providerName] &&
      detail.critic?.score
    ) {
      providerCriticDetails.push(detail);
    }
  }

  const totalUser = providerUserDetails.reduce(
    (result, provider) => result + (provider?.user?.score || 0),
    0
  );
  const totalCritic = providerCriticDetails.reduce(
    (result, provider) => result + (provider?.critic?.score || 0),
    0
  );

  return {
    user: {
      score: (totalUser / providerUserDetails.length)
        .toFixed(2)
        .replace(/[.,]00$/, ''),
      count: providerUserDetails.length,
      url: '#',
      display: 'Users',
    },
    critic: {
      score: (totalCritic / providerCriticDetails.length)
        .toFixed(2)
        .replace(/[.,]00$/, ''),
      count: providerCriticDetails.length,
      url: '#',
      display: 'Critics',
    },
    custom: {
      userProviders: providerUserDetails.map((provider) => provider.name),
      criticProviders: providerCriticDetails.map((provider) => provider.name),
    },
  };
}

/**
 * Creates the logo for a provider
 */
function buildLogo(name, url, iconSrc, extraImgStyles = '') {
  return `<center style="text-align: center;">
  <a target="_blank" class="rating" href="${url}" rel="noreferrer">
    <img src="${iconSrc}" style="height:48px!important; width:48px!important; margin:0; padding:0; ${extraImgStyles}" title="${name} Reviews">
  </a>
</center>`;
}

/**
 * Builds the critic and user rating details
 */
function buildRatings(name, critic, user, custom) {
  if (name === 'PTP') {
    return `
        <span id="ptp_your_rating">${critic.display}: ${
      critic.score !== null
        ? `<span style="color: ${getThresholdColor(critic.score)}">${
            critic.score
          }%</span>`
        : `None`
    }</span>
        <br />(<a id="star0" href="#edit-vote">${
          critic.score !== null ? `Edit` : `Cast`
        } ${critic.countType}</a>)
        <br /><br />
        Users: ${
          user.score
            ? `<span style="color: ${getThresholdColor(user.score)}">${
                user.score
              }%</span>`
            : `None`
        }
        <br />(<a target="_blank" href="${user.url}">${
      user.count ? user.count.toLocaleString() : `No`
    } ${user.countType}${user.count === 1 ? `` : `s`}</a>)`;
  }

  if (name === 'Letterboxd') {
    return `
        ${custom.displays[0]}: ${
      custom.numbers[0]
        ? `<a target="_blank" rel="noopener noreferrer" href="${
            custom.urls[0]
          }">${custom.numbers[0].toLocaleString()}</a>`
        : `None`
    }<br />
        ${custom.displays[1]}: ${
      custom.numbers[1]
        ? `<a target="_blank" rel="noopener noreferrer" href="${
            custom.urls[1]
          }">${custom.numbers[1].toLocaleString()}</a>`
        : `None`
    }
        <br /><br />
        ${user.display}: ${
      user.score
        ? `<span style="color: ${getThresholdColor(user.score)}">${
            user.score
          }%</span>`
        : `None`
    }<br />
        (<a target="_blank" rel="noopener noreferrer" href="${user.url}">${
      user.count ? user.count.toLocaleString() : `No`
    } ${user.countType}${user.count === 1 ? `` : `s`}</a>)`;
  }

  if (name === 'Combined Average') {
    const userProviders = custom?.userProviders ?
      `From ${custom.userProviders.join(', ')}` :
      '';
    const criticProviders = custom?.criticProviders ?
      `From ${custom.criticProviders.join(', ')}` :
      '';
    return `
          ${
            critic && SETTING_CRITIC_AVG_PROVIDER_NUM !== 0
              ? `${critic.display}: ${
                  critic.score &&
                  critic.count >= SETTING_CRITIC_AVG_PROVIDER_NUM
                    ? `<span style="color: ${getThresholdColor(
                        critic.score
                      )}">${critic.score}%</span>`
                    : `None`
                }
          <br />(<a href="${critic.url}" title="${criticProviders}">${
                  critic.count >= SETTING_CRITIC_AVG_PROVIDER_NUM
                    ? `${critic.count.toLocaleString()} providers`
                    : `Not enough data`
                }</a>)
          <br /><br />
          `
              : `<br /><br /><br />`
          }
          ${
            user && SETTING_USER_AVG_PROVIDER_NUM !== 0
              ? `${user.display}: ${
                  user.score && user.count >= SETTING_USER_AVG_PROVIDER_NUM
                    ? `<span style="color: ${getThresholdColor(user.score)}">${
                        user.score
                      }%</span>`
                    : `None`
                }
          <br />(<a href="${user.url}" title="${userProviders}">${
                  user.count >= SETTING_USER_AVG_PROVIDER_NUM
                    ? `${user.count.toLocaleString()} providers`
                    : `Not enough data`
                }</a>)
          `
              : `<br /><br />`
          }
    `;
  }

  return `
      ${
        critic
          ? `${critic.display}: ${
              critic.score
                ? `<span style="color: ${getThresholdColor(critic.score)}">${
                    critic.score
                  }%</span>`
                : `None`
            }
      <br />(<a target="_blank" rel="noopener noreferrer" href="${
        critic.url
      }">${critic.count ? critic.count.toLocaleString() : `No`} ${
              critic.countType
            }${critic.count === 1 ? `` : `s`}</a>)
      <br /><br />
      `
          : ``
      }
      ${user.display}: ${
    user.score
      ? `<span style="color: ${getThresholdColor(user.score)}">${
          user.score
        }%</span>`
      : `None`
  }
      <br />(<a target="_blank" rel="noopener noreferrer" href="${user.url}">${
    user.count ? user.count.toLocaleString() : `No`
  } ${user.countType}${user.count === 1 ? `` : `s`}</a>)`;
}

/**
 * Creates the logo and ratings cell
 */
function buildCell(logo, ratings, isAvgCell = false) {
  return `<td style="width: auto; padding: 0; ${
    isAvgCell
      ? 'border-left: 2px solid rgba(255, 255, 255, 0.20); padding-left: 10px;'
      : ''
  }">
      <div style="height: 60px;">
        ${logo}
      </div>
      <div style="text-align: center;">
        <div style="display: inline-block; text-align: left;">
          ${ratings}
        </div>
      <div>
    </td>`;
}

/**
 * Builds the new rating box based on the information we processed
 */
function buildNewRatingsBox(details) {
  const table = document.createElement('div');
  table.style = 'padding: 10px 0;';

  const cells = [];
  for (let i = 0, len = SETTING_RATING_PROVIDER_ORDER.length; i < len; i += 1) {
    const detail = details.find(
      (d) => d.name === SETTING_RATING_PROVIDER_ORDER[i]
    );
    if (!detail) {
      continue;
    }

    const {
      name,
      url,
      icon,
      critic,
      user,
      custom
    } = detail;
    const logo = buildLogo(
      name,
      url,
      icon,
      name === 'PTP' && critic?.score === null ?
      `filter: grayscale(${SETTING_GRAYSCALE_UNRATED_PTP_FILMS});` :
      ``
    );
    const ratings = buildRatings(name, critic, user, custom);
    cells.push(buildCell(logo, ratings));
  }

  if (
    SETTING_CRITIC_AVG_PROVIDER_NUM !== 0 ||
    SETTING_USER_AVG_PROVIDER_NUM !== 0
  ) {
    // push the avg score in as well
    const avgScore = getAverageScore(details);
    if (avgScore.critic.count > 0 || avgScore.user.count > 0) {
      const logo = `<center style="text-align: center;"><div style="font-size: 60px; line-height: 0.75" title="Combined Average Reviews">✹</div></center>`;
      const ratings = buildRatings(
        'Combined Average',
        avgScore.critic,
        avgScore.user,
        avgScore.custom
      );
      cells.push(buildCell(logo, ratings, true));
    }
  }

  table.innerHTML = `<table id="movie-ratings-table" style="margin: auto; width: 100%; table-layout: fixed;">
      <tbody>
        <tr style="justify-content: space-evenly;">
          ${cells.join('')}
        </tr>
        ${getRottenTomatoesCriticConsensus(details, cells.length)}
      </tbody>
    </table>`;

  return table;
}

/**
 * Replaces the old rating box with a new rating box
 */
function replaceRatingsBox(oldTable, newTable) {
  oldTable.parentNode.replaceChild(newTable, oldTable);
}

/**
 * Reinitializes the PTP personal rating part of the page
 */
function reinitializeVoter(personalRating) {
  // eslint-disable-next-line no-undef
  const groupId = unsafeWindow.groupid;
  // eslint-disable-next-line no-undef
  unsafeWindow.InitializeMoviePageVoting(groupId, personalRating);
}

// Main script runner
(async function main() {
  // Make sure there is a rating table on the page
  const ratingsTable = document.getElementById('movie-ratings-table');
  if (!ratingsTable) {
    return;
  }

  // find the header, and set to "loading"
  const header = ratingsTable.parentNode.querySelector(
    '.panel__heading__title'
  );
  header.innerHTML =
    'Ratings <span style="font-weight: normal">(Loading Better Rating Box ...)</span>';

  // find the imdb link from the page
  const imdbId =
    document.getElementById('imdb-title-link')?.href?.match(/tt[\d]+/)?.[0] ||
    null;

  // get all the details from all the providers
  const links = getLinks(ratingsTable, imdbId);
  const details = await getDetails(links);
  const personalRating = details.find((d) => d.name === 'PTP').critic.score;

  // create a new ratings box, and replace the old one
  const newRatingTable = buildNewRatingsBox(details);
  replaceRatingsBox(ratingsTable, newRatingTable);

  // reinitialize the personal vote JS
  reinitializeVoter(personalRating);

  // Set the header back to normal
  header.innerHTML = 'Ratings';
})();