SB100 / PTP Movie Awards

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

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

/* jshint esversion: 6 */

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

// Which winning 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_TO_SHOW_WINNERS = [
  'Academy Awards, USA',
  'Golden Globes, USA',
  'BAFTA Awards',
];

// Which nominated 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_TO_SHOW_NOMINATED = [
  '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';

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

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

/**
 * 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) {
  var template = document.createElement('template');
  html = html.trim();
  template.innerHTML = html;
  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 ? true : false;
}

/**
 * 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: url,
    timeout: 10000,
    onloadstart: () => {},
    onload: (result) => {
      if (result.status !== 200) {
        console.log('[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;
}

/**
 * Get the summary header from the page "Showing all 5 wins and 10 nominations"
 */
function getSummary(page) {
  return page.querySelector('.header .nav .desc')?.innerText?.replace('Showing all ', '');
}

/**
 * Process the IMDb page into JSON
 */
function processResults(page) {
  const elems = Array.from(page.querySelectorAll('.article .header + h3, .article br + h3, .article h3 + .awards'));

  let lastTitle = null;

  return elems.reduce((result, elem) => {
    if (elem.nodeName === 'H3') {
      // match the name and the year from the h3 title
      const matches = elem.innerText.replace(/[\r\n]+/g, '').match(/([A-Za-zÀ-ÖØ-öø-ÿ\s\.',:;\(\)]+?)([\d]{4,4})/i);
      if (!matches) {
        // if there are no matches, we can't process the following table, so set lastTitle to null (will cause next table to be skipped)
        lastTitle = null;
        return result;
      }

      const sanitizedName = matches[1].trim();
      lastTitle = sanitizedName;
      const year = parseInt(matches[2], 10);
      const link = elem.innerHTML.match(/\"(\/event(?:[^\?]+))/i)?.[1];

      // create an object holding the year info. We'll populate nominee and winner data in the next if block
      if (Object.prototype.hasOwnProperty.call(result, sanitizedName) === false) {
        result[sanitizedName] = {
          year,
          link,
          winner: [],
          nominee: []
        };
      }
    }

    else if (elem.nodeName === 'TABLE') {
      // make sure we processed a title just before this
      if (lastTitle === null) {
        return result;
      }

      // we'll be looping through rows and processing cells. This var will hold the row grouping so we can add each cell's results to it
      let lastOutcome = null;
      // sometimes the award type doesn't have a header in the second column. In which case, this var will hold the title from the first as a backup
      let lastAwardCategory = null;
      const tds = Array.from(elem.querySelectorAll('td'));
      for (let i = 0, len = tds.length; i < len; i += 1) {
        const td = tds[i];

        if (td.classList.contains('title_award_outcome')) {
          // save the outcome name to a variable so we can populate it with the results from the next loop
          lastOutcome = td.querySelector('b').innerText.toLowerCase().trim();
          // save the award category as a backup incase there is no description in the next column
          lastAwardCategory = td.querySelector('.award_category')?.innerText?.trim();
          // create an empty award and (winner/nominee) array
          result[lastTitle][lastOutcome] = [];
        }
        else if (lastOutcome !== null) {
          // if some note exists in the cell, remove it.
          const notes = td.querySelector('.award_detail_notes');
          notes && notes.remove();

          // make sure we have a name. If so, the follow cells contain the award description and people
          // match everything before the first html element
          const descriptionMatch = td.innerHTML.match(/^([^\<]+)/i);
          // match the people for this specific description
          // match all <a> tags as all names are hyperlinked
          const people = [...td.innerHTML.matchAll(/<a(?:[^\>]+)>([^\<]+)/ig)].map(matches => matches[1]);

          // add to result
          result[lastTitle][lastOutcome].push({
            description: descriptionMatch[1].trim() || lastAwardCategory,
            people
          });
        }
      }
    }

    return result;
  }, {});
}

/**
 * Filter the awards down to only the ones we want to see, as defined in the settings
 */
function filterResults(results) {
  const uniqueAwards = [...new Set([...SETTING_AWARDS_TO_SHOW_WINNERS, ...SETTING_AWARDS_TO_SHOW_NOMINATED])];
  return Object.entries(results).reduce((result, [name, obj]) => {
    // remove entries we're not interested in
    if (uniqueAwards.includes(name) === false) {
      return result;
    }

    // remove winners we're not interested in
    if (SETTING_AWARDS_TO_SHOW_WINNERS.includes(name) === false) obj.winner = [];

    // remove nominees we're not interested in
    if (SETTING_AWARDS_TO_SHOW_NOMINATED.includes(name) === false) obj.nominee = [];

    // add to new result and return
    result[name] = obj;
    return result;
  }, {});
}

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

  // build a fake summary object so that we can use the rows function above.
  const fakeSummaryObj = [{
    description: 'Summary',
    people: [summary]
  }]

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

  let html = summary ? rows(fakeSummaryObj) : '';
  Object.entries(results).forEach(([name, obj], idx) => {
    const hasWinner = obj.winner.length > 0;
    const hasNominee = obj.nominee.length > 0;
    const hasEither = hasWinner || hasNominee;
    if (!hasEither) {
      return;
    }

    html += `<tr>
          <th colspan="2" ${idx !== 0 ? 'class="award__name"' : ''}>
            ${name}
            <a href="${obj.link ? `https://www.imdb.com${obj.link}` : '#unknown'}" class="award__year" target="_blank" rel="noopener noreferrer">${obj.year}</a>
          </th>
        </tr>
  ${hasWinner ? `<tr><td colspan="2" class="award__subheader">Winner</td></tr>` : ''}
  ${hasWinner ? rows(obj.winner) : ''}
  ${hasNominee ? `<tr><td colspan="2" class="award__subheader">Nominated</td></tr>` : ''}
  ${hasNominee ? rows(obj.nominee) : ''}`;
  });

  if (html === '') {
    html += '<tr><td colspan="2">None</td></tr>'
  }

  table.innerHTML = 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) {
    console.log('[PTP Movie Awards]', 'Could not find panel body');
  }

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

  const imdbId = findImdbIdFromPage();
  const results = await queryAwardsPage(imdbId);
  const processed = processResults(results);
  const filtered = filterResults(processed);

  const summary = getSummary(results);

  try {
    const table = createTable(filtered, summary);
    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;
  }

  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) {
    console.log('[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__name {
    border-top: 1px solid #CCC!important;
}

.award__year {
    font-size: 0.8em;
    font-weight: normal;
    padding-left: 5px;
}

.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 () {
  'use strict';

  createStyleTag();
  createPanel();
})();