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.2.0
// @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
// @include      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
 * =============================
 */

// 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
 * =============================
 */

/**
 * Turn a HTML string into a HTML element so that we can run querySelector calls against it
 */
function htmlToElement(html) {
  var template = document.createElement('template');
  html = html.trim();
  template.innerHTML = html;
  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: 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) {
    console.log('[PTP Better Ratings Box]', '[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
 */
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: score,
        count: count,
        url: ratingUrl.toString(),
        display: 'Users',
        countType: 'vote'
      },
      critic: null,
    }
  }
  catch (e) {
    console.log('[PTP Better Ratings Box]', '[IMDb]', e);
    return null;
  }
}

/**
 * Process an IMDb page and turn into a JSON object of information
 */
function processImdb(page, data) {
  try {
    const userCell = page.querySelector('.ratingTable.Selected');
    const userScore = parseInt(parseFloat(userCell?.querySelector('.bigcell')?.textContent?.replace(',', '.')) * 10, 10) || null;
    const userCount = parseInt(userCell?.querySelector('.smallcell')?.textContent?.replace(/[^\d]+/g, ''), 10) || 0;
    const userUrl = new URL(data.url);
    userUrl.search += '?demo=imdb_users';

    const criticCell = page.querySelector('table + br + table .ratingTable.noLeftBorder')
    const criticScore = parseInt(parseFloat(criticCell?.querySelector('.bigcell')?.textContent?.replace(',', '.')) * 10, 10) || null;
    const criticCount = parseInt(criticCell?.querySelector('.smallcell')?.textContent?.replace(/[^\d]+/g, ''), 10) || 0;
    const criticUrl = new URL(data.url);
    criticUrl.search += '?demo=top_1000_voters';

    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: criticScore,
        count: criticCount,
        url: criticUrl.toString(),
        display: 'Top 1k',
        countType: 'vote'
      },
    }
  }
  catch (e) {
    console.log('[PTP Better Ratings Box]', '[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('.user_score_summary .metascore_w.user').innerText) * 10) || null;
    const userCount = parseInt(page.querySelector('.user_score_summary .based_on')?.innerText?.match(/\d+/), 10) || 0;
    const userUrl = new URL(data.url);
    userUrl.pathname += '/user-reviews';

    const criticScore = parseInt(page.querySelector('.ms_wrapper .metascore_w').innerText, 10) || null;
    const criticCount = parseInt(page.querySelector('.ms_wrapper .based_on')?.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) {
    console.log('[PTP Better Ratings Box]', '[Metacritic]', e);
    return null;
  }
}

/**
 * Process a Letterboxd page and turn into a JSON object of information
 */
async function processLetterboxd(page, data) {
  try {
    const firstResultLink = page.querySelector('.results > li a')?.href?.replace('https://passthepopcorn.me/', 'https://letterboxd.com/');
    if (!firstResultLink) return null;

    const moviePage = await query(firstResultLink);
    // make sure we're on a matching movie page
    const imdbLink = moviePage.querySelector('[data-track-action="IMDb"]')?.href;
    if (imdbLink.includes(data.imdbId) === false) {
      return null;
    }

    const membersPage = await query(`${firstResultLink}members`);

    const jsonString = moviePage.querySelector('script[type="application/ld+json"]').innerText.replace(/\/\*.*?\*\//g, '').trim();
    const json = JSON.parse(jsonString);

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

    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: firstResultLink,
      user: {
        score: userScore,
        count: userCount,
        url: `${firstResultLink}ratings`,
        display: 'Users',
        countType: 'vote'
      },
      custom: {
        numbers: [likes, fans],
        urls: [`${firstResultLink}likes`, `${firstResultLink}fans`],
        displays: ['Likes', 'Fans'],
      }
    }
  }
  catch (e) {
    console.log('[PTP Better Ratings Box]', '[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('score-details-json');
  try {
    const json = JSON.parse(jsonElem.textContent);
    const userUrl = new URL(data.url);
    userUrl.pathname += '/reviews?type=user';
    const criticUrl = new URL(data.url);
    criticUrl.pathname += '/reviews';

    return {
      name: 'Rotten Tomatoes',
      icon: getRottenTomatoesLogo(json.scoreboard.tomatometerState),
      url: data.url.toString(),
      user: {
        score: parseInt(json.scoreboard.audienceScore || 0, 10),
        count: json.scoreboard.audienceCount,
        url: userUrl.toString(),
        display: 'Users',
        countType: 'vote'
      },
      critic: {
        score: parseInt(json.scoreboard.tomatometerScore || 0, 10),
        count: json.scoreboard.tomatometerCount,
        url: criticUrl.toString(),
        display: 'Critics',
        countType: 'vote'
      },
      custom: {
        criticConsensus: page.querySelector('[data-qa="critics-consensus"]')?.textContent,
        criticConsensusIcon: getRottenTomatoesLogo(json.scoreboard.tomatometerState, false)
      }
    }
  }
  catch (e) {
    console.log('[PTP Better Ratings Box]', '[Rotten Tomatoes]', e);
    return null;
  }
}

/**
 * Get the Rotten Tomatoes fresh logo, depending on the movie status
 */
function getRottenTomatoesLogo(status, isLarge = true) {
  switch (status) {
    case 'certified-fresh': {
      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';
    }
    case 'rotten':
      return 'https://www.rottentomatoes.com/assets/pizza-pie/images/icons/tomatometer/tomatometer-rotten.f1ef4f02ce3.svg';
    case 'fresh':
      return 'https://www.rottentomatoes.com/assets/pizza-pie/images/icons/tomatometer/tomatometer-fresh.149b5e8adc3.svg';
    case 'empty':
    default:
      return 'https://www.rottentomatoes.com/assets/pizza-pie/images/icons/tomatometer/tomatometer-empty.cd930dab34a.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;' /></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:
      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.href;

      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/search/films/${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 => {
        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>
  <a target="_blank" class="rating" href="${url}" rel="noreferrer">
    <img src="${iconSrc}" style="height:48px; width:48px; ${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; ${isAvgCell ? 'border-left: 2px solid rgba(255, 255, 255, 0.20)' : ''}">
      <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><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>
          ${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) {
  const groupId = unsafeWindow.groupid;
  unsafeWindow.InitializeMoviePageVoting(groupId, personalRating);
}

// Main script runner
(async function () {
  'use strict';

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