Letterboxd Language Switcher

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

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

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

})();