Letterboxd Language Switcher

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 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];
        });
    }

})();