C411 - Customized v2

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

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 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.

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

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

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

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

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

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