C411 - Customized v2

Ajoute le delta upload/download, les previews d'images, l'ouverture NFO et le bouton .torrent. Version 2 : accessibilité, robustesse, cache.

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

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

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

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

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

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

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

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

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

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

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

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

// ==UserScript==
// @name         C411 - Customized v2
// @namespace    https://c411.org/
// @version      2026.05.06
// @description  Ajoute le delta upload/download, les previews d'images, l'ouverture NFO et le bouton .torrent. Version 2 : accessibilité, robustesse, cache.
// @author       Communauté C411
// @match        https://c411.org/*
// @icon         https://c411.org/favicon.ico
// @grant        GM_xmlhttpRequest
// @connect      c411.org
// @run-at       document-start
// @license      MIT
// @compatible   chrome Tampermonkey
// @compatible   firefox Tampermonkey
// @compatible   firefox Violentmonkey
// @compatible   edge Tampermonkey
// @homepageURL  https://c411.org/community
// ==/UserScript==

(function () {
    'use strict';

    const DEBUG = false;

    const CONFIG = {
        statsApiPath: '/api/auth/me',
        statsRefreshInterval: 30000,
        deltaTextColor: '#e0595b',
        deltaSeparatorColor: '#007a55',
        deltaFontWeight: '600',

        previewDelay: 180,
        thumbPreviewDelay: 100,
        previewMaxHeight: 320,
        previewMaxWidth: 460,
        thumbPreviewMaxWidth: 260,
        thumbPreviewMaxHeight: 380,
        xOffset: 18,
        yOffset: 14,
        requestTimeout: 7000,
        imageProbeTimeout: 5000,
        maxBannerRatio: 1.35,
        previewBorder: '1px solid rgba(0,0,0,.8)',
        previewShadow: '0 8px 18px rgba(0,0,0,.55)',
        previewBackground: '#00bc7d',

        imageCacheMaxSize: 100,

        imageScores: {
            tmdb: 5000,
            original: 300,
            w780: 250,
            w500: 200,
            w300: 150,
            w200: 100,
            w92: 50,
            ibb: 40,
            imgur: 30,
            extension: 20
        },
        imagePenalties: {
            badBanner: 4000,
            flag: 5000,
            c411Square: 5000,
            favicon: 5000,
            logo: 5000,
            icon: 5000,
            avatar: 5000
        }
    };

    const STATE = {
        hoverTimer: null,
        thumbHoverTimer: null,
        currentHoveredLink: null,
        currentHoveredThumb: null,
        lastMouseEvent: null,
        requestSerial: 0,
        cachedStats: null,
        observerScheduled: false,
        globalEscapeBound: false,
        routeHooksBound: false,
        imageCache: new Map(),
        preModalFocusElement: null
    };

    function debug(...args) {
        if (DEBUG) console.debug('[C411]', ...args);
    }

    function unique(arr) {
        return [...new Set(arr.filter(Boolean))];
    }

    function absolutizeUrl(src, baseUrl) {
        try {
            return new URL(src, baseUrl).href;
        } catch {
            return null;
        }
    }

    function gmFetchText(url, responseType = 'text') {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url,
                timeout: CONFIG.requestTimeout,
                responseType,
                onload: (res) => {
                    if (res && typeof res.status === 'number' && res.status >= 400) {
                        reject(new Error(`HTTP ${res.status}`));
                        return;
                    }
                    resolve(res);
                },
                onerror: reject,
                ontimeout: reject
            });
        });
    }

    function decodeHtmlEntities(text) {
        const textarea = document.createElement('textarea');
        textarea.innerHTML = text;
        return textarea.value;
    }

    function cleanupNfoText(text) {
        return decodeHtmlEntities(String(text || ''))
            .replace(/\r\n/g, '\n')
            .replace(/\n{3,}/g, '\n\n')
            .trim();
    }

    function extractTorrentHashFromHref(href) {
        if (!href) return null;
        try {
            const url = new URL(href, location.origin);
            const match = url.pathname.match(/^\/torrents\/([a-f0-9]{40})\/?$/i);
            return match ? match[1] : null;
        } catch {
            return null;
        }
    }

    function isTodayPage() {
        return location.pathname === '/torrents/today';
    }

    function isMainTorrentsPage() {
        return location.pathname === '/torrents';
    }

    function isTorrentDetailsPage() {
        return /^\/torrents\/[a-f0-9]{40}\/?$/i.test(location.pathname);
    }

    function toNum(v) {
        const n = Number(v);
        return Number.isFinite(n) && n >= 0 ? n : null;
    }

    function formatBytesBinaryFR(bytes) {
        if (!Number.isFinite(bytes) || bytes < 0) return null;

        const units = ['o', 'Ko', 'Mo', 'Go', 'To', 'Po'];
        let value = bytes;
        let i = 0;

        while (value >= 1024 && i < units.length - 1) {
            value /= 1024;
            i++;
        }

        return `${value.toFixed(3)} ${units[i]}`;
    }

    function makeSvgIcon(pathsHtml, className = 'shrink-0 size-4') {
        return `
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
                 stroke-width="1.5" stroke="currentColor" aria-hidden="true"
                 class="${className}" data-slot="leadingIcon">
                ${pathsHtml}
            </svg>
        `;
    }

    const NFO_PATHS = `
        <path stroke-linecap="round" stroke-linejoin="round"
              d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375H14.25A2.25 2.25 0 0 1 12 9.375V5.625A3.375 3.375 0 0 0 8.625 2.25H6.75A2.25 2.25 0 0 0 4.5 4.5v15A2.25 2.25 0 0 0 6.75 21.75h10.5A2.25 2.25 0 0 0 19.5 19.5v-5.25Z" />
        <path stroke-linecap="round" stroke-linejoin="round"
              d="M12 3v4.125c0 .621.504 1.125 1.125 1.125H17.25" />
        <path stroke-linecap="round" stroke-linejoin="round"
              d="M7.5 12.75h7.5M7.5 16.5h4.5" />
    `;

    const DOWNLOAD_PATHS = `
        <path stroke-linecap="round" stroke-linejoin="round"
              d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
    `;

    const NFO_ICON = makeSvgIcon(NFO_PATHS);
    const DOWNLOAD_ICON = makeSvgIcon(DOWNLOAD_PATHS);
    const HEADER_NFO_ICON = makeSvgIcon(NFO_PATHS, 'shrink-0 size-4 opacity-70');
    const HEADER_DOWNLOAD_ICON = makeSvgIcon(DOWNLOAD_PATHS, 'shrink-0 size-4 opacity-70');

    function findChildByClassIncludes(root, needle) {
        if (!root) return null;
        return Array.from(root.children).find(el =>
            el instanceof HTMLElement && el.className.includes(needle)
        ) || null;
    }

    function getDirectChildAnchorRows(root) {
        if (!root) return [];
        return Array.from(root.children).filter(el => {
            if (!(el instanceof HTMLAnchorElement)) return false;
            try {
                return /^\/torrents\/[a-f0-9]{40}\/?$/i.test(new URL(el.href, location.origin).pathname);
            } catch {
                return false;
            }
        });
    }

    function extractStats(data) {
        const roots = [data, data?.user, data?.data, data?.profile].filter(Boolean);

        for (const root of roots) {
            const uploaded = toNum(root?.uploaded);
            const downloaded = toNum(root?.downloaded);
            if (uploaded != null && downloaded != null) {
                return { uploaded, downloaded };
            }
        }

        return null;
    }

    async function fetchStats() {
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), CONFIG.requestTimeout);

        try {
            const res = await fetch(CONFIG.statsApiPath, {
                credentials: 'include',
                headers: { accept: 'application/json' },
                signal: controller.signal
            });

            if (!res.ok) return;

            const data = await res.json();
            const stats = extractStats(data);
            if (!stats) return;

            STATE.cachedStats = stats;
            renderDelta();
        } catch (error) {
            debug('fetchStats error:', error);
        } finally {
            clearTimeout(timeoutId);
        }
    }

    function renderDelta() {
        if (!STATE.cachedStats) return;

        const uploadedSpan = document.querySelector('span[title="Uploaded"], span[title^="Uploaded ("]');
        const downloadedSpan = document.querySelector('span[title="Downloaded"], span[title^="Downloaded ("]');

        if (!uploadedSpan || !downloadedSpan || !uploadedSpan.parentElement) return;

        const box = uploadedSpan.parentElement;
        const deltaBytes = Math.max(0, STATE.cachedStats.uploaded - STATE.cachedStats.downloaded);
        const deltaText = `Δ${formatBytesBinaryFR(deltaBytes)}`;

        let deltaSpan = box.querySelector('[data-vm-delta="1"]');
        if (!deltaSpan) {
            deltaSpan = document.createElement('a');
            deltaSpan.dataset.vmDelta = '1';
            deltaSpan.href = '/community/my-rank';
            deltaSpan.target = '_self';
            box.insertBefore(deltaSpan, downloadedSpan);
        }

        let sepSpan = box.querySelector('[data-vm-delta-sep="1"]');
        if (!sepSpan) {
            sepSpan = document.createElement('span');
            sepSpan.dataset.vmDeltaSep = '1';
            sepSpan.textContent = '|';
            box.insertBefore(sepSpan, downloadedSpan);
        }

        deltaSpan.textContent = deltaText;
        deltaSpan.title = `↑ - ↓ = Delta (${deltaBytes} octets)`;
        deltaSpan.style.whiteSpace = 'nowrap';
        deltaSpan.style.color = CONFIG.deltaTextColor;
        deltaSpan.style.fontWeight = CONFIG.deltaFontWeight;
        deltaSpan.style.textDecoration = 'none';
        deltaSpan.style.cursor = 'pointer';

        sepSpan.style.color = CONFIG.deltaSeparatorColor;
        sepSpan.style.fontWeight = CONFIG.deltaFontWeight;
    }

    function initDeltaFetching() {
        fetchStats();
        setInterval(fetchStats, CONFIG.statsRefreshInterval);

        window.addEventListener('focus', fetchStats);
        document.addEventListener('visibilitychange', () => {
            if (!document.hidden) fetchStats();
        });
    }

    function isUsefulImageSrc(src) {
        if (!src || typeof src !== 'string') return false;

        const s = src.toLowerCase();
        if (!/^https?:\/\//.test(s) && !s.startsWith('/')) return false;

        const banned = ['c411_square', '/favicon', 'apple-touch-icon', 'flagcdn', 'emoji', 'icon', 'avatar', 'logo'];
        return !banned.some(fragment => s.includes(fragment));
    }

    function isBadPresentationBanner(src) {
        const s = src.toLowerCase();
        return /undefined-imgur-\d+\.png/i.test(s) || /undefined-imgur\.png/i.test(s);
    }

    function scoreImage(src) {
        const s = src.toLowerCase();
        const bonus = CONFIG.imageScores;
        const malus = CONFIG.imagePenalties;
        let score = 0;

        if (s.includes('image.tmdb.org')) score += bonus.tmdb;
        if (s.includes('/original/')) score += bonus.original;
        if (s.includes('/w780/')) score += bonus.w780;
        if (s.includes('/w500/')) score += bonus.w500;
        if (s.includes('/w300/')) score += bonus.w300;
        if (s.includes('/w200/')) score += bonus.w200;
        if (s.includes('/w92/')) score += bonus.w92;
        if (s.includes('ibb.co')) score += bonus.ibb;
        if (s.includes('imgur')) score += bonus.imgur;
        if (/\.(jpg|jpeg|png|webp)(\?|$)/i.test(s)) score += bonus.extension;

        if (isBadPresentationBanner(s)) score -= malus.badBanner;
        if (s.includes('flagcdn')) score -= malus.flag;
        if (s.includes('c411_square')) score -= malus.c411Square;
        if (s.includes('favicon')) score -= malus.favicon;
        if (s.includes('logo')) score -= malus.logo;
        if (s.includes('icon')) score -= malus.icon;
        if (s.includes('avatar')) score -= malus.avatar;

        return score;
    }

    function pickBestImage(urls) {
        const filtered = unique(urls).filter(isUsefulImageSrc);
        if (!filtered.length) return null;
        return filtered.sort((a, b) => scoreImage(b) - scoreImage(a))[0] || null;
    }

    function extractImageUrlsFromText(text, baseUrl) {
        if (!text || typeof text !== 'string') return [];

        const urls = [];

        const imgTagRegex = /<img[^>]+src=["']([^"']+)["']/gi;
        for (const match of text.matchAll(imgTagRegex)) {
            urls.push(absolutizeUrl(match[1], baseUrl));
        }

        const rawUrlRegex = /https?:\/\/[^\s"'<>]+?(?:jpg|jpeg|png|webp)(?:\?[^\s"'<>]*)?/gi;
        for (const match of text.matchAll(rawUrlRegex)) {
            urls.push(match[0]);
        }

        return unique(urls).filter(isUsefulImageSrc);
    }

    function collectStringsDeep(value, out = []) {
        if (value == null) return out;

        if (typeof value === 'string') {
            out.push(value);
            return out;
        }

        if (Array.isArray(value)) {
            for (const v of value) collectStringsDeep(v, out);
            return out;
        }

        if (typeof value === 'object') {
            for (const key of Object.keys(value)) {
                collectStringsDeep(value[key], out);
            }
        }

        return out;
    }

    function extractImageUrlsFromJson(json, baseUrl) {
        const strings = collectStringsDeep(json, []);
        let urls = [];

        for (const str of strings) {
            urls = urls.concat(extractImageUrlsFromText(str, baseUrl));

            if (/^https?:\/\/.+/i.test(str) || str.startsWith('/')) {
                const abs = absolutizeUrl(str, baseUrl);
                if (abs && isUsefulImageSrc(abs) && /\.(jpg|jpeg|png|webp)(\?|$)/i.test(abs)) {
                    urls.push(abs);
                }
            }
        }

        return unique(urls).filter(isUsefulImageSrc);
    }

    function probeImageDimensions(src) {
        return new Promise((resolve) => {
            const img = new Image();
            let done = false;

            const finish = (result) => {
                if (done) return;
                done = true;
                resolve(result);
            };

            const timer = setTimeout(() => finish(null), CONFIG.imageProbeTimeout);

            img.onload = () => {
                clearTimeout(timer);
                finish({
                    src,
                    width: img.naturalWidth || 0,
                    height: img.naturalHeight || 0
                });
            };

            img.onerror = () => {
                clearTimeout(timer);
                finish(null);
            };

            img.src = src;
        });
    }

    async function chooseLargestUsefulImage(urls) {
        const filtered = unique(urls)
            .filter(isUsefulImageSrc)
            .filter(src => !isBadPresentationBanner(src));

        if (!filtered.length) return null;

        const tmdb = filtered.find(src => src.includes('image.tmdb.org'));
        if (tmdb) return tmdb;

        const probed = await Promise.all(filtered.map(probeImageDimensions));
        const valid = probed
            .filter(Boolean)
            .filter(img => img.width > 0 && img.height > 0)
            .filter(img => (img.width / img.height) <= CONFIG.maxBannerRatio);

        if (!valid.length) return null;

        valid.sort((a, b) => {
            const areaA = a.width * a.height;
            const areaB = b.width * b.height;
            if (areaB !== areaA) return areaB - areaA;
            return b.height - a.height;
        });

        return valid[0]?.src || null;
    }

    function torrentUrlCandidates(url) {
        const u = new URL(url, location.origin);
        const cleanPath = u.pathname.replace(/\/+$/, '');
        const hash = cleanPath.split('/').pop();

        return unique([
            `${u.origin}${cleanPath}/_payload.json`,
            `${u.origin}${cleanPath}/_payload.js`,
            `${u.origin}/api/torrents/${hash}`,
            `${u.origin}/api/torrents/${hash}/details`,
            `${u.origin}/api/torrent/${hash}`,
            `${u.origin}/api/torrent/${hash}/details`,
            `${u.origin}/api/resource/torrents/${hash}`,
            `${u.origin}/api/resources/torrents/${hash}`,
            `${u.origin}${cleanPath}`
        ]);
    }

    async function tryEndpoint(endpoint, pageUrl) {
        try {
            const res = await gmFetchText(endpoint, 'text');
            const contentType = (res.responseHeaders || '').toLowerCase();
            const text = typeof res.responseText === 'string' ? res.responseText : '';

            if (!text) return [];

            if (contentType.includes('application/json') || endpoint.endsWith('.json')) {
                try {
                    return extractImageUrlsFromJson(JSON.parse(text), pageUrl);
                } catch {
                    return extractImageUrlsFromText(text, pageUrl);
                }
            }

            return extractImageUrlsFromText(text, pageUrl);
        } catch {
            return [];
        }
    }

    function removeAllPreviews() {
        document.querySelectorAll('#torrent-preview, #c411-img-preview').forEach(el => el.remove());
    }

    function positionPreview(preview, e, fallbackWidth, fallbackHeight) {
        const rect = preview.getBoundingClientRect();
        const pw = rect.width || fallbackWidth;
        const ph = rect.height || fallbackHeight;

        let top = e.pageY + CONFIG.yOffset + 8;
        let left = e.pageX + CONFIG.xOffset + 6;

        if (e.clientY + ph + CONFIG.yOffset > window.innerHeight) {
            top = e.pageY - ph - CONFIG.yOffset;
        }

        if (e.clientX + pw + CONFIG.xOffset > window.innerWidth) {
            left = e.pageX - pw - CONFIG.xOffset;
        }

        if (top < window.scrollY + 8) top = window.scrollY + 8;
        if (left < 8) left = 8;

        preview.style.top = `${top}px`;
        preview.style.left = `${left}px`;
    }

    function createImageOnlyPreview(id, imgSrc, maxWidth, maxHeight, e) {
        if (!imgSrc || !e) return;

        document.getElementById(id)?.remove();

        const preview = document.createElement('div');
        preview.id = id;
        preview.style.cssText = `
            position:absolute;
            z-index:999999;
            border:${CONFIG.previewBorder};
            box-shadow:${CONFIG.previewShadow};
            background:${CONFIG.previewBackground};
            padding:6px;
            border-radius:6px;
            pointer-events:none;
        `;

        const img = new Image();
        img.src = imgSrc;
        img.style.maxHeight = `${maxHeight}px`;
        img.style.maxWidth = `${maxWidth}px`;
        img.style.display = 'block';
        img.style.borderRadius = '4px';
        img.onerror = () => preview.remove();
        img.onload = () => positionPreview(preview, e, maxWidth, maxHeight);

        preview.appendChild(img);
        document.body.appendChild(preview);
        positionPreview(preview, e, maxWidth, maxHeight);
    }

    function showTorrentPreview(imgSrc, e) {
        document.getElementById('c411-img-preview')?.remove();
        createImageOnlyPreview('torrent-preview', imgSrc, CONFIG.previewMaxWidth, CONFIG.previewMaxHeight, e);
    }

    function showThumbPreview(imgSrc, e) {
        document.getElementById('torrent-preview')?.remove();
        createImageOnlyPreview('c411-img-preview', imgSrc, CONFIG.thumbPreviewMaxWidth, CONFIG.thumbPreviewMaxHeight, e);
    }

    function cacheImageResult(hash, imgSrc) {
        if (!hash) return;
        if (STATE.imageCache.has(hash)) STATE.imageCache.delete(hash);
        STATE.imageCache.set(hash, imgSrc);

        while (STATE.imageCache.size > CONFIG.imageCacheMaxSize) {
            const oldestKey = STATE.imageCache.keys().next().value;
            STATE.imageCache.delete(oldestKey);
        }
    }

    async function fetchTorrentImage(url, callback) {
        const hash = extractTorrentHashFromHref(url);

        if (hash && STATE.imageCache.has(hash)) {
            const cached = STATE.imageCache.get(hash);
            cacheImageResult(hash, cached);
            callback(cached);
            return;
        }

        const currentToken = ++STATE.requestSerial;
        const endpoints = torrentUrlCandidates(url);
        const results = await Promise.all(endpoints.map(endpoint => tryEndpoint(endpoint, url)));

        if (currentToken !== STATE.requestSerial) return;

        const allUrls = unique(results.flat());
        const tmdb = allUrls.find(src => src.includes('image.tmdb.org'));

        if (tmdb) {
            cacheImageResult(hash, tmdb);
            callback(tmdb);
            return;
        }

        const largest = await chooseLargestUsefulImage(allUrls);
        if (currentToken !== STATE.requestSerial) return;

        if (largest) {
            cacheImageResult(hash, largest);
            callback(largest);
            return;
        }

        const best = pickBestImage(allUrls);
        cacheImageResult(hash, best || null);
        callback(best || null);
    }

    function isSmallTmdbThumb(img) {
        if (!img) return false;

        const src = img.currentSrc || img.src || '';
        if (!src.includes('image.tmdb.org/t/p/w92/')) return false;

        const w = img.naturalWidth || img.width || img.clientWidth || 0;
        const h = img.naturalHeight || img.height || img.clientHeight || 0;

        return w <= 120 && h <= 180;
    }

    function getTorrentLink(target) {
        return target?.closest?.('a[href^="/torrents/"]') || null;
    }

    function handleMouseOver(e) {
        const link = getTorrentLink(e.target);

        if (link) {
            STATE.currentHoveredLink = link;
            STATE.lastMouseEvent = {
                pageX: e.pageX,
                pageY: e.pageY,
                clientX: e.clientX,
                clientY: e.clientY
            };

            clearTimeout(STATE.hoverTimer);
            STATE.hoverTimer = setTimeout(() => {
                if (STATE.currentHoveredLink !== link) return;

                fetchTorrentImage(link.href, (imgSrc) => {
                    if (!imgSrc || STATE.currentHoveredLink !== link) return;
                    showTorrentPreview(imgSrc, STATE.lastMouseEvent);
                });
            }, CONFIG.previewDelay);

            return;
        }

        const img = e.target?.closest?.('img');
        if (!isSmallTmdbThumb(img)) return;

        STATE.currentHoveredThumb = img;
        clearTimeout(STATE.thumbHoverTimer);

        const mouseSnapshot = {
            pageX: e.pageX,
            pageY: e.pageY,
            clientX: e.clientX,
            clientY: e.clientY
        };

        STATE.thumbHoverTimer = setTimeout(() => {
            if (STATE.currentHoveredThumb !== img) return;

            const largeSrc = (img.currentSrc || img.src)
                .replace('/w92/', '/w500/')
                .replace('/w154/', '/w500/')
                .replace('/w185/', '/w500/')
                .replace('/w200/', '/w500/')
                .replace('/w300/', '/w500/');

            showThumbPreview(largeSrc, mouseSnapshot);
        }, CONFIG.thumbPreviewDelay);
    }

    function handleMouseOut(e) {
        const link = getTorrentLink(e.target);
        if (link) {
            if (STATE.currentHoveredLink === link) STATE.currentHoveredLink = null;
            clearTimeout(STATE.hoverTimer);
            document.getElementById('torrent-preview')?.remove();
        }

        const leavingImg = e.target?.closest?.('img');
        const enteringImg = e.relatedTarget?.closest?.('img');

        if (isSmallTmdbThumb(leavingImg)) {
            if (STATE.currentHoveredThumb === leavingImg) STATE.currentHoveredThumb = null;
            clearTimeout(STATE.thumbHoverTimer);

            if (!isSmallTmdbThumb(enteringImg)) {
                document.getElementById('c411-img-preview')?.remove();
            }
        }
    }

    function handleMouseMove(e) {
        STATE.lastMouseEvent = {
            pageX: e.pageX,
            pageY: e.pageY,
            clientX: e.clientX,
            clientY: e.clientY
        };

        const torrentPreview = document.getElementById('torrent-preview');
        if (torrentPreview) {
            positionPreview(torrentPreview, e, CONFIG.previewMaxWidth, CONFIG.previewMaxHeight);
        }

        const thumbPreview = document.getElementById('c411-img-preview');
        if (thumbPreview) {
            positionPreview(thumbPreview, e, CONFIG.thumbPreviewMaxWidth, CONFIG.thumbPreviewMaxHeight);
        }
    }

    function handleClickOrMouseDown() {
        STATE.currentHoveredLink = null;
        STATE.currentHoveredThumb = null;
        clearTimeout(STATE.hoverTimer);
        clearTimeout(STATE.thumbHoverTimer);
        removeAllPreviews();
    }

    function initPreview() {
        document.addEventListener('mouseover', handleMouseOver, true);
        document.addEventListener('mouseout', handleMouseOut, true);
        document.addEventListener('mousemove', handleMouseMove, { passive: true });
        document.addEventListener('mousedown', handleClickOrMouseDown, true);
        document.addEventListener('click', handleClickOrMouseDown, true);
    }

    function buildNfoModalStructure() {
        const overlay = document.createElement('div');
        overlay.id = 'c411-nfo-overlay';
        overlay.setAttribute('role', 'dialog');
        overlay.setAttribute('aria-modal', 'true');
        overlay.setAttribute('aria-labelledby', 'c411-nfo-title');
        overlay.style.cssText = `
            position: fixed;
            inset: 0;
            background: rgba(0,0,0,.72);
            z-index: 1000000;
            display: none;
            align-items: center;
            justify-content: center;
            padding: 24px;
        `;

        overlay.innerHTML = `
            <div id="c411-nfo-modal" style="
                width: min(1000px, 96vw);
                height: min(85vh, 900px);
                background: #000000;
                color: #4ade80;
                border: 1px solid rgba(255,255,255,.12);
                border-radius: 12px;
                box-shadow: 0 20px 50px rgba(0,0,0,.5);
                display: flex;
                flex-direction: column;
                overflow: hidden;
            ">
                <div style="
                    display:flex;
                    align-items:center;
                    justify-content:space-between;
                    padding:12px 14px;
                    border-bottom:1px solid rgba(255,255,255,.08);
                    background:#111827;
                    color:#e2e8f0;
                ">
                    <div id="c411-nfo-title" style="font-size:14px;font-weight:600;">NFO</div>
                    <button id="c411-nfo-close" type="button" aria-label="Fermer la modal" style="
                        border:none;
                        background:transparent;
                        color:#cbd5e1;
                        cursor:pointer;
                        font-size:18px;
                        line-height:1;
                        padding:4px 8px;
                        border-radius:6px;
                    ">✕</button>
                </div>
                <pre id="c411-nfo-content" style="
                    margin:0;
                    padding:16px;
                    overflow:auto;
                    white-space:pre;
                    word-break:normal;
                    flex:1;
                    font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
                    background:#000000;
                    color:#4ade80;
                " tabindex="0"></pre>
            </div>
        `;

        return overlay;
    }

    function wireNfoModalEvents(overlay) {
        overlay.addEventListener('click', (event) => {
            if (event.target === overlay) hideNfoModal();
        });

        overlay.querySelector('#c411-nfo-close').addEventListener('click', hideNfoModal);

        overlay.addEventListener('keydown', (event) => {
            if (event.key !== 'Tab') return;

            const focusable = overlay.querySelectorAll(
                'button, [href], [tabindex]:not([tabindex="-1"])'
            );
            if (focusable.length === 0) return;

            const first = focusable[0];
            const last = focusable[focusable.length - 1];

            if (event.shiftKey && document.activeElement === first) {
                event.preventDefault();
                last.focus();
            } else if (!event.shiftKey && document.activeElement === last) {
                event.preventDefault();
                first.focus();
            }
        });
    }

    function ensureNfoModal() {
        let overlay = document.getElementById('c411-nfo-overlay');
        if (overlay) return overlay;

        overlay = buildNfoModalStructure();
        document.body.appendChild(overlay);
        wireNfoModalEvents(overlay);
        return overlay;
    }

    function showNfoModal(content, title = 'NFO') {
        const overlay = ensureNfoModal();
        document.getElementById('c411-nfo-title').textContent = title;
        document.getElementById('c411-nfo-content').textContent = content || 'NFO introuvable.';
        overlay.style.display = 'flex';

        STATE.preModalFocusElement = document.activeElement;
        const closeBtn = document.getElementById('c411-nfo-close');
        if (closeBtn) closeBtn.focus();
    }

    function hideNfoModal() {
        const overlay = document.getElementById('c411-nfo-overlay');
        if (overlay) overlay.style.display = 'none';

        if (STATE.preModalFocusElement && typeof STATE.preModalFocusElement.focus === 'function') {
            try {
                STATE.preModalFocusElement.focus();
            } catch {}
            STATE.preModalFocusElement = null;
        }
    }

    function initGlobalEscape() {
        if (STATE.globalEscapeBound) return;
        STATE.globalEscapeBound = true;

        document.addEventListener('keydown', (event) => {
            if (event.key !== 'Escape') return;
            removeAllPreviews();
            hideNfoModal();
        }, true);
    }

    function extractNfoFromApiPayload(data) {
        if (!data || typeof data !== 'object') return null;

        const candidates = [
            data?.metadata?.nfoContent,
            data?.nfoContent,
            data?.nfo,
            data?.torrent?.metadata?.nfoContent,
            data?.torrent?.nfoContent,
            data?.data?.metadata?.nfoContent,
            data?.data?.nfoContent
        ];

        for (const value of candidates) {
            if (typeof value === 'string' && value.trim().length > 0) {
                return cleanupNfoText(value);
            }
        }

        return null;
    }

    async function fetchTorrentNfo(hash) {
        const endpoint = `/api/torrents/${hash}`;
        const res = await gmFetchText(endpoint, 'text');
        const text = typeof res.responseText === 'string' ? res.responseText : '';

        if (!text) {
            throw new Error('Réponse vide du serveur');
        }

        let data;
        try {
            data = JSON.parse(text);
        } catch {
            throw new Error('Réponse API invalide');
        }

        const nfoText = extractNfoFromApiPayload(data);
        if (nfoText) return nfoText;

        const hasNfo = Boolean(
            data?.metadata?.hasNfo ??
            data?.hasNfo ??
            data?.torrent?.metadata?.hasNfo ??
            data?.data?.metadata?.hasNfo
        );

        if (hasNfo) {
            throw new Error('NFO détecté mais contenu introuvable dans la réponse API');
        }

        throw new Error('NFO introuvable');
    }

    function createActionButton(title, svg, onClick) {
        const button = document.createElement('button');
        button.type = 'button';
        button.title = title;
        button.setAttribute('aria-label', title);
        button.setAttribute('data-state', 'closed');
        button.setAttribute('data-grace-area-trigger', '');
        button.setAttribute('data-slot', 'base');
        button.className = 'rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors text-xs gap-1 text-primary hover:bg-primary/10 active:bg-primary/10 focus:outline-none focus-visible:bg-primary/10 disabled:bg-transparent aria-disabled:bg-transparent dark:disabled:bg-transparent dark:aria-disabled:bg-transparent p-1';
        button.innerHTML = svg;
        button.addEventListener('click', onClick);
        return button;
    }

    async function handleNfoClick(event, linkLike) {
        event.preventDefault();
        event.stopPropagation();

        const hash = extractTorrentHashFromHref(linkLike?.href);
        if (!hash) {
            showNfoModal('Hash introuvable.', 'Erreur');
            return;
        }

        const button = event.currentTarget;
        button.style.pointerEvents = 'none';
        button.style.opacity = '.6';

        try {
            showNfoModal('Chargement du NFO…', 'NFO');
            const nfoText = await fetchTorrentNfo(hash);
            showNfoModal(nfoText, 'NFO');
        } catch (error) {
            showNfoModal(error?.message || 'Erreur pendant le chargement du NFO.', 'Erreur');
        } finally {
            button.style.pointerEvents = '';
            button.style.opacity = '';
        }
    }

    function getTorrentDownloadUrl(hash) {
        return `/api/torrents/${hash}/download`;
    }

    function handleDownloadClick(event, linkLike) {
        event.preventDefault();
        event.stopPropagation();

        const hash = extractTorrentHashFromHref(linkLike?.href);
        if (!hash) return;

        window.location.assign(getTorrentDownloadUrl(hash));
    }

    function findTodayHeaders() {
        if (!isTodayPage()) return [];

        const grids = document.querySelectorAll('div.grid');
        return Array.from(grids).filter(row => {
            const text = row.textContent || '';
            if (!/Nom/.test(text)) return false;
            if (!/Taille/.test(text)) return false;
            if (row.querySelector('a[href^="/torrents/"]')) return false;
            return true;
        });
    }

    function enhanceTodayHeader() {
        const headers = findTodayHeaders();

        for (const header of headers) {
            if (header.dataset.c411TodayActionsHeader === '1') continue;

            header.dataset.c411TodayActionsHeader = '1';
            header.style.gridTemplateColumns = '1fr auto auto auto auto auto auto auto auto';

            const nfoCell = document.createElement('div');
            nfoCell.className = 'w-8 text-center flex items-center justify-center';
            nfoCell.title = 'NFO';
            nfoCell.innerHTML = HEADER_NFO_ICON;

            const dlCell = document.createElement('div');
            dlCell.className = 'w-8 text-center flex items-center justify-center';
            dlCell.title = 'Téléchargement';
            dlCell.innerHTML = HEADER_DOWNLOAD_ICON;

            header.appendChild(nfoCell);
            header.appendChild(dlCell);
        }
    }

    function enhanceTodayTorrentRow(row) {
        if (!row || row.dataset.c411TodayActions === '1') return;

        const hash = extractTorrentHashFromHref(row.href);
        if (!hash) return;

        row.dataset.c411TodayActions = '1';
        row.style.gridTemplateColumns = '1fr auto auto auto auto auto auto auto auto';

        const nfoCell = document.createElement('div');
        nfoCell.className = 'w-8 flex items-center justify-center';

        const dlCell = document.createElement('div');
        dlCell.className = 'w-8 flex items-center justify-center';

        const nfoButton = createActionButton('Afficher le NFO', NFO_ICON, event => handleNfoClick(event, row));
        const dlButton = createActionButton('Télécharger le .torrent', DOWNLOAD_ICON, event => handleDownloadClick(event, row));

        nfoCell.appendChild(nfoButton);
        dlCell.appendChild(dlButton);

        row.appendChild(nfoCell);
        row.appendChild(dlCell);
    }

    function addTodayActions() {
        if (!isTodayPage()) return;

        enhanceTodayHeader();

        const rows = document.querySelectorAll('a[href^="/torrents/"].grid');
        for (const row of rows) {
            enhanceTodayTorrentRow(row);
        }
    }

    function getMainTorrentGridRows() {
        return Array.from(document.querySelectorAll('div[class*="lg:grid"]')).filter(row => {
            if (!(row instanceof HTMLDivElement)) return false;
            if (!row.querySelector('a[href^="/torrents/"]')) return false;
            if (!row.querySelector('button')) return false;
            return true;
        });
    }

    function getMainTorrentHeaderRows() {
        return Array.from(document.querySelectorAll('div[class*="lg:grid"]')).filter(row => {
            if (!(row instanceof HTMLDivElement)) return false;

            const text = row.textContent || '';
            if (!/Nom/.test(text)) return false;
            if (!/Taille/.test(text)) return false;
            if (row.querySelector('a[href^="/torrents/"]')) return false;

            return true;
        });
    }

    function enhanceMainHeaderRow(header) {
        if (!header || header.dataset.c411MainNfoHeader === '1') return;

        header.dataset.c411MainNfoHeader = '1';

        const emptyCells = Array.from(header.children).filter(el =>
            el instanceof HTMLDivElement &&
            el.classList.contains('w-8') &&
            !el.textContent.trim() &&
            !el.querySelector('svg') &&
            !el.querySelector('span')
        );

        for (const cell of emptyCells) {
            cell.remove();
        }

        header.style.gridTemplateColumns = 'auto 1fr auto auto auto auto auto auto auto auto';

        const nfoCell = document.createElement('div');
        nfoCell.className = 'w-8 flex items-center justify-center';
        nfoCell.title = 'NFO';
        nfoCell.innerHTML = HEADER_NFO_ICON;

        const dlCell = document.createElement('div');
        dlCell.className = 'w-8 flex items-center justify-center';
        dlCell.title = 'Téléchargement';
        dlCell.innerHTML = HEADER_DOWNLOAD_ICON;

        header.appendChild(nfoCell);
        header.appendChild(dlCell);
    }

    function enhanceMainTorrentRow(row) {
        if (!row || row.dataset.c411MainNfoRow === '1') return;

        const torrentLink = row.querySelector('a[href^="/torrents/"]');
        if (!torrentLink) return;

        const hash = extractTorrentHashFromHref(torrentLink.href);
        if (!hash) return;

        const downloadCell = Array.from(row.children).find(child => child.querySelector?.('button'));
        if (!downloadCell) return;

        row.dataset.c411MainNfoRow = '1';
        row.style.gridTemplateColumns = 'auto 1fr auto auto auto auto auto auto auto auto';

        const nfoCell = document.createElement('div');
        nfoCell.className = 'w-8 flex justify-center';

        const nfoButton = createActionButton('Afficher le NFO', NFO_ICON, event => {
            handleNfoClick(event, torrentLink);
        });

        nfoCell.appendChild(nfoButton);
        row.insertBefore(nfoCell, downloadCell);
    }

    function addMainTorrentActions() {
        if (!isMainTorrentsPage()) return;

        for (const header of getMainTorrentHeaderRows()) {
            enhanceMainHeaderRow(header);
        }

        for (const row of getMainTorrentGridRows()) {
            enhanceMainTorrentRow(row);
        }
    }

    function findOverviewSlotGroups() {
        if (!isMainTorrentsPage()) return [];

        return Array.from(document.querySelectorAll('div.children-fade-in')).filter(group => {
            const rows = getDirectChildAnchorRows(group);
            if (!rows.length) return false;

            return rows.some(row =>
                !!findChildByClassIncludes(row, 'hidden lg:grid') &&
                !!findChildByClassIncludes(row, 'lg:hidden')
            );
        });
    }

    function insertSlotMobileActions(mobileRow, rowLink) {
        if (!mobileRow || mobileRow.dataset.c411SlotMobileActions === '1') return;

        const infoLine = Array.from(mobileRow.querySelectorAll('div')).find(el =>
            el instanceof HTMLDivElement &&
            el.className.includes('flex items-center gap-2 text-xs text-muted')
        );

        if (!infoLine) return;

        const downloadButton = infoLine.querySelector('button[data-slot="base"]');
        if (!downloadButton || downloadButton.parentElement !== infoLine) return;

        const nfoButton = createActionButton('Afficher le NFO', NFO_ICON, event => handleNfoClick(event, rowLink));
        infoLine.insertBefore(nfoButton, downloadButton);

        mobileRow.dataset.c411SlotMobileActions = '1';
    }

    function enhanceOverviewSlotRow(row) {
        if (!row || row.dataset.c411OverviewSlotRow === '1') return;

        const hash = extractTorrentHashFromHref(row.href);
        if (!hash) return;

        const desktopRow = findChildByClassIncludes(row, 'hidden lg:grid');
        if (desktopRow) {
            desktopRow.style.gridTemplateColumns = 'auto 1fr auto auto auto auto auto auto auto auto';

            const downloadCell = Array.from(desktopRow.children).find(child =>
                child instanceof HTMLElement &&
                child.querySelector?.('button[data-slot="base"]')
            );

            if (downloadCell) {
                const nfoCell = document.createElement('div');
                nfoCell.className = 'w-8 flex justify-center';

                const nfoButton = createActionButton('Afficher le NFO', NFO_ICON, event => handleNfoClick(event, row));
                nfoCell.appendChild(nfoButton);

                desktopRow.insertBefore(nfoCell, downloadCell);
            }
        }

        const mobileRow = findChildByClassIncludes(row, 'lg:hidden');
        if (mobileRow) {
            insertSlotMobileActions(mobileRow, row);
        }

        row.dataset.c411OverviewSlotRow = '1';
    }

    function addOverviewSlotActions() {
        if (!isMainTorrentsPage()) return;

        for (const group of findOverviewSlotGroups()) {
            for (const row of getDirectChildAnchorRows(group)) {
                enhanceOverviewSlotRow(row);
            }
        }
    }

    function findDetailsSlotContainers() {
        if (!isTorrentDetailsPage()) return [];

        return Array.from(document.querySelectorAll('div.slot-fade-in')).filter(container => {
            const rows = getDirectChildAnchorRows(container);
            return rows.length > 0;
        });
    }

    function createInlineSlotActionsCell(rowLink) {
        const wrapper = document.createElement('div');
        wrapper.className = 'flex items-center gap-1 shrink-0';

        const nfoButton = createActionButton('Afficher le NFO', NFO_ICON, event => handleNfoClick(event, rowLink));
        const dlButton = createActionButton('Télécharger le .torrent', DOWNLOAD_ICON, event => handleDownloadClick(event, rowLink));

        wrapper.appendChild(nfoButton);
        wrapper.appendChild(dlButton);

        return wrapper;
    }

    function enhanceDetailsSlotRow(row) {
        if (!row || row.dataset.c411DetailsSlotRow === '1') return;

        const hash = extractTorrentHashFromHref(row.href);
        if (!hash) return;

        const content = row.firstElementChild;
        if (!(content instanceof HTMLElement)) return;

        const flexRow = Array.from(content.querySelectorAll('div')).find(el =>
            el instanceof HTMLDivElement &&
            el.className.includes('flex') &&
            el.className.includes('items-center') &&
            el.className.includes('flex-wrap') &&
            el.className.includes('text-xs')
        );

        if (!flexRow) return;

        if (!flexRow.querySelector('.flex-1')) {
            const spacer = document.createElement('span');
            spacer.className = 'flex-1';
            flexRow.appendChild(spacer);
        }

        const copyBtn = Array.from(flexRow.querySelectorAll('button')).find(btn =>
            btn.querySelector('.i-heroicons\\:document-duplicate, [class*="document-duplicate"]')
        );

        const sizeNode = Array.from(flexRow.children).find(el =>
            el instanceof HTMLElement &&
            el.className.includes('text-muted') &&
            /\b(?:[0-9]+(?:[.,][0-9]+)?\s?(?:Go|Mo|To))\b/i.test(el.textContent || '')
        );

        const markerNode = Array.from(flexRow.children).find(el =>
            el instanceof HTMLElement &&
            (el.className.includes('w-4 shrink-0') || el.className.includes('check-circle-solid'))
        );

        const actions = createInlineSlotActionsCell(row);

        if (markerNode) {
            flexRow.insertBefore(actions, markerNode);
        } else if (sizeNode) {
            flexRow.insertBefore(actions, sizeNode.nextSibling);
        } else if (copyBtn) {
            flexRow.insertBefore(actions, copyBtn.nextSibling);
        } else {
            flexRow.appendChild(actions);
        }

        row.dataset.c411DetailsSlotRow = '1';
    }

    function addDetailsSlotActions() {
        if (!isTorrentDetailsPage()) return;

        for (const container of findDetailsSlotContainers()) {
            for (const row of getDirectChildAnchorRows(container)) {
                enhanceDetailsSlotRow(row);
            }
        }
    }

    function scheduleRerun() {
        if (STATE.observerScheduled) return;
        STATE.observerScheduled = true;

        requestAnimationFrame(() => {
            STATE.observerScheduled = false;
            renderDelta();
            addTodayActions();
            addMainTorrentActions();
            addOverviewSlotActions();
            addDetailsSlotActions();
        });
    }

    function initUnifiedObserver() {
        new MutationObserver(scheduleRerun).observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    function bindRouteHooks() {
        if (STATE.routeHooksBound) return;
        STATE.routeHooksBound = true;

        const wrapHistoryMethod = (method) => {
            const original = history[method];
            history[method] = function (...args) {
                const result = original.apply(this, args);
                try {
                    scheduleRerun();
                } catch (error) {
                    debug('scheduleRerun after history.' + method + ' failed:', error);
                }
                return result;
            };
        };

        wrapHistoryMethod('pushState');
        wrapHistoryMethod('replaceState');

        window.addEventListener('popstate', scheduleRerun);
        window.addEventListener('hashchange', scheduleRerun);
    }

    function init() {
        initDeltaFetching();
        initPreview();
        initGlobalEscape();

        addTodayActions();
        addMainTorrentActions();
        addOverviewSlotActions();
        addDetailsSlotActions();

        initUnifiedObserver();
        bindRouteHooks();
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
        init();
    }
})();