NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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(); })();