您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds links of filtered search results to the metadata section (studios, networks, genres etc.) on title summary pages. Like the vip feature, only better. Also adds a country flag. See README for details.
// ==UserScript== // @name Trakt.tv | Enhanced Title Metadata // @description Adds links of filtered search results to the metadata section (studios, networks, genres etc.) on title summary pages. Like the vip feature, only better. Also adds a country flag. See README for details. // @version 0.8.2 // @namespace https://github.com/Fenn3c401 // @author Fenn3c401 // @license GPL-3.0-or-later // @homepageURL https://github.com/Fenn3c401/Trakt.tv-Userscript-Collection#readme // @supportURL https://github.com/Fenn3c401/Trakt.tv-Userscript-Collection/issues // @icon data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBpZD0iTGF5ZXJfMiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDQ4IDQ4Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmNscy0xIHsKICAgICAgICBmaWxsOiB1cmwoI3JhZGlhbC1ncmFkaWVudCk7CiAgICAgIH0KCiAgICAgIC5jbHMtMiB7CiAgICAgICAgZmlsbDogI2ZmZjsKICAgICAgfQogICAgPC9zdHlsZT4KICAgIDxyYWRpYWxHcmFkaWVudCBpZD0icmFkaWFsLWdyYWRpZW50IiBjeD0iNDguNDYiIGN5PSItLjk1IiBmeD0iNDguNDYiIGZ5PSItLjk1IiByPSI2NC44NCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICAgICA8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiM5ZjQyYzYiLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIuMjciIHN0b3AtY29sb3I9IiNhMDQxYzMiLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIuNDIiIHN0b3AtY29sb3I9IiNhNDNlYmIiLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIuNTMiIHN0b3AtY29sb3I9IiNhYTM5YWQiLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIuNjQiIHN0b3AtY29sb3I9IiNiNDMzOWEiLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIuNzMiIHN0b3AtY29sb3I9IiNjMDJiODEiLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIuODIiIHN0b3AtY29sb3I9IiNjZjIwNjEiLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIuOSIgc3RvcC1jb2xvcj0iI2UxMTQzYyIvPgogICAgICA8c3RvcCBvZmZzZXQ9Ii45NyIgc3RvcC1jb2xvcj0iI2Y1MDYxMyIvPgogICAgICA8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9InJlZCIvPgogICAgPC9yYWRpYWxHcmFkaWVudD4KICA8L2RlZnM+CiAgPGcgaWQ9Il94MkRfLXByb2R1Y3Rpb24iPgogICAgPGcgaWQ9ImxvZ29tYXJrLnNxdWFyZS5ncmFkaWVudCI+CiAgICAgIDxwYXRoIGlkPSJiYWNrZ3JvdW5kIiBjbGFzcz0iY2xzLTEiIGQ9Ik00OCwxMS4yNnYyNS40N2MwLDYuMjItNS4wNSwxMS4yNy0xMS4yNywxMS4yN0gxMS4yNmMtNi4yMiwwLTExLjI2LTUuMDUtMTEuMjYtMTEuMjdWMTEuMjZDMCw1LjA0LDUuMDQsMCwxMS4yNiwwaDI1LjQ3YzMuMzIsMCw2LjMsMS40Myw4LjM3LDMuNzIuNDcuNTIuODksMS4wOCwxLjI1LDEuNjguMTguMjkuMzQuNTkuNS44OS4zMy42OC42LDEuMzkuNzksMi4xNC4xLjM3LjE4Ljc2LjIzLDEuMTUuMDkuNTQuMTMsMS4xMS4xMywxLjY4WiIvPgogICAgICA8ZyBpZD0iY2hlY2tib3giPgogICAgICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTEzLjYyLDE3Ljk3bDcuOTIsNy45MiwxLjQ3LTEuNDctNy45Mi03LjkyLTEuNDcsMS40N1pNMjguMDEsMzIuMzdsMS40Ny0xLjQ2LTIuMTYtMi4xNiwyMC4zMi0yMC4zMmMtLjE5LS43NS0uNDYtMS40Ni0uNzktMi4xNGwtMjIuNDYsMjIuNDYsMy42MiwzLjYyWk0xMi45MiwxOC42N2wtMS40NiwxLjQ2LDE0LjQsMTQuNCwxLjQ2LTEuNDctNC4zMi00LjMxTDQ2LjM1LDUuNGMtLjM2LS42LS43OC0xLjE2LTEuMjUtMS42OGwtMjMuNTYsMjMuNTYtOC42Mi04LjYxWk00Ny44Nyw5LjU4bC0xOS4xNywxOS4xNywxLjQ3LDEuNDYsMTcuODMtMTcuODN2LTEuMTJjMC0uNTctLjA0LTEuMTQtLjEzLTEuNjhaTTI1LjE2LDIyLjI3bC03LjkyLTcuOTItMS40NywxLjQ3LDcuOTIsNy45MiwxLjQ3LTEuNDdaTTQxLjMyLDM1LjEyYzAsMy40Mi0yLjc4LDYuMi02LjIsNi4ySDEyLjg4Yy0zLjQyLDAtNi4yLTIuNzgtNi4yLTYuMlYxMi44OGMwLTMuNDIsMi43OC02LjIxLDYuMi02LjIxaDIwLjc4di0yLjA3SDEyLjg4Yy00LjU2LDAtOC4yOCwzLjcxLTguMjgsOC4yOHYyMi4yNGMwLDQuNTYsMy43MSw4LjI4LDguMjgsOC4yOGgyMi4yNGM0LjU2LDAsOC4yOC0zLjcxLDguMjgtOC4yOHYtMy41MWgtMi4wN3YzLjUxWiIvPjwhLS0gNDVkMjM4NWQzYWFjYmI1OTMyNmEzODYxNDljNWE4NzggLS0+CiAgICAgIDwvZz4KICAgIDwvZz4KICA8L2c+Cjwvc3ZnPg== // @match https://trakt.tv/* // @run-at document-start // @grant unsafeWindow // @grant GM_info // @grant GM_addStyle // @grant GM_openInTab // @grant GM.getValue // @grant GM.setValue // ==/UserScript== /* README > Inspired by sergeyhist's [Trakt.tv Clickable Info](https://github.com/sergeyhist/trakt-scripts/blob/main/trakt-info.user.js) userscript. ### General - By clicking on the label for languages, genres, networks and studios, you can make a search for all their respective values combined, ANDed for genres and languages, ORed for networks and studios. For example if the genres are "Crime" and "Drama", then a label search will return a selection of other titles that also have the genres "Crime" AND "Drama". - The search results default to either the "movies" or "shows" search category depending on the type of the current title. - The title year and certification link to filtered search results as well. - Mouse middle click opens the filtered search results (including those where the link is dynamically constructed) in a new background tab. - Flags are not available for all countries. - A "+ n more" button is added for networks when needed. - Installing the [Trakt.tv | Unlocked Client-Side VIP Features](x70tru7b.md) userscript will allow free users to further modify the applied advanced filters, after accessing the filtered search results. - For the time being this script won't work for vip users. */ 'use strict'; let $, toastr, trakt; const Logger = Object.freeze({ _DEFAULT_PREFIX: GM_info.script.name.replace('Trakt.tv', 'Userscript') + ': ', _DEFAULT_TOAST: true, _printMsg(fnConsole, fnToastr, msg, { data, prefix = Logger._DEFAULT_PREFIX, toast = Logger._DEFAULT_TOAST } = {}) { msg = prefix + msg; console[fnConsole](msg, (data ? data : '')); if (toast) toastr[fnToastr](msg + (data ? ' See console for details.' : '')); }, info: (msg, opt) => Logger._printMsg('info', 'info', msg, opt), success: (msg, opt) => Logger._printMsg('info', 'success', msg, opt), warning: (msg, opt) => Logger._printMsg('warn', 'warning', msg, opt), error: (msg, opt) => Logger._printMsg('error', 'error', msg, opt), }); addStyles(); document.addEventListener('turbo:load', async () => { if (!/^\/(shows|movies)\//.test(location.pathname)) return; $ ??= unsafeWindow.jQuery; toastr ??= unsafeWindow.toastr; trakt ??= unsafeWindow.userscriptTraktAPIModule?.isFulfilled ? await unsafeWindow.userscriptTraktAPIModule : null; if (!$ || !toastr) return; const $additionalStatsLi = $('#overview .additional-stats > li'), pathSplit = location.pathname.split('/').filter(Boolean); if (!$additionalStatsLi.length) return; // YEAR const $year = $('#summary-wrapper .year'); if ($year.parent().is('a')) $year.insertAfter($year.parent()); // year is part of link to title summary page on e.g. /comments subpage $year.wrapAll(`<a href="/search/${pathSplit[0]}?years=${$year.text().slice(0, 4)}-${$year.text().slice(-4)}"></a>`); // year range on /seasons/all // CERTIFICATION const $certification = $('#summary-wrapper div.certification'); $certification.wrap(`<a href="/search/${pathSplit[0]}?certifications=${$certification.text().toLowerCase()}"></a>`); // GENRES const $genres = $additionalStatsLi.filter(':has([itemprop="genre"])'), matchingGenres = []; $genres.find('[itemprop="genre"]').each((i, e) => { matchingGenres[i] = $(e).text().toLowerCase().replaceAll(' ', '-'); $(e).wrap(`<a href="/search/${pathSplit[0]}?genres=${matchingGenres[i]}"></a>`); }); if (matchingGenres.length > 1) $genres.find('label').wrap(`<a href="/search/${pathSplit[0]}?genres=+${matchingGenres.join(',+')}"></a>`); // search for titles with the same set of genres combined // COUNTRY const $country = $additionalStatsLi.filter((_, e) => $(e).find('label').text().toLowerCase() === 'country'); // countryOfOrigin + name meta tags are unreliable let matchingCountry; // also used for networks and studios if ($country.length) { const allCountriesMap = await getMapOfAllCountries(), countryText = $country.contents().get(-1)?.textContent; matchingCountry = allCountriesMap[countryText]; if (matchingCountry) { // flags seem to only be available for countries that are also watch-now countries (~139), no flag assets beyond those /movies/kpop-demon-hunters-2025/releases const countryFlag = unsafeWindow.watchnowAllCountries?.[matchingCountry]?.image; if (countryFlag) $country.children().last().after(`<img class="country-flag" src="${countryFlag}">`); $country.contents().filter((_, e) => !$(e).is('meta, label')).wrapAll(`<a href="/search/${pathSplit[0]}?countries=${matchingCountry}"></a>`); } else { GM.setValue('allCountriesMap', null); Logger.error(`Failed to match title country. Cached countries have been cleared. Reload page to try again.`); } } /////////////////////////////////////////////////////////////////////////////////////////////// // LANGUAGES const $languages = $additionalStatsLi.filter((_, e) => $(e).find('label').text().toLowerCase().startsWith('language')), matchingLanguages = {}; // also used for networks and studios if ($languages.length) { const allLanugagesArrSorted = await getSortedArrOfAllLanguages(), allLanugagesMap = Object.fromEntries(allLanugagesArrSorted); let languagesText = $languages.contents().get(-1).textContent; allLanugagesArrSorted.forEach(([id, name], i) => { const regExp = new RegExp(`${RegExp.escape(name)}(, |$)`); if (regExp.test(languagesText)) { matchingLanguages[languagesText.indexOf(name)] = id; languagesText = languagesText.replace(regExp, (m) => ' '.repeat(m.length)); } }); if (!languagesText.trim()) { const matchingLanguagesIds = Object.values(matchingLanguages); $languages.contents().last().replaceWith( matchingLanguagesIds .map((id) => `<a href="/search/${pathSplit[0]}?languages=${id}">${allLanugagesMap[id]}</a>`) .join(', ') ); if (matchingLanguagesIds.length > 1) $languages.find('label').wrap(`<a href="/search/${pathSplit[0]}?languages=+${matchingLanguagesIds.join(',+')}"></a>`); } else { GM.setValue('allLanugagesArrSorted', null); Logger.error(`Failed to match all title languages (original: ${$languages.contents().get(-1).textContent} | remainder: ${languagesText.trim()}). ` + `Cached languages have been cleared. Reload page to try again.`); } } /////////////////////////////////////////////////////////////////////////////////////////////// // NETWORKS const $networks = $additionalStatsLi.filter((_, e) => $(e).find('label').text().toLowerCase().startsWith('network')), // .stat class is unreliable $networkAlt = $additionalStatsLi.filter((_, e) => /airs|aired|premiered/i.test($(e).find('label').text())).first(); // can have one network as suffix if ($networks.length && pathSplit[3] !== 'all') { // network names on /seasons/all are invalid (memory addresses instead of names) const matchingNetworks = {}, allNetworksArrSorted = await getSortedArrOfAllNetworks(), allNetworksMap = Object.fromEntries(allNetworksArrSorted); let networksText = $networks.contents().get(-1).textContent; // text is not sanitized and can contain tabs and stray spaces from network names allNetworksArrSorted.forEach(([id, { name, countryId }], i) => { const regExp = new RegExp(`${RegExp.escape(name)}(, |$)`); if (regExp.test(networksText) && ( // !countryId || // TODO countryId === matchingCountry || Object.hasOwn(matchingLanguages, countryId) || name !== allNetworksArrSorted[i+1]?.[1].name )) { matchingNetworks[networksText.indexOf(name)] = id; networksText = networksText.replace(regExp, (m) => ' '.repeat(m.length)); } }); if (!networksText.trim()) { const matchingNetworksIds = Object.values(matchingNetworks); $networks.contents().last().replaceWith( matchingNetworksIds .map((id) => `<a href="/search/shows?network_ids=${id}">${allNetworksMap[id].name}${allNetworksMap[id].countryId ? ` (${allNetworksMap[id].countryId.toUpperCase()})` : ''}</a>`) .join('') ); if (matchingNetworksIds.length > 1) { $networks.find('label').wrap(`<a href="/search/shows?network_ids=${matchingNetworksIds.join(',')}"></a>`); $(`<a href onclick="$(this).hide(); $(this).next().show(); return false;"> + ${matchingNetworksIds.length - 1} more</a>`) // necessary because for some titles there are 10+ networks listed .insertAfter($networks.children().eq(1)) .nextAll() .wrapAll(`<span style="display: none;"></span>`); } $networks.find('a:not(:has(label), [onclick])').slice(1).before(', '); // comma insertion done here because nextAll() doesn't support text nodes } else { GM.setValue('allNetworksArrSorted', null); Logger.error(`Failed to match all title networks (original: ${$networks.contents().get(-1).textContent} | remainder: ${networksText.trim()}). ` + `Cached networks have been cleared. Reload page to try again.`); } } else if ($networkAlt.text().includes(' on ') && pathSplit[3] !== 'all') { const allNetworksArrSorted = await getSortedArrOfAllNetworks(), networkText = $networkAlt.contents().last().text().split(' on ')[1]; const matchingNetwork = networkText ? allNetworksArrSorted.find(([id, { name, countryId }], i) => new RegExp(`${RegExp.escape(name)}(, |$)`).test(networkText) && ( // !countryId || // TODO countryId === matchingCountry || Object.hasOwn(matchingLanguages, countryId) || name !== allNetworksArrSorted[i+1]?.[1].name ) ) : null; if (matchingNetwork) { $networkAlt.contents().last().remove(); $networkAlt.append(` on <a href="/search/shows?network_ids=${matchingNetwork[0]}">${matchingNetwork[1].name}` + `${matchingNetwork[1].countryId ? ` (${matchingNetwork[1].countryId.toUpperCase()})` : ''}</a>`) } else { GM.setValue('allNetworksArrSorted', null); Logger.error(`Failed to match title network (${networkText}). Cached networks have been cleared. Reload page to try again.`); } } /////////////////////////////////////////////////////////////////////////////////////////////// // STUDIOS const $studios = $additionalStatsLi.filter((_, e) => $(e).find('label').text().toLowerCase().startsWith('studio')); if ($studios.length) { if (trakt) { let hasRun = false; const matchStudioFromElemContext = async function(evt) { if (hasRun) return; hasRun = true; evt?.preventDefault(); unsafeWindow.showLoading?.(); const dataStudios = await trakt[pathSplit[0]].studios({ id: $('.summary-user-rating').attr(`data-${pathSplit[0].slice(0, -1)}-id`) }), // has the same order as $studios allStudioIdsJoined = dataStudios.map((studio) => studio.ids.trakt).join(); unsafeWindow.hideLoading?.(); if (evt) { const url = `/search/${pathSplit[0]}?studio_ids=${$(this).find('label').length ? allStudioIdsJoined : dataStudios[0].ids.trakt}`; if (evt.type === 'click') location.href = url; else GM_openInTab(location.origin + url, { insert: true, setParent: true }); } $studios.children().eq(0).attr('href', `/search/${pathSplit[0]}?studio_ids=${allStudioIdsJoined}`); $studios.children().eq(1).attr('href', `/search/${pathSplit[0]}?studio_ids=${dataStudios[0].ids.trakt}`); $studios.find('.studios-more').html(dataStudios.slice(1).map((studio) => `, <a href="${studio.ids.trakt}">${studio.name}</a>`)); } // wrap names with unresolved anchor tags to minimize api requests $studios.find('label').wrap($(`<a href="#"></a>`).one('click auxclick', matchStudioFromElemContext)); $studios.contents().eq(1).wrap($(`<a href="#"></a>`).one('click auxclick', matchStudioFromElemContext)); $studios.find('.studios-expand').one('click', () => matchStudioFromElemContext()); } else { const matchingStudios = new Set(), $studiosMore = $studios.find('.studios-more'), $studiosExpand = $studios.find('.studios-expand'), studiosMoreSplit = $studiosMore.text().split(', ').slice(1), studiosMoreCount = +$studiosExpand.text().match(/\d+/)?.[0] || null; // use studio search endpoint from advanced filters modal (~250.000 studios total; several thousand studio names contain commas; returns max. of 1000 results per request sorted lexicographically) const queryStudioNameMatches = (query) => { return fetch('/autocomplete/studios?query=' + encodeURIComponent(query)) .then((r) => r.json()) .then((r) => Object.fromEntries( r.map(({ label: name, value: studioId, tag: countryId }) => [name, +studioId, countryId?.toLowerCase() ?? null]) .sort(([nameA, studioIdA, countryIdA], [nameB, studioIdB, countryIdB]) => nameA === nameB ? (countryIdA && (countryIdA === matchingCountry || Object.hasOwn(matchingLanguages, countryIdA))) - (countryIdB && (countryIdB === matchingCountry || Object.hasOwn(matchingLanguages, countryIdB))) || // (countryIdB && 1) - (countryIdA && 1) || // TODO studioIdB - studioIdA // the lower the studio id, the more major the studio tends to be : 0) )); }; // executed from the context of an unresolved anchor tag (no lookup on page load to minimize api requests) const matchStudioFromElemContext = async function(evt) { evt?.preventDefault(); $(this).off(); unsafeWindow.showLoading?.(); const studioName = $(this).text(), queryResult = await queryStudioNameMatches(studioName), studioId = queryResult[studioName]; unsafeWindow.hideLoading?.(); if (studioId) { matchingStudios.add(studioId); const url = `/search/${pathSplit[0]}?studio_ids=${studioId}`; if (evt) { if (evt.type === 'click') location.href = url; else GM_openInTab(location.origin + url, { insert: true, setParent: true }); } $(this).attr('href', url); } else { Logger.error('Failed to match title studio: ' + studioName, { data: queryResult }); } }; // algorithm to deal with getting ids for a list of studio names, separated by commas, which by themseves can contain commas: // for split(', ') part at index i try to find longest possible match in part's result list by looking for results[parts(i)], then results[parts(i) + parts(i+1)] etc. longest match wins const matchStudiosMoreSplit = async () => { if (matchingStudios.size > 1) return; unsafeWindow.showLoading?.(); const partsQueryResults = await Promise.all(studiosMoreSplit.map((part) => queryStudioNameMatches(part).then((results) => [part, results]))); let consumedUntilIndex = -1; unsafeWindow.hideLoading?.(); $studiosMore.html(partsQueryResults.map(([part, results], i) => { if (i <= consumedUntilIndex) return null; let longestMatch; for (let j = i; j < partsQueryResults.length; j++) { if (j !== i) part += ', ' + partsQueryResults[j][0]; if (results[part]) { consumedUntilIndex = j; longestMatch = [part, results[part]]; } }; if (longestMatch) { matchingStudios.add(longestMatch[1]); return `, <a href="/search/${pathSplit[0]}?studio_ids=${longestMatch[1]}">${longestMatch[0]}</a>`; } else { Logger.error('Failed to match all title studios. Could not match: ' + partsQueryResults[i][0], { data: results }); throw new Error('Failed to match all title studios.'); // don't mutate original elem } }).join('')); } $studios.contents().eq(1).wrap($(`<a href="#"></a>`).on('click auxclick', matchStudioFromElemContext)); if (studiosMoreCount) { // parseStudiosMoreSplit() always works, but it's overkill in most cases as only a small subset of studios have names containing commas, separate handling of trivial cases minimizes api requests if (studiosMoreCount === 1) { $studiosMore .text(', ') .append($(`<a href="#">${studiosMoreSplit.join(', ')}</a>`).on('click auxclick', matchStudioFromElemContext)); } else if (studiosMoreCount === studiosMoreSplit.length) { $studiosMore.empty(); studiosMoreSplit.forEach((part) => $studiosMore.append(', ', $(`<a href="#">${part}</a>`).on('click auxclick', matchStudioFromElemContext))); } else { $studiosExpand.one('click', matchStudiosMoreSplit); } $studios.find('label') .wrap(`<a href="#"></a>`) .parent() .on('click auxclick', async function(evt) { evt.preventDefault(); $(this).off(); await Promise.all([...$studios.find('a[href="#"]:not(:has(label), .studios-expand)').get().map((e) => matchStudioFromElemContext.call(e)), matchStudiosMoreSplit()]); const url = `/search/${pathSplit[0]}?studio_ids=${Array.from(matchingStudios).join(',')}`; if (evt.type === 'click') location.href = url; else GM_openInTab(location.origin + url, { insert: true, setParent: true }); // GM_openInTab for reliably opening background tabs $(this).attr('href', url); }); } } } }, { capture: true }); /////////////////////////////////////////////////////////////////////////////////////////////// // fetch and cache a map of all possible country values (~235) from the advanced filters modal async function getMapOfAllCountries() { let allCountriesMap = JSON.parse(await GM.getValue('allCountriesMap', null)); if (!allCountriesMap) { const doc = await fetch('/search/movies').then((r) => r.text()).then((d) => new DOMParser().parseFromString(d, 'text/html')); // movie countries are superset of show countries allCountriesMap = Object.fromEntries( $(doc).find('#filter-countries') .children() .get() .map((e) => [$(e).text(), $(e).attr('value').toLowerCase()]) ); GM.setValue('allCountriesMap', JSON.stringify(allCountriesMap)) } return allCountriesMap; } // fetch and cache a sorted list of all possible language values (~179) from the advanced filters modal async function getSortedArrOfAllLanguages() { let allLanguagesArrSorted = JSON.parse(await GM.getValue('allLanguagesArrSorted', null)); if (!allLanguagesArrSorted) { const doc = await fetch('/search/movies').then((r) => r.text()).then((d) => new DOMParser().parseFromString(d, 'text/html')); // movie languages are superset of show languages allLanguagesArrSorted = $(doc).find('#filter-languages') .children() .get() .map((e) => [$(e).attr('value'), $(e).text()]) .sort(([, nameA], [, nameB]) => nameB.length - nameA.length); // ensure longest names get matched first, necessary because language names can include other language names and commas GM.setValue('allLanguagesArrSorted', JSON.stringify(allLanguagesArrSorted)) } return allLanguagesArrSorted; } // fetch and cache a sorted list of all possible network values (~4000) from the advanced filters modal (trakt api only returns one single network and only the name, no id) async function getSortedArrOfAllNetworks() { let allNetworksArrSorted = JSON.parse(await GM.getValue('allNetworksArrSorted', null)); if (!allNetworksArrSorted) { const doc = await fetch('/search/shows').then((r) => r.text()).then((d) => new DOMParser().parseFromString(d, 'text/html')), collator = new Intl.Collator(); allNetworksArrSorted = $(doc).find('#filter-network_ids') .children() .get() .map((e) => $(e).text() ? [+$(e).attr('value'), { name: $(e).text(), countryId: $(e).attr('data-tag')?.toLowerCase() }] : null) // (names are not sanitized, can contain leading/trailing whitespace) .filter(Boolean) // at least one option has no name .sort(([networkIdA, { name: nameA, countryId: countryIdA }], [networkIdB, { name: nameB, countryId: countryIdB }]) => nameB.length - nameA.length || // ensure longest names get matched first, necessary because network names can include other network names and commas collator.compare(nameA, nameB) || // make sure all those with the same name are neighbors (countryIdB && 1) - (countryIdA && 1) || // prioritize those with country code networkIdB - networkIdA // the lower the network id, the more major the network tends to be ); GM.setValue('allNetworksArrSorted', JSON.stringify(allNetworksArrSorted)) } return allNetworksArrSorted; } /////////////////////////////////////////////////////////////////////////////////////////////// function addStyles() { GM_addStyle(` #overview .additional-stats .country-flag { width: 20px !important; margin: -2px 5px 0 0 !important; transition: transform .5s ease; } #overview .additional-stats a:hover > .country-flag { transform: scale(1.1); } :is(#info-wrapper .additional-stats a > label, #summary-wrapper a > .year):hover { color: var(--link-color) !important; cursor: pointer !important; } #summary-wrapper a:has(> .certification):hover { color: #fff !important; } `); }