Letterboxd Language Switcher

Switch film title, tagline, synopsis and genres via TMDB (no API key required)

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

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

})();