SB100 / PTP Movie Awards

// ==UserScript==
// @name         PTP Movie Awards
// @namespace    https://openuserjs.org/users/SB100
// @description  Shows the awards a movie has won on movie pages
// @version      2.0.0
// @author       SB100
// @copyright    2024, SB100 (https://openuserjs.org/users/SB100)
// @updateURL    https://openuserjs.org/meta/SB100/PTP_Movie_Awards.meta.js
// @license      MIT
// @match        https://*passthepopcorn.me/torrents.php?*id=*
// @grant        GM.xmlHttpRequest
// @connect      imdb.com

// ==/UserScript==

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

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

// Which awards to show on a movie page. Copy headers exactly as is from the IMDb awards page
// e.g. https://www.imdb.com/title/tt10272386/awards/
const SETTING_AWARDS = [
  'Academy Awards, USA',
  'Golden Globes, USA',
  'BAFTA Awards',
];

/**
 * Force the award panel to always start in a specific state (opened or closed). Otherwise, the toggle state will be remembered, and used on other movie pages.
 * Allowed enums: 'none', 'open', 'close'
 */
const SETTING_FORCE_PANEL_DEFAULT_STATE = 'none';

/**
 * Choose whether the award name should be aligned to the left or right in the table. Default is right
 */
const SETTING_ALIGN_AWARD_NAME = 'right';

/**
 * Output console.log lines to the console
 */
const DEBUG_ENABLED = true;

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

const CONFIG_IMDB_AWARDS_URL = 'https://www.imdb.com/title/{imdbId}/awards';

/**
 * Show console.log lines if debug is enabled
 */
function debug(...str) {
  if (!DEBUG_ENABLED) {
    return;
  }

  // eslint-disable-next-line no-console
  console.log(...str);
}

/**
 * Insert a new node after an existing node
 */
function insertAfter(newNode, existingNode) {
  existingNode.parentNode.insertBefore(newNode, existingNode.nextSibling);
}

/**
 * Try parsing a string into JSON, otherwise fallback
 */
function JsonParseWithDefault(s, fallback = null) {
  try {
    return JSON.parse(s);
  }
  catch (e) {
    return fallback;
  }
}

/**
 * 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;
}

/**
 * Get all settings stored in localStorage for this script
 */
function getSettings() {
  const settings = window.localStorage.getItem('awardSettings');
  return JsonParseWithDefault(settings || {}, {});
}

/**
 * Set a setting into localStorage for this script
 */
function setSetting(name, value) {
  const json = getSettings();
  json[name] = value;
  window.localStorage.setItem('awardSettings', JSON.stringify(json));
}

/**
 * Get the default open state of the awards panel
 */
function getInitialOpenState() {
  if (SETTING_FORCE_PANEL_DEFAULT_STATE === 'open') return true;
  if (SETTING_FORCE_PANEL_DEFAULT_STATE === 'close') return false;

  const settings = getSettings();
  return !!settings.toggleOpen;
}

/**
 * Find the movies IMDb ID from the page
 */
function findImdbIdFromPage() {
  const elem = document.getElementById('imdb-title-link');
  const href = elem && elem.href;
  return href && href.match(/tt\d+/)[0];
}

/**
 * Query the IMDb awards page for any results a movie might have
 */
function queryAwardsPage(imdbId) {
  let resolver;
  let rejecter;
  const p = new Promise((resolveFn, rejectFn) => {
    resolver = resolveFn;
    rejecter = rejectFn;
  });

  const url = CONFIG_IMDB_AWARDS_URL.replace('{imdbId}', imdbId);

  GM.xmlHttpRequest({
    method: 'get',
    url,
    timeout: 10000,
    onloadstart: () => {},
    onload: (result) => {
      if (result.status !== 200) {
        debug('[PTP Movie Awards]', result);
        rejecter(new Error('Error received from IMDB'));
        return;
      }

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

  return p;
}

/**
 * Process the IMDb page into JSON
 */
function processResults(page) {
  try {
    const data = JsonParseWithDefault(
      page.querySelector('#__NEXT_DATA__')?.innerText, {
        props: {
          pageProps: {
            contentData: {
              categories: [],
            },
          },
        },
      }
    );

    const wanted = data.props.pageProps.contentData.categories
      .filter((info) => SETTING_AWARDS.includes(info.name))
      .map((info) => {
        const winnerAndNomineeInfo = info.section.items.reduce((acc, item) => {
          if (!Array.isArray(acc[item.rowTitle])) {
            acc[item.rowTitle] = [];
          }

          acc[item.rowTitle].push({
            catName: item.listContent[0].text,
            people: item.subListContent.map((content) => content.text),
          });

          return acc;
        }, {});

        return {
          name: info.name,
          data: winnerAndNomineeInfo,
        };
      });

    const summary = page
      .querySelector('[data-testid="awards-signpost"]')
      ?.innerText?.toLowerCase();

    return {
      total: data.props.pageProps.contentData.categories.length,
      summary,
      wanted,
    };
  }
  catch (e) {
    debug('[PTP Movie Awards]', '[Process Error]', e.message);
    return [];
  }
}

/**
 * Create the main table that holds all of the award data
 */
function createTable(results, summary, total) {
  const rows = (info) =>
    info
    .map(
      (obj) => `
          <tr>
            <td class="award__description-left ${
              SETTING_ALIGN_AWARD_NAME === 'right'
                ? ''
                : 'award__description-left--aligned-left'
            } award__description--overflow" title="${obj.catName}">
              <span class="award__description--dimmed">${obj.catName}</span>
            </td>
            <td class="award__description-right award__description--overflow" title="${obj.people.join(
              ', '
            )}">${obj.people.join(', ')}</td>
          </tr>
      `
    )
    .join('');

  const table = document.createElement('table');
  table.classList.add('table', 'table--bordered');

  const html = results
    .map(
      ({
        name,
        data
      }) => `<tr>
        <th colspan="2" class="award__name">${name}</th>
        ${Object.entries(data)
          .map(
            ([title, info]) => `
              <tr><td colspan="2" class="award__subheader">${title}</td></tr>
              ${rows(info)}
            `
          )
          .join('')}
      </tr>`
    )
    .join('');

  if (html === '') {
    table.innerHTML = `<tr><td colspan="2">${
      total > 0 ? `${total} results filtered out` : 'None'
    }</td></tr>`;
    return table;
  }

  table.innerHTML = `<tr><th colspan="2" class="award--center">${summary}</th></tr>${html}`;
  return table;
}

/**
 * Create the main panel body. If it is open, fill it with the award data table
 */
async function createPanelBody(panel) {
  if (panel.dataset.hasRun === '1') {
    return;
  }

  const body = panel.querySelector('.panel__body');
  if (!body) {
    debug('[PTP Movie Awards]', 'Could not find panel body');
  }

  body.innerHTML = `<table class='table table--bordered'><tr><td colspan="2">Loading …</td></tr></table>`;

  try {
    const imdbId = findImdbIdFromPage();
    const results = await queryAwardsPage(imdbId);
    const {
      total,
      summary,
      wanted
    } = processResults(results);

    const table = createTable(wanted, summary, total);
    body.innerHTML = '';

    // scroll and fade element. The class will be applied if the table is > 300px below
    const maxHeightContainer = document.createElement('div');
    maxHeightContainer.appendChild(table);

    body.appendChild(maxHeightContainer);

    if (table.offsetHeight >= 300) {
      maxHeightContainer.classList.add('award__fade-out');
    }
  }
  catch (e) {
    body.innerHTML = `<table class='table table--bordered'><tr><td colspan="2">Error: ${e.message}</td></tr></table>`;
    return;
  }

  // eslint-disable-next-line no-param-reassign
  panel.dataset.hasRun = 1;
}

/**
 * Create the main panel
 */
function createPanel() {
  const isOpen = getInitialOpenState();

  const panel = document.createElement('div');
  panel.id = 'panel__awards';
  panel.classList.add('panel');
  panel.innerHTML = `<div class="panel__heading">
    <span class="panel__heading__title">Awards</span>
    <a id="panel__awards-toggle" class="panel__heading__toggler" style="font-size: 0.9em;" title="Toggle" href="#awards">(${
      isOpen ? 'Hide' : 'Show'
    } awards)</a>
</div>
<div class="panel__body ${isOpen ? '' : 'hidden'}"></div>`;

  const castTable = document.querySelector(
    '.main-column > table.table:not(.torrent_table):not(#requests)'
  );
  if (!castTable) {
    debug('[PTP Movie Awards]', 'Could not find cast table');
    return;
  }

  insertAfter(panel, castTable);

  if (isOpen) {
    createPanelBody(panel);
  }

  const panelToggle = document.querySelector('#panel__awards-toggle');
  panelToggle.addEventListener('click', () => {
    const panelBody = document.querySelector('#panel__awards .panel__body');

    if (panelBody.classList.contains('hidden')) {
      panelBody.classList.remove('hidden');
      panelToggle.innerText = '(Hide awards)';
      setSetting('toggleOpen', true);
      createPanelBody(panel);
    }
    else {
      panelBody.classList.add('hidden');
      panelToggle.innerText = '(Show awards)';
      setSetting('toggleOpen', false);
    }
  });
}

/**
 * All the custom styling that powers the script
 */
function createStyleTag() {
  const css = `.award__fade-out {
    display: block;
    max-height: 300px;
    padding-bottom: 15px;
    overflow: hidden;
    -webkit-mask-image: linear-gradient(to bottom, black 90%, transparent 100%);
    mask-image: linear-gradient(to bottom, black 90%, transparent 100%);
}

.award__fade-out:hover {
    overflow-y: scroll;
}

#panel__awards .panel__body {
    padding:0;
}

.award--center {
    text-align: center;
}

.award__name {
    border-top: 1.5px dashed #CCC!important;
}

.award__description-left {
    width: 50%;
    text-align: right;
    border-right: solid thin #555;
}

.award__description-left--aligned-left {
    text-align: left;
}

.award__description-right {
    width: 50%;
}

.award__description--overflow {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 0;
}

.award__subheader {
    font-weight: bold;
}

.award__description--dimmed {
    filter: brightness(80%);
}`;

  const style = document.createElement('style');
  style.type = 'text/css';
  style.appendChild(document.createTextNode(css));

  document.head.appendChild(style);
}

// Main script runner
(function main() {
  createStyleTag();
  createPanel();
})();