Greasy Fork is available in English.
Switch film title, tagline, synopsis and genres via TMDB (no API key required)
// ==UserScript==
// @name Letterboxd Language Switcher
// @namespace http://tampermonkey.net/
// @version 0.2
// @license MIT
// @description Switch film title, tagline, synopsis and genres via TMDB (no API key required)
// @author Mael
// @match https://letterboxd.com/film/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect www.themoviedb.org
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const tmdbId = document.body.dataset.tmdbId;
const tmdbType = document.body.dataset.tmdbType || 'movie';
if (!tmdbId || tmdbType !== 'movie') return;
// Cache — populated lazily on first dropdown open
let cachedMediaId = null;
let cachedLanguages = null; // array of {i18n_tag, english_name}
class WafError extends Error {
constructor() { super('waf'); }
}
// ── Snapshot original content ──────────────────────────────────────────────
const orig = {
title: document.querySelector('.headline-1 .name')?.innerHTML,
tagline: document.querySelector('h4.tagline')?.innerHTML,
synopsisCondenseable: document.querySelector('.production-synopsis .truncate.condenseable p')?.innerHTML,
synopsisCondensed: document.querySelector('.production-synopsis .truncate.condensed p')?.innerHTML,
genres: Array.from(
document.querySelectorAll('#tab-panel-genres div.text-sluglist:first-of-type a.text-slug')
).map(a => a.textContent),
};
// ── Styles ─────────────────────────────────────────────────────────────────
GM_addStyle(`
#lb-lang-switcher {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 13px;
user-select: none;
}
#lb-lang-btn {
background: #1c1c1c;
color: #e0e0e0;
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 7px 11px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
}
#lb-lang-btn:hover { background: #2a2a2a; }
#lb-lang-dropdown {
display: none;
position: absolute;
bottom: 38px;
right: 0;
background: #1c1c1c;
border: 1px solid #3a3a3a;
border-radius: 6px;
min-width: 185px;
max-height: 320px;
overflow-y: auto;
box-shadow: 0 6px 18px rgba(0,0,0,0.6);
}
#lb-lang-dropdown a {
display: block;
padding: 7px 12px;
color: #bbb;
text-decoration: none;
cursor: pointer;
}
#lb-lang-dropdown a:hover { background: #2a2a2a; color: #fff; }
#lb-lang-dropdown a.active { color: #6bc64d; }
#lb-lang-dropdown .lb-loading {
padding: 10px 12px;
color: #666;
font-style: italic;
font-size: 12px;
}
#lb-lang-dropdown .lb-waf-msg {
padding: 8px 12px 4px;
color: #bbb;
font-size: 12px;
line-height: 1.4;
}
#lb-lang-dropdown .lb-waf-action {
display: block;
padding: 7px 12px;
color: #bbb;
text-decoration: none;
cursor: pointer;
border-top: 1px solid #333;
}
#lb-lang-dropdown .lb-waf-action:hover { background: #2a2a2a; color: #fff; }
#lb-lang-dropdown hr { border: none; border-top: 1px solid #333; margin: 0; }
#lb-lang-status {
font-size: 10px;
color: #666;
text-align: right;
margin-top: 3px;
height: 13px;
}
`);
// ── DOM ────────────────────────────────────────────────────────────────────
const root = document.createElement('div');
root.id = 'lb-lang-switcher';
const btn = document.createElement('button');
btn.id = 'lb-lang-btn';
btn.textContent = '🌐 Language';
const dropdown = document.createElement('div');
dropdown.id = 'lb-lang-dropdown';
const status = document.createElement('div');
status.id = 'lb-lang-status';
root.appendChild(dropdown);
root.appendChild(btn);
root.appendChild(status);
document.body.appendChild(root);
// ── UI behaviour ───────────────────────────────────────────────────────────
btn.addEventListener('click', e => {
e.stopPropagation();
if (dropdown.style.display === 'block') {
dropdown.style.display = 'none';
return;
}
openDropdown();
});
// Clicking inside the dropdown should not bubble up to document
dropdown.addEventListener('click', e => e.stopPropagation());
document.addEventListener('click', () => { dropdown.style.display = 'none'; });
function openDropdown() {
dropdown.style.display = 'block';
if (cachedLanguages) return; // already populated
dropdown.innerHTML = '<div class="lb-loading">Loading languages…</div>';
loadLanguages()
.then(langs => {
cachedLanguages = langs;
populateDropdown(langs);
})
.catch(err => {
if (err instanceof WafError) { showWafHelp(); }
else { dropdown.innerHTML = `<div class="lb-loading">${err.message}</div>`; }
console.error('[lb-lang]', err);
});
}
function populateDropdown(langs) {
dropdown.innerHTML = '';
const origItem = document.createElement('a');
origItem.textContent = '↩ Original (Letterboxd)';
origItem.dataset.lang = 'original';
dropdown.appendChild(origItem);
const hr = document.createElement('hr');
dropdown.appendChild(hr);
langs.forEach(({ i18n_tag, english_name }) => {
const a = document.createElement('a');
a.textContent = `${english_name} (${i18n_tag})`;
a.dataset.lang = i18n_tag;
dropdown.appendChild(a);
});
dropdown.addEventListener('click', onLanguageClick);
}
function onLanguageClick(e) {
e.preventDefault();
const langCode = e.target.dataset.lang;
if (!langCode) return;
dropdown.style.display = 'none';
dropdown.querySelectorAll('a').forEach(a => a.classList.remove('active'));
if (langCode === 'original') {
restoreOriginal();
btn.textContent = '🌐 Language';
status.textContent = '';
return;
}
e.target.classList.add('active');
btn.textContent = '⏳ Loading…';
status.textContent = '';
fetchTMDB(tmdbId, langCode)
.then(data => {
applyTranslation(data);
btn.textContent = `🌐 ${e.target.textContent}`;
status.textContent = 'via TMDB';
})
.catch(err => {
btn.textContent = '🌐 Language';
if (err instanceof WafError) {
status.textContent = '';
showWafHelp();
} else {
status.textContent = err.message;
}
console.error('[lb-lang]', err);
});
}
function showWafHelp() {
// Opens TMDB in a new tab (allowed since triggered by a user click) and
// shows a one-click Retry so the user never sees a raw error message.
cachedMediaId = null;
cachedLanguages = null;
window.open(`https://www.themoviedb.org/movie/${tmdbId}`, '_blank');
dropdown.innerHTML = '';
dropdown.style.display = 'block';
const msg = document.createElement('div');
msg.className = 'lb-waf-msg';
msg.textContent = 'TMDB opened in a new tab. Wait for it to load, then:';
dropdown.appendChild(msg);
const retryBtn = document.createElement('a');
retryBtn.className = 'lb-waf-action';
retryBtn.textContent = '↻ Retry';
retryBtn.href = '#';
retryBtn.addEventListener('click', e => {
e.preventDefault();
openDropdown();
});
dropdown.appendChild(retryBtn);
}
// ── Language list loading ──────────────────────────────────────────────────
// Flow: fetch TMDB page (en-US) → extract media_id via regex →
// fetch /translation-popup → parse JSON data array
function loadLanguages() {
const getMediaId = cachedMediaId
? Promise.resolve(cachedMediaId)
: fetchHTML(`https://www.themoviedb.org/movie/${tmdbId}?language=en-US`).then(html => {
const m = html.match(/media_id=([a-f0-9]{24})/);
if (!m) throw new Error('media_id not found — TMDB page structure may have changed');
cachedMediaId = m[1];
return cachedMediaId;
});
return getMediaId.then(mediaId => {
const referral = encodeURIComponent(`/movie/${tmdbId}`);
const url = `https://www.themoviedb.org/translation-popup?language=en-US&media_type=Movie&media_id=${mediaId}&referral=${referral}&_=${Date.now()}`;
return fetchHTML(url, {
'Accept': 'text/html, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'Referer': `https://www.themoviedb.org/movie/${tmdbId}`,
}).then(html => {
const m = html.match(/\bdata:\s*(\[[\s\S]*?\]),\s*\n/);
if (!m) throw new Error('Language data not found in TMDB popup');
const langs = JSON.parse(m[1]);
return langs.sort((a, b) => a.english_name.localeCompare(b.english_name));
});
});
}
// ── Generic HTML fetch via GM_xmlhttpRequest ───────────────────────────────
function fetchHTML(url, extraHeaders = {}) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: { Accept: 'text/html', ...extraHeaders },
onload(resp) {
if (resp.status === 403 || resp.status === 503) {
reject(new WafError());
return;
}
if (resp.status >= 400) {
reject(new Error(`TMDB error ${resp.status}`));
return;
}
resolve(resp.responseText);
},
onerror() { reject(new Error('Network error')); },
});
});
}
// ── TMDB content fetch + parse ─────────────────────────────────────────────
function fetchTMDB(id, lang) {
return fetchHTML(`https://www.themoviedb.org/movie/${id}?language=${lang}`)
.then(html => {
const doc = new DOMParser().parseFromString(html, 'text/html');
return parseTMDB(doc);
});
}
function parseTMDB(doc) {
const rawTitle = doc.querySelector('.title h2')?.textContent?.trim() ?? '';
const title = rawTitle.replace(/\s*\(\d{4}\)\s*$/, '').trim() || null;
const tagline = doc.querySelector('h3.tagline')?.textContent?.trim() || null;
const synopsis = doc.querySelector('div.overview p')?.textContent?.trim() || null;
const genres = Array.from(doc.querySelectorAll('.genres a'))
.map(a => a.textContent.trim())
.filter(Boolean);
return { title, tagline, synopsis, genres };
}
// ── Apply / restore ────────────────────────────────────────────────────────
function applyTranslation({ title, tagline, synopsis, genres }) {
if (title) {
const el = document.querySelector('.headline-1 .name');
if (el) el.textContent = title;
}
const taglineEl = document.querySelector('h4.tagline');
if (taglineEl) taglineEl.textContent = tagline ?? '';
if (synopsis) {
const condenseableEl = document.querySelector('.production-synopsis .truncate.condenseable p');
const condensedEl = document.querySelector('.production-synopsis .truncate.condensed p');
if (condenseableEl) condenseableEl.textContent = synopsis;
if (condensedEl) condensedEl.textContent = synopsis;
}
if (genres.length > 0) {
const links = document.querySelectorAll(
'#tab-panel-genres div.text-sluglist:first-of-type a.text-slug'
);
links.forEach((link, i) => {
if (genres[i]) link.textContent = genres[i];
});
}
}
function restoreOriginal() {
const titleEl = document.querySelector('.headline-1 .name');
if (titleEl && orig.title !== undefined) titleEl.innerHTML = orig.title;
const taglineEl = document.querySelector('h4.tagline');
if (taglineEl && orig.tagline !== undefined) taglineEl.innerHTML = orig.tagline;
const condenseableEl = document.querySelector('.production-synopsis .truncate.condenseable p');
if (condenseableEl && orig.synopsisCondenseable !== undefined) condenseableEl.innerHTML = orig.synopsisCondenseable;
const condensedEl = document.querySelector('.production-synopsis .truncate.condensed p');
if (condensedEl && orig.synopsisCondensed !== undefined) condensedEl.innerHTML = orig.synopsisCondensed;
const links = document.querySelectorAll(
'#tab-panel-genres div.text-sluglist:first-of-type a.text-slug'
);
links.forEach((link, i) => {
if (orig.genres[i] !== undefined) link.textContent = orig.genres[i];
});
}
})();