// ==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;
}
`);
}