SB100 / PTP Streaming Links

// ==UserScript==
// @name         PTP Streaming Links
// @namespace    https://openuserjs.org/users/SB100
// @description  Show streaming links on movie pages
// @version      4.0.0
// @author       SB100
// @copyright    2024, SB100 (https://openuserjs.org/users/SB100)
// @updateURL    https://openuserjs.org/meta/SB100/PTP_Streaming_Links.meta.js
// @license      MIT
// @include      https://*passthepopcorn.me/torrents.php?*id=*
// @include      https://*passthepopcorn.me/requests.php?*action=view*id=*
// @include      https://*passthepopcorn.me/requests.php?*action=new
// @grant        GM.xmlHttpRequest
// @connect      justwatch.com

// ==/UserScript==

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

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

/**
 * A map of PTP Languages to JustWatch locales.
 */
const LANGUAGE_MAP = {
  'English US': 'en_US',

  Arabic: 'ar_EG',
  Bulgarian: 'bg_BG',
  'English Australia': 'en_AU',
  'English Canadian': 'en_CA',
  'English Irish': 'en_IE',
  'English New Zealand': 'en_NZ',
  'English Philippines': 'en_PH',
  'English Singapore': 'en_SG',
  'English South Africa': 'en_ZA',
  'English UK': 'en_GB',
  'Chinese Hong Kong': 'zh_HK',
  'Chinese Taiwan': 'zh_TW',
  Czech: 'cs_CZ',
  Estonian: 'et_EE',
  Finnish: 'fi_FI',
  French: 'fr_FR',
  German: 'de_DE',
  Greek: 'el_GR',
  Hindi: 'en_IN',
  Hungarian: 'hu_HU',
  Icelandic: 'is_IS',
  Italian: 'it_IT',
  Japanese: 'ja_JP',
  Korean: 'ko_KR',
  Latvian: 'lv_LV',
  Lithuanian: 'lt_LT',
  Polish: 'pl_PL',
  Portuguese: 'pt_PT',
  'Portuguese Brazil': 'pt_BR',
  Romanian: 'ro_RO',
  Russian: 'ru_RU',
  Slovak: 'sk_SK',
  Spanish: 'es_ES',
  'Spanish Argentina': 'es_AR',
  'Spanish Chile': 'es_CL',
  'Spanish Columbia': 'es_CO',
  'Spanish Costa Rica': 'es_CR',
  'Spanish Ecuador': 'es_EC',
  'Spanish Mexico': 'es_MX',
  'Spanish Peru': 'es_PE',
  'Spanish Venezuela': 'es_VE',
  Swedish: 'sv_SE',
  Turkish: 'tr_TR',
};

/**
 * Error messages to show when a version is missing from one of the categories
 */
const TEXT_NO_LINK_FOUND = {
  STREAM: 'No links found',
  RENT: 'No links found',
  BUY: 'No links found',
};

/**
 * Force the streaming 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';

// How long to cache per-locale providers for. 24 hours is the default.
const SETTING_CACHE_TIME = 1000 * 60 * 60 * 24;

// Print debug lines out to the dev console
const SETTING_DEBUG = false;

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

const URL_API_BASE = 'https://apis.justwatch.com';
const URL_IMG_BASE = 'https://images.justwatch.com';

// ================================= GraphQL

const GRAPHQL_ITEM_FIND = `
query GetSuggestedTitles($country: Country!, $language: Language!, $first: Int!, $filter: TitleFilter) {
    popularTitles(country: $country, first: $first, filter: $filter) {
        edges {
            node {
                ...SuggestedTitle
                __typename
            }
            __typename
        }
        __typename
    }
}

fragment SuggestedTitle on MovieOrShow {
    id
    objectType
    content(country: $country, language: $language) {
        fullPath
        title
        originalReleaseYear
        externalIds {
          imdbId
        }
        __typename
    }
    offers(country: $country, platform: WEB) {
        monetizationType
        presentationType
        standardWebURL
        retailPrice(language: $language)
        retailPriceValue
        lastChangeRetailPriceValue
        currency
        package {
            id
            packageId
            __typename
        }
        id
        __typename
    }
    __typename
}`;

// ================================= Helpers

/**
 * Print a debug line out to the dev console, if the setting is enabled
 */
function debug(...strOrStrArr) {
  if (!SETTING_DEBUG) {
    return;
  }

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

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

/**
 * Create a full icon url from JustWatch
 */
function makeIconUrl(providerId, providers) {
  const iconUrl = providers[providerId] && providers[providerId].icon;
  return `${URL_IMG_BASE}${iconUrl}`.replace('{profile}', 's100');
}

/**
 * Turn a monetization type into a short string to show on PTP
 */
function monetizationToShort(monetization) {
  switch (monetization.toLowerCase()) {
    case 'flatrate':
      return 'Sub';
    case 'free':
      return 'Free';
    case 'ads':
      return 'Ads';
    case 'rent':
      return 'Rent';
    case 'buy':
      return 'Buy';
    case 'flatrate_and_buy':
      return 'Sub ($)';
    case 'cinema':
      return 'Film';
    default:
      return '???';
  }
}

/**
 * Return a function to filter offers to the viewType we want
 * @param viewType 'STREAM' | 'RENT' | 'BUY'
 */
function getOfferFilterFn(viewType) {
  if (['STREAM', 'RENT', 'BUY'].includes(viewType) === false) {
    throw new Error('Invalid viewType passed into getOfferFilterFn');
  }

  return {
    STREAM: (justWatchOffer) => ['flatrate', 'ads', 'free'].includes(
      justWatchOffer.monetizationType.toLowerCase()
    ),
    RENT: (justWatchOffer) => ['rent'].includes(justWatchOffer.monetizationType.toLowerCase()),
    BUY: (justWatchOffer) => ['buy', 'flatrate_and_buy'].includes(
      justWatchOffer.monetizationType.toLowerCase()
    ),
  } [viewType];
}

// ================================= Local Storage

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

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

/**
 * Reads the cache object from local storage, or returns a default object
 */
function getImdbCache() {
  try {
    return JsonParseWithDefault(
      window.localStorage.getItem('streamingLinkCache'), {}
    );
  }
  catch (e) {
    return {};
  }
}

/**
 * Reads the cache for a specific imdb entry
 */
function getCacheForImdb(imdbId) {
  const cache = getImdbCache();
  return cache?.[imdbId] || {};
}

/**
 * Writes an imdb entry to the cache
 */
function setCacheForImdb(imdbId, localeFullPathMap) {
  const cache = getImdbCache() || {};
  if (!cache[imdbId]) {
    cache[imdbId] = {};
  }

  cache[imdbId] = localeFullPathMap;
  window.localStorage.setItem('streamingLinkCache', JSON.stringify(cache));
}

/**
 * Remove a locale for an imdb entry
 */
function removeLocaleForImdb(imdbId, locale) {
  const cache = getImdbCache();
  const allLocales = cache?.[imdbId];

  // no item set in cache
  if (!allLocales) {
    return;
  }

  const newLocales = Object.fromEntries(
    Object.entries(allLocales).filter(([key]) => key.includes(locale) === false)
  );

  setCacheForImdb(imdbId, newLocales);
}

/**
 * Get the default open state of the streaming 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;
}

// ================================= Query

/**
 * Generic way to query an endpoint
 */
function query(base, path, method = 'get', params = {}) {
  let resolver;
  let rejecter;
  const p = new Promise((resolveFn, rejectFn) => {
    resolver = resolveFn;
    rejecter = rejectFn;
  });

  const url = new URL(`${base}${path}`);
  const obj = {
    method,
    timeout: 10000,
    onloadstart: () => {},
    onload: (response) => resolver(response),
    onerror: (response) => rejecter(response),
    ontimeout: (response) => rejecter(response),
  };

  if (method === 'post') {
    const headers =
      base === URL_API_BASE ?
      {
        'Content-Type': 'application/json',
      } :
      {};

    const final = Object.assign(obj, {
      url: url.toString(),
      headers,
      data: JSON.stringify(params),
    });

    GM.xmlHttpRequest(final);
  }
  else {
    url.search = new URLSearchParams(params).toString();

    const final = Object.assign(obj, {
      url: url.toString(),
    });

    GM.xmlHttpRequest(final);
  }

  return p;
}

/**
 * GraphQL call for a title in a locale, making sure it matches an imdbId entry
 */
async function queryFind(itemTitle, year, imdbId, locale) {
  const [language, country] = locale.split('_');

  debug(`[${itemTitle}]`, 'Searching for item on JustWatch', `(${locale})`);

  const response = await query(URL_API_BASE, '/graphql', 'post', {
    operationName: 'GetSuggestedTitles',
    query: GRAPHQL_ITEM_FIND,
    variables: {
      country,
      filter: {
        searchQuery: itemTitle,
      },
      first: 4,
      language,
    },
  });
  if (response.status !== 200) {
    debug(`[${itemTitle}]`, 'Non 200 status returned', response);
    return null;
  }

  try {
    const jsonResults = JSON.parse(response.response);
    debug(`[${itemTitle}]`, 'JSON Results', jsonResults);

    // filter to the matching imdbId, or name/year match
    const filtered = jsonResults.data.popularTitles.edges.filter((edge) => {
      if (edge.node.content.externalIds.imdbId) {
        return (
          edge.node.objectType === 'MOVIE' &&
          edge.node.content.externalIds.imdbId === imdbId
        );
      }

      return (
        edge.node.objectType === 'MOVIE' &&
        edge.node.content.title === itemTitle &&
        edge.node.content.originalReleaseYear === year
      );
    });

    // ensure we only have 1 entry
    if (filtered.length !== 1) {
      debug(
        `[${itemTitle}]`,
        'No relevant results found:',
        `(${filtered.length})`
      );
      return null;
    }

    debug(`[${itemTitle}]`, 'Found relevant item');

    return filtered[0].node;
  }
  catch (e) {
    debug(`[${itemTitle}]`, 'JSON.parse error', e);
    return null;
  }
}

/**
 * Obtains all locales and hrefs a movie is in (populates the dropdown)
 */
async function getLocalesAndHrefsForItem(fullPath, itemTitle, imdbId) {
  const response = await query(URL_API_BASE, `/content/urls`, 'get', {
    include_children: true,
    path: fullPath,
  });
  if (response.status !== 200) {
    return [];
  }

  const json = JSON.parse(response.response);
  const hrefs = json.href_lang_tags;
  const mappings = hrefs.reduce((result, item) => {
    // eslint-disable-next-line no-param-reassign
    result[item.locale] = item.href;
    return result;
  }, {});
  setCacheForImdb(imdbId, mappings);

  return mappings;
}

/**
 * Loops through defined locales, looking for an itemTitle, verifying by imdbId
 * Locales could either be all the locales we parsed from PTP, or a single locale (as a single entry in an array) that the user selected from the locale dropdown
 */
async function queryItem(itemTitle, year, imdbId, locales) {
  let foundLocale = null;

  for (let i = 0, len = locales.length; i < len; i += 1) {
    const locale = locales[i];
    debug(`[${itemTitle}]`, `Checking Locale: ${locale}`);

    // reset found locale if we're in second iteration
    // first may have found item, but had no offers in it
    foundLocale = null;

    const foundItem = await queryFind(itemTitle, year, imdbId, locale);
    if (foundItem === null) {
      continue;
    }

    foundLocale = locale;
    const itemCache = getCacheForImdb(imdbId, locales);

    if (!itemCache?.[locale]) {
      debug(`[${itemTitle}]`, 'Updating cache for locale', locale);
      setCacheForImdb(imdbId, {
        [locale]: foundItem.content.fullPath
      });
      await getLocalesAndHrefsForItem(
        foundItem.content.fullPath,
        itemTitle,
        imdbId
      );
    }

    if (foundItem?.offers?.length) {
      debug(`[${itemTitle}]`, 'Offers found for locale:', locale);
      return {
        foundItem,
        locale,
      };
    }

    debug(`[${itemTitle}]`, 'No offers found for locale:', locale);
  }

  return {
    foundItem: null,
    locale: foundLocale,
  };
}

/**
 * Get all providers defined in a locale
 */
async function queryProviders(locale) {
  const result = await query(
    URL_API_BASE,
    `/content/providers/locale/${locale}`
  );
  if (result.status !== 200) {
    return null;
  }

  const json = JSON.parse(result.response);
  return json.reduce((res, provider) => {
    res[provider.id] = {
      name: provider.clear_name,
      icon: provider.icon_url,
    };
    return res;
  }, {});
}

/**
 * Cache / Retrieve the list of Providers from the JustWatch API
 * Cache time is controlled by the CACHE_TIME variable at the top of the script
 */
async function getProviders(locale) {
  const sortedKeys = Object.keys(window.localStorage)
    .filter((key) => key.startsWith(`justWatchProviders-${locale}-`))
    .sort((a, b) => {
      const aTime = parseInt(
        a.replace(`justWatchProviders-${locale}-`, ''),
        10
      );
      const bTime = parseInt(
        b.replace(`justWatchProviders-${locale}-`, ''),
        10
      );
      return aTime - bTime;
    });

  for (
    let i = 0, sortedKeysLength = sortedKeys.length; i < sortedKeysLength; i += 1
  ) {
    const key = sortedKeys[i];
    const keyTime = parseInt(
      key.replace(`justWatchProviders-${locale}-`, ''),
      10
    );

    // last entry and we're still in the cache period
    if (
      i === sortedKeysLength - 1 &&
      new Date().getTime() - keyTime < SETTING_CACHE_TIME
    ) {
      return JsonParseWithDefault(window.localStorage.getItem(key));
    }

    window.localStorage.removeItem(key);
  }

  // if we didn't find an entry, query the api and store in localStorage
  const providers = await queryProviders(locale);
  if (providers && Object.keys(providers).length > 0) {
    window.localStorage.setItem(
      `justWatchProviders-${locale}-${new Date().getTime()}`,
      JSON.stringify(providers)
    );
    return providers;
  }

  // nothing found =(
  return null;
}

// ================================= DOM Manipulation

/**
 * Parse the item name and year from the tracker torrent page
 * If there is an AKA, get the AKA part (i.e. the English part)
 */
function getItemNameYearImdbId() {
  const title = document.querySelector('.page__title').textContent;

  const itemMatch = title.match(/[^[]+/);
  const itemParts = itemMatch?.[0].split(/( AKA | > )/);
  const itemName = itemParts && itemParts[itemParts.length - 1].trim();

  const yearMatch = title.match(/\[([\d]+)]/);
  const year =
    yearMatch && yearMatch[1].length === 4 ?
    parseInt(yearMatch[1], 10) :
    Number.NaN;

  const imdbElem =
    // general torrent page
    document.getElementById('imdb-title-link') ||
    // torrent request page
    Array.from(document.querySelectorAll(`a[rel="noreferrer"]`)).find((a) =>
      a.href.match(/tt\d+/)
    );
  const imdbHref = imdbElem?.href;
  const imdbId = imdbHref?.match(/tt\d+/)?.[0];

  return {
    itemName,
    year,
    imdbId,
  };
}

/**
 * Gets languages from torrent pages
 */
function getTorrentPageLanguages() {
  const movieInfo = document.querySelector('#movieinfo');
  const sections =
    movieInfo && movieInfo.querySelectorAll('.panel__body > div');

  const languages =
    sections &&
    Array.from(sections).filter((div) =>
      div.textContent.startsWith('Language')
    );

  if (!languages || languages.length === 0) {
    return [];
  }

  return languages[0].textContent.replace('Language: ', '').split(', ');
}

/**
 * Gets languages from request pages
 */
function getRequestPageLanguages() {
  const movieInfo = document.querySelector('#movieinfo');
  const sections =
    movieInfo &&
    movieInfo.querySelectorAll(`.panel__body a[href*="?languages="]`);

  if (!sections || sections.length === 0) {
    return [];
  }

  return Array.from(sections).map((s) => s.textContent);
}

/**
 * Find the appropriate locales for an item that are listed in the Info panel. Always prepend en_US first
 */
function getItemLanguages() {
  const torrentPageLanguages = getTorrentPageLanguages();
  const requestPageLanguages = getRequestPageLanguages();

  if (torrentPageLanguages.length === 0 && requestPageLanguages.length === 0) {
    return [LANGUAGE_MAP['English US']];
  }

  const langs =
    torrentPageLanguages.length > 0 ?
    torrentPageLanguages :
    requestPageLanguages;

  const results = [];
  for (let i = 0, len = langs.length; i < len; i += 1) {
    const lang = langs[i];
    if (Object.prototype.hasOwnProperty.call(LANGUAGE_MAP, lang)) {
      results.push(LANGUAGE_MAP[lang]);
    }
  }

  if (!results.includes(LANGUAGE_MAP['English US'])) {
    results.unshift(LANGUAGE_MAP['English US']);
  }

  return results;
}

/**
 * Return an array of missing editions from the PTP movie page
 */
function getMissingEditions() {
  let missing = [
    'Standard Definition',
    'High Definition',
    'Ultra High Definition',
  ];

  Array.from(
    document.querySelectorAll('.basic-movie-list__torrent-edition')
  ).forEach((row) => {
    if (
      row.querySelector('.basic-movie-list__torrent-edition__main')
      .textContent === 'Feature Film'
    ) {
      const version = row.querySelector(
        '.basic-movie-list__torrent-edition__sub'
      ).textContent;
      missing = missing.filter((item) => item !== version);
    }
  });

  return missing.map((m) => {
    if (m === 'Standard Definition') return 'SD';
    if (m === 'High Definition') return 'HD';
    if (m === 'Ultra High Definition') return '4K';
    return '???';
  });
}

// ================================= DOM Creations

/**
 * Show a message in the streaming box. Destroys other contents inside it
 * type = 'warning' | 'error'
 */
function showMessage(panelBody, msg, type = 'warning') {
  const div = document.createElement('div');
  div.classList.add(
    'streaming__message',
    type === 'error' ? 'streaming--red' : undefined,
    type === 'warning' ? 'streaming--yellow' : undefined
  );
  div.innerText = msg;

  // remove all contents and replace with error
  while (panelBody.firstChild) {
    panelBody.removeChild(panelBody.lastChild);
  }
  panelBody.appendChild(div);

  return null;
}

/**
 * Create the cells that go into a row
 * Cells = provider image and presentationTypes
 */
function createCells(offers, providers) {
  const presentationTypeOrdering = ['SD', 'HD', '_4K'];
  const types = offers
    .sort(
      (a, b) =>
      presentationTypeOrdering.indexOf(a.presentationType.toUpperCase()) -
      presentationTypeOrdering.indexOf(b.presentationType.toUpperCase())
    )
    .map(
      (offer) =>
      `<li class="streaming__presentation-type">
        ${offer.retailPrice ?? monetizationToShort(offer.monetizationType)} 
        <span style="color: #C8AF4B;">${offer.presentationType
          .toUpperCase()
          .replace('_', '')}</span>
      </li>`
    );

  const providerId = offers[0].package.packageId;

  return `<a
    class="streaming__item"
    href="${offers[0].standardWebURL}"
    target="_blank"
    rel="noopener noreferrer"
  >
    <img
      class="streaming__img" src="${makeIconUrl(providerId, providers)}"
      title="${providers[providerId]?.name || 'Unknown'}"
      alt="Provider icon"
     />
    <ul class="list list--unstyled streaming__presentation-types">
      ${types.join('')}
    </ul>
  </a>`;
}

/**
 * Create a singular row for a specific viewType (stream, rent, buy)
 */
function createRow(viewType, offers, providers) {
  // group offers by provider so we can show SD / HD / 4K all together
  const byProvider = offers.reduce((results, item) => {
    if (!results[item.package.packageId]) {
      // eslint-disable-next-line no-param-reassign
      results[item.package.packageId] = [];
    }
    results[item.package.packageId].push(item);
    return results;
  }, {});

  // create the individual cells
  const row = Object.keys(byProvider)
    // some providers are missing from the provider list. We can't show them, and JustWatch doesn't either, so let's remove them
    .filter((providerId) => typeof providers[providerId] !== 'undefined')
    // sort alphabetically
    .sort((a, b) => {
      const aProvider = providers[a].name;
      const bProvider = providers[b].name;

      if (aProvider > bProvider) return 1;
      if (bProvider > aProvider) return -1;
      return 0;
    })
    .map((providerId) => {
      const providerOffers = byProvider[providerId];
      return createCells(providerOffers, providers);
    })
    .join('');

  return `<div class="streaming__row">
    <div class="streaming__row-header">${viewType}</div>
    <div class="streaming__row-content ${
      row === '' ? 'streaming__row-content--empty streaming--red' : ''
    }">
      ${row || TEXT_NO_LINK_FOUND[viewType]}
    </div>
  </div>`;
}

/**
 * Create all rows
 */
function createRows(
  itemName,
  imdbId,
  locale,
  providers,
  justWatchObj = {
    offers: []
  }
) {
  return ['STREAM', 'RENT', 'BUY'].map((viewType) =>
    createRow(
      viewType,
      justWatchObj.offers.filter(getOfferFilterFn(viewType)),
      providers
    )
  );
}

/**
 * Creates the locale selector, based on what locales are available to the movie, and what we have configured in the script
 */
function createLocaleDropdown(imdbId, localeForFoundMovie = null) {
  const selector = document.createElement('select');
  selector.id = 'streaming__select';
  selector.addEventListener('change', (event) => {
    // eslint-disable-next-line no-use-before-define
    createPanelBody([event.target.value]);
  });

  const trackerLocaleMap = Object.values(LANGUAGE_MAP);
  const trackerLanguageMap = Object.keys(LANGUAGE_MAP);
  const itemAllLocales = Object.keys(getCacheForImdb(imdbId));
  const availableLanguages = itemAllLocales
    .filter((l) => trackerLocaleMap.includes(l))
    .map((l) => trackerLanguageMap.find((lang) => LANGUAGE_MAP[lang] === l));

  const finalLanguages =
    availableLanguages.length > 0 ? availableLanguages : trackerLanguageMap;

  finalLanguages.sort().forEach((lang) => {
    const option = document.createElement('option');
    option.value = LANGUAGE_MAP[lang];
    option.innerText = `${lang} (${LANGUAGE_MAP[lang]})`;
    option.selected = LANGUAGE_MAP[lang] === localeForFoundMovie;

    selector.appendChild(option);
  });

  return {
    selector,
    showingDefault: availableLanguages.length === 0
  };
}

/**
 * Create the footer, including the locale selector, and which editions are missing on PTP
 */
function createFooter(imdbId, localeForFoundMovie = null) {
  const ptpMissing = getMissingEditions();
  const hasPtpMissing = ptpMissing.length > 0;

  const missingUnknownElem = `<div class="streaming__missing streaming--grey">Missing on PTP: Unknown</div>`;
  const missingElem = `<div class="streaming__missing streaming--${
    hasPtpMissing ? 'red' : 'green'
  }">
    ${
      hasPtpMissing
        ? `Missing on PTP: ${ptpMissing.join(', ')}`
        : 'All editions available on PTP'
    }
  </div>`;

  const selectorDiv = document.createElement('div');
  selectorDiv.classList.add('streaming__locale');
  const {
    selector,
    showingDefault
  } = createLocaleDropdown(
    imdbId,
    localeForFoundMovie
  );

  const localeText = document.createElement('span');
  localeText.appendChild(
    document.createTextNode(`${showingDefault ? '*' : ''} Locale: `)
  );
  if (showingDefault) {
    localeText.title = "Couldn't filter locales, showing all";
  }

  selectorDiv.appendChild(localeText);
  selectorDiv.appendChild(selector);

  const footer = document.createElement('div');
  footer.classList.add('streaming__footer');
  footer.innerHTML = window.location.href.includes('torrents.php') ?
    missingElem :
    missingUnknownElem;
  footer.prepend(selectorDiv);

  return footer;
}

/**
 * Creates the body of the panel, including results and footer
 * If no specific array of locales is passed in, we'll parse PTP to find all available for the movie
 * Otherwise the user has selected a local from the dropdown, and we want to search for the movie based only on that one, overwriting PTP parsed ones
 */
async function createPanelBody(
  locales = getItemLanguages(), {
    itemName,
    year,
    imdbId
  } = getItemNameYearImdbId()
) {
  const panel = document.querySelector('#panel__streaming');
  if (!panel) {
    return;
  }

  const body = panel.querySelector('.streaming__rows');

  // error check that we got all needed data
  if (
    !itemName ||
    !year ||
    Number.isNaN(year) ||
    !imdbId ||
    !Array.isArray(locales) ||
    locales.length === 0
  ) {
    showMessage(
      body,
      `Could not find all necessary data\r\nName: "${itemName}"; Year: "${year}"; IMDb: "${imdbId}"; Locales: "${
        Array.isArray(locales) ? locales.join(', ') : locales
      }"`,
      'error'
    );
    // locales[0] = potentially the locale the user has selected, otherwise just the first in the entry
    body.appendChild(createFooter(imdbId, locales[0]));
    return;
  }

  // show loading message to user – JustWatch API can take a long time
  showMessage(body, `Loading ...`);

  const {
    foundItem: justWatchObj,
    locale
  } = await queryItem(
    itemName,
    year,
    imdbId,
    locales
  );

  if (!locale || !justWatchObj?.offers?.length) {
    showMessage(
      body,
      `No offers found\r\nTried Locales: "${locales.join(
        ', '
      )}"; Found Locale: "${locale}"; Offers: "${
        justWatchObj?.offers?.length || 'None'
      }"`,
      'error'
    );
    // locales[0] = potentially the locale the user has selected, otherwise just the first in the entry
    body.appendChild(createFooter(imdbId, locale || locales[0]));
    removeLocaleForImdb(imdbId, locale);
    return;
  }

  // get all the streaming providers for the locale. Cache them as well
  const providers = await getProviders(locale);
  if (!providers) {
    showMessage(body, `No providers found\r\nLocale: "${locale}`, 'error');
    body.appendChild(createFooter(imdbId, locale));
    return;
  }

  // generate rows
  const rows = createRows(itemName, imdbId, locale, providers, justWatchObj);
  // show in panel
  body.innerHTML = rows.join('');

  // generate footer
  body.appendChild(createFooter(imdbId, locale));
}

/**
 * Create the main panel and find a place to append it to
 */
function createPanel() {
  const isOpen = getInitialOpenState();

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

  // ratings == main torrent movie pages
  const ratings = document.querySelector('.main-column > .panel + .panel');
  if (ratings) {
    ratings.parentNode.insertBefore(panel, ratings.nextSibling);
  }
  else {
    // movie that has been requested page, and request a movie form
    const requests = document.querySelector(
      '#request-table, .panel.form--horizontal.generic-form'
    );
    requests.parentNode.insertBefore(panel, requests.nextSibling);
  }

  if (isOpen) {
    createPanelBody();
  }

  const panelToggle = document.querySelector('#panel__toggle');
  panelToggle.addEventListener('click', () => {
    const panelBody = document.querySelector('#panel__streaming .panel__body');

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

// ================================= Event Listeners

/**
 * Listen for key down events (arrow up and down) so we can quickly change the locale in the dropdown and perform a new search
 */
function createKeyDownListener() {
  document.onkeydown = (event) => {
    const dropdown = document.querySelector('#streaming__select');
    if (!dropdown) {
      return;
    }

    const direction =
      // eslint-disable-next-line no-nested-ternary
      event.key === 'ArrowUp' ?
      'up' :
      event.key === 'ArrowDown' ?
      'down' :
      null;

    if (direction === null) {
      return;
    }

    const newIndex =
      direction === 'up' ?
      dropdown.selectedIndex - 1 :
      dropdown.selectedIndex + 1;

    if (newIndex < 0 || newIndex >= dropdown.options.length) {
      return;
    }

    event.preventDefault();

    dropdown.options[newIndex].selected = 'selected';
    dropdown.dispatchEvent(new Event('change'));
  };
}

/**
 * Continuously check the new requests page to see if the user has filled in a name and year for the movie
 */
function checkChange() {
  let lastTitle;
  let lastYear;
  let lastImdbId;
  const inputTitle = document.querySelector('#title');
  const inputYear = document.querySelector('#year');
  const inputImdb = document.querySelector('#imdb');

  const locales = getItemLanguages();

  const checkRecreate = () => {
    const title = inputTitle.value;
    const year = inputYear.value;
    const yearInt = parseInt(year, 10);
    const imdbId = inputImdb.value?.match(/tt\d+/)?.[0];

    if (title === lastTitle && year === lastYear && imdbId === lastImdbId) {
      return;
    }

    lastTitle = title;
    lastYear = year;
    lastImdbId = imdbId;

    if (
      title.length > 0 &&
      year.length === 4 &&
      !Number.isNaN(yearInt) &&
      imdbId
    ) {
      createPanelBody(locales, {
        itemName: title,
        year: yearInt,
        imdbId
      });
    }
  };

  // only continually check if we found all inputs, i.e. we're on the new request page
  if (!inputTitle || !inputYear || !inputImdb) {
    return;
  }

  setInterval(() => {
    checkRecreate();
  }, 2000);
}

// ================================= Styles

/**
 * Create styles for the streaming box. BEM FTW
 */
function createStyleTag() {
  const css = `
  .streaming__rows {
    display: flex;
    flex-direction: column;
    gap: 10px;
  }

  .streaming__row {
    display: flex;
    flex-direction: column;
    gap: 10px;
    padding-bottom: 10px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.25);
  }

  .streaming__row-content {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
    gap: 10px;
  }

  .streaming__row-content--empty {
    font-size: 0.85em;
  }

  .streaming__item {
    display: flex;
    flex-direction: column;
    text-align: center;
  }

  .streaming__img {
    width: 50px;
    border-radius: 10px;
  }

  .streaming__presentation-types {
    margin-top: 5px;
  }

  .streaming__presentation-type {
    font-size: 0.85em;
  }

  .streaming__footer {
    display: flex;
    flex-wrap: nowrap;
  }

  .streaming__locale {
    flex: 1;
  }

  .streaming__missing {
    display: flex;
    align-items: center;
    font-weight: bold;
    text-align: right;
    font-size: 0.85em;
  }

  .streaming__message {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 50px;
    text-align: center;
  }

  .streaming--red {
    color: #FF0000;
  }

  .streaming--green {
    color: #009000;
  }

  .streaming--yellow {
    color: #C8AF4B;
  }

  .streaming--grey {
    color: #CCC;
  }

  .hidden {
    display: none;
  }`;

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

  document.head.appendChild(style);
}

// ================================= Main

(async function run() {
  createStyleTag();
  createPanel();

  createKeyDownListener();
  checkChange();
})();