// ==UserScript== // @name Plex Awards 🏆 // @namespace plex.awards // @version 2.1.5 // @description IMDb awards & nominations shown directly on the Plex page — ranked by prestige, summarised in one line, expandable, with a link to the title's /awards/ page. Fully bilingual: in French the data itself is pulled from IMDb's French pages. // @match https://app.plex.tv/* // @match http://*/web/* // @match https://*/web/* // @match http://127.0.0.1:32400/* // @match http://localhost:32400/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_setClipboard // @connect imdb.com // @connect api.graphql.imdb.com // @connect * // @icon https://watch.plex.tv/icons/favicon.ico // @run-at document-idle // @downloadURL https://raw.githubusercontent.com/PyroDzeus/userscripts/main/plex-awards.user.js // @updateURL https://raw.githubusercontent.com/PyroDzeus/userscripts/main/plex-awards.user.js // ==/UserScript== (function () { 'use strict'; // One line in the console so you can tell at a glance that the script started. console.info('[Plex Awards] 2.1.4 loaded'); const CARD_ID = 'paw-card'; const STORE = 'pawSettings'; const CACHE_KEY = 'pawCache'; const CACHE_VERSION = 3; // bump to invalidate every cached entry at once /* ============================================================ 1. SETTINGS ============================================================ */ const DEFAULTS = { lang: 'auto', // 'auto' (follows the browser) | 'en' | 'fr' placement: 'title', // 'title' | 'summary' | 'float' corner: 'br', // floating mode only: 'br' | 'bl' | 'tr' | 'tl' floatPos: null, // {left, top} once dragged — overrides `corner` expanded: false, // open the detail panel automatically majorOnly: false, // only show prestige ceremonies (tier >= 4) sort: 'prestige', // 'prestige' | 'year' maxGroups: 8, // ceremonies listed before the "+ N more" button chips: 3, // highlighted wins shown in the collapsed bar localized: true, // in French, read IMDb's French pages rather than the default ones source: 'auto', // 'auto' | 'page' (force the HTML page) | 'api' (force GraphQL) cacheDays: 30, }; function loadSettings() { let s = null; try { s = JSON.parse(GM_getValue(STORE, 'null')); } catch (e) {} return Object.assign({}, DEFAULTS, s || {}); } function saveSettings() { GM_setValue(STORE, JSON.stringify(settings)); } let settings = loadSettings(); /* ============================================================ 2. I18N — interface strings only. The award texts themselves come from IMDb, in the matching language: see LOCALE / fetchAwards below. ============================================================ */ const I18N = { en: { wins: n => n + (n > 1 ? ' wins' : ' win'), noms: n => n + (n > 1 ? ' nominations' : ' nomination'), loading: 'Awards
', unavailable: 'Awards unavailable', retry: 'Retry', none: 'No awards on record', filteredOut: 'No major awards for this title — untick “Major awards only” to see everything.', failedHint: 'IMDb did not answer. Retry, or open the page directly.', razzT: n => n + (n > 1 ? ' entries' : ' entry') + ' from a “worst of” ceremony — counted above, listed last.', detailsT: 'Show / hide the detail', settingsT: 'Settings', imdbT: 'Open the awards page on IMDb', langT: 'Switch language (also switches the IMDb source)', seriesNote: t => 'Awards for the series: ' + t, more: n => '+ ' + n + ' more ceremonies', less: 'Collapse', award: 'Award', awardsWord: 'Awards', winner: 'Winner', nominee: 'Nominee', sPosition: 'Position', sCorner: 'Corner', sLanguage: 'Language', sSort: 'Sort by', sMajor: 'Major awards only', sExpanded: 'Expanded by default', sMax: 'Ceremonies', sLocalized: 'French data from IMDb FR', sSource: 'Source', posTitle: 'Under the title', posSummary: 'Under the synopsis', posFloat: 'Floating card', cBR: 'Bottom right', cBL: 'Bottom left', cTR: 'Top right', cTL: 'Top left', sortPrestige: 'Prestige', sortYear: 'Most recent', langAuto: 'Auto', langEn: 'English', langFr: 'Français', srcAuto: 'Auto', srcPage: 'IMDb page', srcApi: 'IMDb API', copyMd: 'Copy as Markdown', copied: '✓ Copied', copyFail: '✕ Copy failed', resetPos: 'Reset position', clearCache: 'Clear IMDb cache', cacheCleared: '✓ Cache cleared', logConsole: 'Log raw data to console', logged: '✓ See the console', dragHint: 'Tip: drag the bar to move the card.', }, fr: { wins: n => n + (n > 1 ? ' victoires' : ' victoire'), noms: n => n + (n > 1 ? ' nominations' : ' nomination'), loading: 'PalmarĂšs
', unavailable: 'PalmarĂšs indisponible', retry: 'RĂ©essayer', none: 'Aucune rĂ©compense rĂ©fĂ©rencĂ©e', filteredOut: 'Aucun prix majeur pour ce titre — dĂ©coche « Prix majeurs seulement » pour tout voir.', failedHint: 'IMDb n’a pas rĂ©pondu. RĂ©essaie, ou ouvre la page directement.', razzT: n => n + (n > 1 ? ' entrĂ©es issues' : ' entrĂ©e issue') + ' d’une cĂ©rĂ©monie « anti-prix » — comptĂ©e ci-dessus, listĂ©e en dernier.', detailsT: 'Afficher / masquer le dĂ©tail', settingsT: 'RĂ©glages', imdbT: 'Ouvrir la page des rĂ©compenses sur IMDb', langT: 'Changer de langue (change aussi la source IMDb)', seriesNote: t => 'PalmarĂšs de la sĂ©rie : ' + t, more: n => '+ ' + n + ' autres cĂ©rĂ©monies', less: 'RĂ©duire', award: 'RĂ©compense', awardsWord: 'PalmarĂšs', winner: 'LaurĂ©at', nominee: 'NommĂ©', sPosition: 'Position', sCorner: 'Coin', sLanguage: 'Langue', sSort: 'Trier par', sMajor: 'Prix majeurs seulement', sExpanded: 'DĂ©pliĂ© par dĂ©faut', sMax: 'CĂ©rĂ©monies', sLocalized: 'DonnĂ©es FR depuis IMDb FR', sSource: 'Source', posTitle: 'Sous le titre', posSummary: 'Sous le synopsis', posFloat: 'Carte flottante', cBR: 'En bas Ă  droite', cBL: 'En bas Ă  gauche', cTR: 'En haut Ă  droite', cTL: 'En haut Ă  gauche', sortPrestige: 'Prestige', sortYear: 'Plus rĂ©cent', langAuto: 'Auto', langEn: 'English', langFr: 'Français', srcAuto: 'Auto', srcPage: 'Page IMDb', srcApi: 'API IMDb', copyMd: 'Copier en Markdown', copied: '✓ CopiĂ©', copyFail: '✕ Échec de la copie', resetPos: 'RĂ©initialiser la position', clearCache: 'Vider le cache IMDb', cacheCleared: '✓ Cache vidĂ©', logConsole: 'Afficher les donnĂ©es brutes', logged: '✓ Voir la console', dragHint: 'Astuce : glisse la barre pour dĂ©placer la carte.', }, }; /** Resolved UI language: explicit choice, otherwise the browser's. */ function lang() { if (settings.lang === 'en' || settings.lang === 'fr') return settings.lang; return /^fr/i.test(navigator.language || '') ? 'fr' : 'en'; } function T() { return I18N[lang()]; } /** True when we should be reading IMDb's French pages. */ function wantFrench() { return lang() === 'fr' && settings.localized; } /** Locale hints sent with every IMDb request. */ function localeHeaders() { return wantFrench() ? { 'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.4', 'x-imdb-user-language': 'fr-FR', 'x-imdb-user-country': 'FR' } : { 'Accept-Language': 'en-US,en;q=0.9', 'x-imdb-user-language': 'en-US', 'x-imdb-user-country': 'US' }; } /* ============================================================ 3. DISK CACHE Entries are keyed by title *and* locale, so switching language never serves you the other language's text. ============================================================ */ let cache = (() => { try { const raw = JSON.parse(GM_getValue(CACHE_KEY, '{}')) || {}; return raw.__v === CACHE_VERSION ? raw : { __v: CACHE_VERSION }; } catch (e) { return { __v: CACHE_VERSION }; } })(); function cacheKey(id) { return id + '@' + (wantFrench() ? 'fr' : 'en'); } function cacheGet(id) { const e = cache[cacheKey(id)]; if (!e) return null; if (Date.now() - e.t > settings.cacheDays * 864e5) { delete cache[cacheKey(id)]; return null; } return e.v.map(a => ({ event: a[0], year: a[1] || null, category: a[2] || '', isWinner: !!a[3] })); } /** True when we stored English text under a French key (the FR page didn't answer). */ function cachePartial(id) { const e = cache[cacheKey(id)]; return !!e && e.p === 0; } function cacheSet(id, items, localised) { cache[cacheKey(id)] = { t: Date.now(), p: localised === false ? 0 : 1, v: items.slice(0, 150).map(i => [i.event, i.year || 0, i.category || '', i.isWinner ? 1 : 0]), }; const keys = Object.keys(cache).filter(k => k !== '__v'); if (keys.length > 300) keys.sort((a, b) => cache[a].t - cache[b].t).slice(0, 80).forEach(k => delete cache[k]); try { GM_setValue(CACHE_KEY, JSON.stringify(cache)); } catch (e) {} } /* ============================================================ 4. PLEX SIDE — rating key, server credentials, metadata ============================================================ */ let serverInfo = null; function getRatingKey() { const m = location.hash.match(/key=([^&]+)/); if (!m) return null; const km = decodeURIComponent(m[1]).match(/\/library\/metadata\/(\d+)/); return km ? km[1] : null; } const PLEX_TV = /(^|\.)plex\.tv$/i; const badServers = new Set(); /* Every URL on the page that carries a token, newest first. Anything served by plex.tv is skipped: that's Plex's own metadata service, not your library, and asking it for /library/metadata/
 answers nothing. Picking one of those was why the card stayed invisible. */ function serverList() { const urls = []; document.querySelectorAll('img[src*="X-Plex-Token"]').forEach(i => urls.push(i.src)); document.querySelectorAll('[style*="X-Plex-Token"]').forEach(el => { const m = (el.getAttribute('style') || '').match(/url\(["']?(.*?X-Plex-Token=.*?)["']?\)/); if (m) urls.push(m[1]); }); try { performance.getEntriesByType('resource').forEach(e => { if (e.name.includes('X-Plex-Token') && /\/(library|photo|video)\//.test(e.name)) urls.push(e.name); }); } catch (e) {} const out = [], seen = new Set(); for (const u of urls.reverse()) { try { const url = new URL(u, location.href); const token = url.searchParams.get('X-Plex-Token'); if (!token || PLEX_TV.test(url.hostname)) continue; const k = url.origin + '|' + token; if (seen.has(k) || badServers.has(k)) continue; seen.add(k); out.push({ origin: url.origin, token }); } catch (e) {} } return out; } /* When Plex Web is served by the server itself (127.0.0.1:32400, /web/
), its own address is the shortest and most reliable way in — no VPN, no detour. The token is the same whichever address you use, so we take the first one the page offers and put the local address at the front. */ function localFirst(list) { const servedByPlex = location.port === '32400' || /^\/web(\/|$)/.test(location.pathname); if (!servedByPlex || !list.length) return list; if (list.some(s => s.origin === location.origin)) { return [list.find(s => s.origin === location.origin), ...list.filter(s => s.origin !== location.origin)]; } const here = { origin: location.origin, token: list[0].token }; return badServers.has(here.origin + '|' + here.token) ? list : [here, ...list]; } function getServerInfo() { if (!serverInfo) serverInfo = localFirst(serverList())[0] || null; return serverInfo; } /** The chosen address answered nothing useful: blacklist it and take the next. */ function dropServerInfo() { if (!serverInfo) return false; badServers.add(serverInfo.origin + '|' + serverInfo.token); serverInfo = null; return !!getServerInfo(); } function fetchXml(srv, path, cb) { const sep = path.includes('?') ? '&' : '?'; let done = false; const once = doc => { if (!done) { done = true; cb(doc); } }; GM_xmlhttpRequest({ method: 'GET', url: `${srv.origin}${path}${sep}X-Plex-Token=${srv.token}`, headers: { Accept: 'application/xml' }, timeout: 8000, onload: res => { if (res.status && (res.status < 200 || res.status >= 300)) return once(null); try { once(new DOMParser().parseFromString(res.responseText, 'text/xml')); } catch (e) { once(null); } }, onerror: () => once(null), ontimeout: () => once(null), }); setTimeout(() => once(null), 10000); // a request that never ends would freeze the card } /** Pull the few fields we need out of a Plex metadata document. */ function parseItem(doc) { if (!doc) return null; const el = doc.querySelector('MediaContainer > Video, MediaContainer > Directory'); if (!el) return null; const guids = [...el.children].filter(c => c.tagName === 'Guid').map(g => g.getAttribute('id') || ''); const g = guids.find(x => x.startsWith('imdb://')); return { type: el.getAttribute('type'), title: el.getAttribute('title'), year: el.getAttribute('year'), parentKey: el.getAttribute('parentKey'), grandparentKey: el.getAttribute('grandparentKey'), grandparentTitle: el.getAttribute('grandparentTitle'), imdb: g ? g.slice(7) : null, }; } /** Episodes and seasons have no awards page of their own: climb to the series. */ function resolveTarget(srv, key, cb) { fetchXml(srv, `/library/metadata/${key}`, doc => { const it = parseItem(doc); if (!it) return cb(null); if (it.type === 'movie' || it.type === 'show') { return cb({ imdb: it.imdb, title: it.title, kind: it.type, viaShow: false }); } const showKey = it.type === 'season' ? it.parentKey : it.grandparentKey; if (!showKey) return cb({ imdb: it.imdb, title: it.title, kind: it.type, viaShow: false }); fetchXml(srv, showKey, d2 => { const sh = parseItem(d2); cb({ imdb: (sh && sh.imdb) || it.imdb, title: (sh && sh.title) || it.grandparentTitle || it.title, kind: it.type, viaShow: true, }); }); }); } /* ============================================================ 5. IMDB SIDE Two independent sources, tried in the order that best matches the requested language: · the /awards/ HTML page — always in the language of its URL (/fr/title/... gives French categories), so it is the first choice in French because it mirrors exactly what you read on imdb.com; · the public GraphQL endpoint — faster and structurally more stable, first choice in English. ============================================================ */ const GQL_URL = 'https://api.graphql.imdb.com/'; const GQL_FULL = `query TitleAwards($id: ID!) { title(id: $id) { awardNominations(first: 250) { total edges { node { isWinner award { text category { text } event { text } eventEdition { year } } } } } } }`; const GQL_MIN = `query TitleAwards($id: ID!) { title(id: $id) { awardNominations(first: 250) { total edges { node { isWinner award { text event { text } eventEdition { year } } } } } } }`; function awardsUrl(id) { return `https://www.imdb.com/${wantFrench() ? 'fr/' : ''}title/${id}/awards/?ref_=tt_awd`; } /** Flatten one GraphQL / embedded-JSON node into our own shape. */ function normNode(n) { const a = n.award || {}; return { event: (a.event && a.event.text) || a.text || T().award, year: (a.eventEdition && a.eventEdition.year) || null, category: (a.category && a.category.text) || (n.category && n.category.text) || a.text || '', isWinner: !!n.isWinner, // authoritative: never inferred from text here }; } /** Remove exact duplicates — IMDb occasionally lists the same line twice. */ function dedupe(items) { const seen = new Set(); return items.filter(i => { const k = i.event + '|' + i.year + '|' + i.category + '|' + i.isWinner; if (seen.has(k)) return false; seen.add(k); return true; }); } /* IMDb answers a background request with 403 (bot check) or 429 (too many requests) far more often than it actually fails. Those deserve another try a moment later; a clean answer with no awards does not. Every source below therefore reports { ok, items } or { ok:false, retry }. */ const SOFT_STATUS = new Set([0, 403, 408, 425, 429, 500, 502, 503, 504]); const softFail = status => ({ ok: false, retry: SOFT_STATUS.has(status === undefined ? 0 : status) }); function gql(id, query, cb) { GM_xmlhttpRequest({ method: 'POST', url: GQL_URL, headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json', 'x-imdb-client-name': 'imdb-web-next', }, localeHeaders()), data: JSON.stringify({ query, variables: { id } }), timeout: 12000, onload: res => { if (res.status && (res.status < 200 || res.status >= 300)) return cb(softFail(res.status)); try { const j = JSON.parse(res.responseText); if (j && j.errors && j.errors.length) return cb({ ok: false, retry: true }); const noms = j && j.data && j.data.title && j.data.title.awardNominations; if (!noms || !Array.isArray(noms.edges)) return cb({ ok: false, retry: false }); cb({ ok: true, items: dedupe(noms.edges.map(e => normNode(e.node))) }); } catch (e) { cb({ ok: false, retry: true }); } // an error page instead of JSON }, onerror: () => cb({ ok: false, retry: true }), ontimeout: () => cb({ ok: false, retry: true }), }); } /** The full query first, then the lighter one if IMDb rejects a field. */ function fromApi(id, cb) { gql(id, GQL_FULL, r => { if (r.ok) return cb(r); gql(id, GQL_MIN, r2 => cb(r2.ok ? r2 : { ok: false, retry: r.retry || r2.retry })); }); } /** * Walk the page's embedded JSON for any object carrying an `isWinner` * boolean. This is the preferred way to read the HTML page: the text * is already localised and the win/nomination flag is explicit. */ function fromEmbeddedJson(html) { const m = html.match(/