// ==UserScript==
// @name UNIT3D Add Letterboxd/IMDB/RT/TMDB rating
// @namespace https://github.com/flowerey/unit3d-scripts
// @version 1.5.3
// @description Add Ratings to Letterboxd/IMDB/RT/TMDB.
// @author blueberry
// @match https://*/torrents/*
// @match https://*/requests/*
// @downloadURL https://raw.githubusercontent.com/flowerey/unit3d-scripts/main/add-ratings.user.js
// @updateURL https://raw.githubusercontent.com/flowerey/unit3d-scripts/main/add-ratings.user.js.meta.js
// @grant GM.xmlHttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-end
// ==/UserScript==
(function () {
"use strict";
const CONFIG = {
cacheHours: 120,
defaultOmdbKey: "6be019fc",
debug: false
};
const getOmdbKey = () => GM_getValue("omdb_api_key", "") || CONFIG.defaultOmdbKey;
const ensureOmdbKey = () => {
let key = GM_getValue("omdb_api_key", "");
if (GM_getValue("omdb_key_dismissed", false)) return key || CONFIG.defaultOmdbKey;
if (!key) {
key = prompt("Enter your OMDb API key (get one free at https://www.omdbapi.com/apikey.aspx):", "");
if (key && key.trim()) {
GM_setValue("omdb_api_key", key.trim());
} else {
GM_setValue("omdb_key_dismissed", true);
}
}
return key || CONFIG.defaultOmdbKey;
};
const log = (msg, data) => {
if (CONFIG.debug) console.log(`%c[U3D Rating] ${msg}`, "color: cyan; font-weight: bold;", data || "");
};
const Cache = {
_maxEntries: 200,
get: (key) => {
const data = localStorage.getItem(key);
if (!data) return null;
try {
const parsed = JSON.parse(data);
if (Date.now() - parsed.timestamp > CONFIG.cacheHours * 3600000) {
localStorage.removeItem(key);
return null;
}
return parsed.value;
} catch (e) { return null; }
},
set: (key, value) => {
try {
localStorage.setItem(key, JSON.stringify({ value: value, timestamp: Date.now() }));
} catch (e) { /* quota exceeded */ }
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith('u3d_')) keys.push(k);
}
if (keys.length > Cache._maxEntries) {
keys.sort((a, b) => {
try { return JSON.parse(localStorage.getItem(a)).timestamp - JSON.parse(localStorage.getItem(b)).timestamp; }
catch { return 0; }
});
for (let i = 0; i < keys.length - Cache._maxEntries; i++) {
localStorage.removeItem(keys[i]);
}
}
}
};
const injectStyles = () => {
const style = document.createElement("style");
style.textContent = `
.rating-value { font-size: 13px; font-weight: 700; margin-left: 8px; vertical-align: middle; display: inline-block; animation: fadeIn 0.3s ease-in; }
.meta__ids li a.meta-id-tag { display: inline-flex !important; align-items: center; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.meta__filmweb img { width: 18px; height: auto; vertical-align: middle; }
.aggregate-score {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 700;
margin-left: 12px;
vertical-align: middle;
animation: fadeIn 0.3s ease-in;
border: 1px solid rgba(255,255,255,0.15);
}
.aggregate-score .agg-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
}
.aggregate-score .agg-value {
font-size: 16px;
}
`;
document.head.appendChild(style);
};
const getColor = (rating, scale = 10) => {
let n = parseFloat(rating);
if (scale === 100) n /= 10;
if (scale === 5) n *= 2;
if (isNaN(n)) return "var(--text-color)";
if (n < 5) return "#ff4d4d";
if (n < 7) return "#ffcc00";
if (n < 9) return "#66ff66";
return "#40E0D0";
};
const renderRating = (element, rating, scale = 10, suffix = "") => {
if (!element) return;
const link = element.tagName === 'A' ? element : element.querySelector('a');
if (!link || link.querySelector('.rating-value')) return;
const span = document.createElement("span");
span.className = "rating-value";
span.style.color = getColor(rating, scale);
span.innerText = rating + suffix;
link.appendChild(span);
};
const ratings = { imdb: null, rt: null, tmdb: null, lb: null, br: null, mc: null };
const renderAggregateScore = () => {
const scores = [];
if (ratings.imdb) scores.push(parseFloat(ratings.imdb));
if (ratings.rt) scores.push(parseFloat(ratings.rt) / 10);
if (ratings.lb) scores.push(parseFloat(ratings.lb) * 2);
if (ratings.mc) scores.push(parseFloat(ratings.mc) / 10);
if (ratings.br) scores.push(parseFloat(ratings.br));
if (ratings.tmdb) scores.push(parseFloat(ratings.tmdb) / 10);
if (scores.length < 2) return;
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
const display = avg.toFixed(1);
const color = getColor(display);
const metaList = document.querySelector('.meta__ids');
if (!metaList) return;
const existing = metaList.querySelector('.aggregate-score');
if (existing) return;
const badge = document.createElement('li');
badge.className = 'aggregate-score';
badge.style.borderColor = color;
badge.style.color = color;
badge.innerHTML = `Avg${display}`;
badge.title = `Aggregate of ${scores.length} rating(s)`;
metaList.appendChild(badge);
};
const handleFilmweb = (metaList) => {
if (document.querySelector('.meta__filmweb')) return;
const titleEl = document.querySelector('.meta__title') || document.querySelector('h1.panel__heading');
if (!titleEl) return;
const cleanTitle = titleEl.textContent
.replace(/[\n\r]+/g, ' ')
.replace(/\s{2,}/g, ' ')
.replace(/\[.*?\]/g, '')
.replace(/\./g, ' ')
.trim();
const fwLink = `https://duckduckgo.com/?q=\\${encodeURIComponent(cleanTitle)}%20site%3Afilmweb.pl`;
const li = document.createElement('li');
li.className = 'meta__filmweb';
const a = document.createElement('a');
a.href = fwLink;
a.className = 'meta-id-tag';
a.target = '_blank';
a.title = `Filmweb: ${cleanTitle}`;
const img = document.createElement('img');
img.src = 'https://fwcdn.pl/prt/static/images/fw/icons2/512x512.png';
img.className = 'meta-id-icon';
img.alt = 'Filmweb';
a.appendChild(img);
li.appendChild(a);
metaList.appendChild(li);
};
const handleMetacritic = (imdbId, els) => {
const cacheKey = `u3d_mc_${imdbId}`;
const cached = Cache.get(cacheKey);
if (cached) {
ratings.mc = cached;
renderAggregateScore();
return;
}
const metaList = document.querySelector('.meta__ids');
if (!metaList) return;
const mcLink = document.createElement('li');
mcLink.className = 'meta__metacritic';
mcLink.innerHTML = `
MC
`;
metaList.appendChild(mcLink);
// Use OMDB to get Metacritic score (OMDB includes Metacritic in Ratings)
const cacheKeyOmdb = `u3d_omdb_${imdbId}`;
const cachedOmdb = Cache.get(cacheKeyOmdb);
if (cachedOmdb && cachedOmdb.mc) {
ratings.mc = cachedOmdb.mc;
Cache.set(cacheKey, cachedOmdb.mc);
const scoreSpan = document.createElement('span');
scoreSpan.className = 'rating-value';
scoreSpan.style.color = getColor(cachedOmdb.mc, 100);
scoreSpan.innerText = cachedOmdb.mc;
mcLink.querySelector('a').appendChild(scoreSpan);
renderAggregateScore();
return;
}
// If OMDB not cached, fetch it via OMDB (with Metacritic)
const apiKey = getOmdbKey();
GM.xmlHttpRequest({
method: "GET",
url: `https://www.omdbapi.com/?apikey=${apiKey}&tomatoes=true&i=${imdbId}`,
onload: (res) => {
try {
const json = JSON.parse(res.responseText);
if (!json || json.Response === "False") return;
const mcObj = json.Ratings ? json.Ratings.find(r => r.Source === "Metacritic") : null;
if (mcObj && mcObj.Value) {
const score = mcObj.Value.replace('/100', '').trim();
ratings.mc = score;
Cache.set(cacheKey, score);
const scoreSpan = document.createElement('span');
scoreSpan.className = 'rating-value';
scoreSpan.style.color = getColor(score, 100);
scoreSpan.innerText = score;
mcLink.querySelector('a').appendChild(scoreSpan);
renderAggregateScore();
}
} catch (e) {
log("Metacritic parse error", e);
}
},
onerror: (e) => log("Metacritic request error", e)
});
};
const handleOMDB = (imdbId, els) => {
const cacheKey = `u3d_omdb_${imdbId}`;
const cached = Cache.get(cacheKey);
if (cached) {
if (cached.imdb) {
ratings.imdb = cached.imdb;
renderRating(els.imdb, cached.imdb, 10);
}
if (cached.rt && els.rt) {
ratings.rt = cached.rt.replace('%', '');
renderRating(els.rt, cached.rt.replace('%', ''), 100, '%');
}
if (cached.rtUrl && els.rt) {
const rtLink = els.rt.querySelector('a');
if (rtLink) rtLink.href = cached.rtUrl;
}
renderAggregateScore();
return;
}
const apiKey = ensureOmdbKey();
GM.xmlHttpRequest({
method: "GET",
url: `https://www.omdbapi.com/?apikey=${apiKey}&tomatoes=true&i=${imdbId}`,
onload: (res) => {
try {
const json = JSON.parse(res.responseText);
if (!json || json.Response === "False") return;
const dataToCache = {};
if (json.imdbRating && json.imdbRating !== "N/A") {
dataToCache.imdb = json.imdbRating;
ratings.imdb = json.imdbRating;
renderRating(els.imdb, json.imdbRating, 10);
}
const rtObj = json.Ratings ? json.Ratings.find(r => r.Source === "Rotten Tomatoes") : null;
if (rtObj) {
dataToCache.rt = rtObj.Value;
ratings.rt = rtObj.Value.replace('%', '');
if (els.rt) renderRating(els.rt, rtObj.Value.replace('%', ''), 100, '%');
}
if (json.tomatoURL && json.tomatoURL !== "N/A") {
dataToCache.rtUrl = json.tomatoURL;
if (els.rt && els.rt.querySelector('a')) els.rt.querySelector('a').href = json.tomatoURL;
}
if (Object.keys(dataToCache).length > 0) Cache.set(cacheKey, dataToCache);
renderAggregateScore();
} catch (e) {
log("OMDB parse error", e);
}
},
onerror: (e) => log("OMDB request error", e),
ontimeout: () => log("OMDB request timed out")
});
};
const handleLetterboxd = (imdbId, container) => {
const cacheKey = `u3d_lb_${imdbId}`;
const cached = Cache.get(cacheKey);
if (cached) {
ratings.lb = cached;
renderRating(container, cached, 5);
renderAggregateScore();
return;
}
GM.xmlHttpRequest({
method: "GET",
url: `https://letterboxd.com/imdb/${imdbId}`,
onload: (res) => {
if (res.status !== 200) return;
const match = res.responseText.match(/"ratingValue":\s*([0-9.]+)/);
if (match && match[1]) {
const rating = parseFloat(match[1]).toFixed(1);
ratings.lb = rating;
renderRating(container, rating, 5);
Cache.set(cacheKey, rating);
renderAggregateScore();
}
},
onerror: (e) => log("Letterboxd request error", e)
});
};
const handleBluray = (imdbId, container) => {
const cacheKey = `u3d_br_${imdbId}`;
const cached = Cache.get(cacheKey);
const updateUI = (url, rating) => {
const link = container.querySelector('a');
if (link && url) link.href = url;
if (rating) {
ratings.br = rating;
renderRating(container, rating, 10);
}
};
if (cached) {
updateUI(cached.url, cached.rating);
renderAggregateScore();
return;
}
GM.xmlHttpRequest({
method: "GET",
url: `https://www.blu-ray.com/search/?quicksearch=1&quicksearch_keyword=${imdbId}§ion=theatrical`,
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
},
onload: (res) => {
if (res.responseText.includes("No results were found")) {
container.remove();
return;
}
let targetUrl = res.finalUrl;
let rating = null;
const parser = new DOMParser();
const doc = parser.parseFromString(res.responseText, "text/html");
const firstResult = doc.querySelector('.figure');
if (firstResult) {
const linkEl = firstResult.querySelector('a');
if (linkEl && linkEl.href) targetUrl = linkEl.href;
const starEl = firstResult.querySelector('.fa-star');
if (starEl) {
rating = starEl.innerText.trim();
} else {
const textNodes = firstResult.innerText;
const match = textNodes.match(/(\d\.\d)/);
if(match) rating = match[1];
}
} else if (!targetUrl.includes("/search/")) {
const ratingContainer = doc.querySelector('#rating_score') || doc.querySelector('[itemprop="ratingValue"]');
if (ratingContainer) rating = ratingContainer.innerText.trim();
}
if (targetUrl.includes("/search/") && !rating) {
container.remove();
return;
}
Cache.set(cacheKey, { url: targetUrl, rating: rating });
updateUI(targetUrl, rating);
renderAggregateScore();
},
onerror: (e) => log("Blu-ray request error", e)
});
};
const init = () => {
injectStyles();
const metaList = document.querySelector('.meta__ids');
if (metaList) handleFilmweb(metaList);
const imdbLink = document.querySelector('[href*="://www.imdb.com/title/tt"]');
if (!imdbLink) return;
const idMatch = imdbLink.href.match(/tt\d+/);
const imdbId = idMatch ? idMatch[0] : null;
if (!imdbId) return;
const Elements = {
imdb: document.querySelector('.meta__imdb'),
rt: document.querySelector('.meta__rotten'),
tmdb: document.querySelector('.meta__tmdb'),
lb: document.querySelector('.meta__letterboxd'),
br: document.querySelector('.meta__blu-ray') || document.querySelector('a[href*="blu-ray.com"]')?.closest('li')
};
if (Elements.lb) {
const img = Elements.lb.querySelector('img');
if (img && img.src.includes('polishtorrent.top')) {
img.src = 'https://infinityhd.net/img/meta/letterboxd.svg';
}
}
if (Elements.tmdb) {
const tmdbRatingEl = document.querySelector('.work__rating .work__rating-text');
if (tmdbRatingEl) {
const tmdbVal = tmdbRatingEl.innerText.trim();
ratings.tmdb = tmdbVal;
renderRating(Elements.tmdb, tmdbVal, 100);
const oldContainer = document.querySelector('.work__rating');
if(oldContainer) oldContainer.remove();
}
}
if (Elements.imdb || Elements.rt) handleOMDB(imdbId, Elements);
if (Elements.lb) handleLetterboxd(imdbId, Elements.lb);
if (Elements.br) handleBluray(imdbId, Elements.br);
handleMetacritic(imdbId, Elements);
};
init();
})();