C411 - Customized v2

Adaptation au preview natif C411 : injection d'images dans les cards sans TMDB. Delta upload/download, ouverture NFO, bouton .torrent.

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.25
// @description  Adaptation au preview natif C411 : injection d'images dans les cards sans TMDB. Delta upload/download, ouverture NFO, bouton .torrent.
// @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;

    // ── CONFIG ────────────────────────────────────────────────────────────────

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

        requestTimeout: 7000,
        imageProbeTimeout: 5000,
        maxBannerRatio: 1.35,
        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
        }
    };

    // ── STATE ─────────────────────────────────────────────────────────────────

    const STATE = {
        cachedStats: null,
        observerScheduled: false,
        globalEscapeBound: false,
        routeHooksBound: false,
        imageCache: new Map(),
        preModalFocusElement: null
    };

    // ── UTILS ─────────────────────────────────────────────────────────────────

    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) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET', url,
                timeout: CONFIG.requestTimeout,
                responseType: 'text',
                onload: res => {
                    if (res?.status >= 400) { reject(new Error(`HTTP ${res.status}`)); return; }
                    resolve(res);
                },
                onerror: reject,
                ontimeout: reject
            });
        });
    }

    function decodeHtmlEntities(text) {
        const ta = document.createElement('textarea');
        ta.innerHTML = text;
        return ta.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 m = new URL(href, location.origin).pathname.match(/^\/torrents\/([a-f0-9]{40})\/?$/i);
            return m ? m[1] : null;
        } catch { return null; }
    }

    const isTodayPage          = () => location.pathname === '/torrents/today';
    const isMainTorrentsPage   = () => location.pathname === '/torrents';
    const isTorrentDetailsPage = () => /^\/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 v = bytes, i = 0;
        while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
        return `${v.toFixed(3)} ${units[i]}`;
    }

    // ── ICONS ─────────────────────────────────────────────────────────────────

    function makeSvgIcon(pathsHtml, cls = '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="${cls}" 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');

    // ── DOM HELPERS ───────────────────────────────────────────────────────────

    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; }
        });
    }

    // ── STATS / DELTA ─────────────────────────────────────────────────────────

    function extractStats(data) {
        for (const root of [data, data?.user, data?.data, data?.profile].filter(Boolean)) {
            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 tid = 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 stats = extractStats(await res.json());
            if (!stats) return;
            STATE.cachedStats = stats;
            renderDelta();
        } catch (e) {
            debug('fetchStats:', e);
        } finally {
            clearTimeout(tid);
        }
    }

    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);

        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          = `Δ${formatBytesBinaryFR(deltaBytes)}`;
        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(); });
    }

    // ── IMAGE FETCHING ────────────────────────────────────────────────────────

    function isUsefulImageSrc(src) {
        if (!src || typeof src !== 'string') return false;
        const s = src.toLowerCase();
        if (!/^https?:\/\//.test(s) && !s.startsWith('/')) return false;
        return !['c411_square', '/favicon', 'apple-touch-icon', 'flagcdn', 'emoji', 'icon', 'avatar', 'logo']
            .some(f => s.includes(f));
    }

    function isBadPresentationBanner(src) {
        return /undefined-imgur(?:-\d+)?\.png/i.test(src);
    }

    function scoreImage(src) {
        const s = src.toLowerCase();
        const { imageScores: b, imagePenalties: p } = CONFIG;
        let score = 0;
        if (s.includes('image.tmdb.org')) score += b.tmdb;
        if (s.includes('/original/'))     score += b.original;
        if (s.includes('/w780/'))         score += b.w780;
        if (s.includes('/w500/'))         score += b.w500;
        if (s.includes('/w300/'))         score += b.w300;
        if (s.includes('/w200/'))         score += b.w200;
        if (s.includes('/w92/'))          score += b.w92;
        if (s.includes('ibb.co'))         score += b.ibb;
        if (s.includes('imgur'))          score += b.imgur;
        if (/\.(jpg|jpeg|png|webp)(\?|$)/i.test(s)) score += b.extension;
        if (isBadPresentationBanner(s))   score -= p.badBanner;
        if (s.includes('flagcdn'))        score -= p.flag;
        if (s.includes('c411_square'))    score -= p.c411Square;
        if (s.includes('favicon'))        score -= p.favicon;
        if (s.includes('logo'))           score -= p.logo;
        if (s.includes('icon'))           score -= p.icon;
        if (s.includes('avatar'))         score -= p.avatar;
        return score;
    }

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

    function extractImageUrlsFromText(text, baseUrl) {
        if (!text || typeof text !== 'string') return [];
        const urls = [];
        for (const m of text.matchAll(/<img[^>]+src=["']([^"']+)["']/gi))
            urls.push(absolutizeUrl(m[1], baseUrl));
        for (const m of text.matchAll(/https?:\/\/[^\s"'<>]+?(?:jpg|jpeg|png|webp)(?:\?[^\s"'<>]*)?/gi))
            urls.push(m[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 k of Object.keys(value)) collectStringsDeep(value[k], out); }
        return out;
    }

    function extractImageUrlsFromJson(json, baseUrl) {
        let urls = [];
        for (const str of collectStringsDeep(json)) {
            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 = r => { if (!done) { done = true; resolve(r); } };
            const tid = setTimeout(() => finish(null), CONFIG.imageProbeTimeout);
            img.onload  = () => { clearTimeout(tid); finish({ src, width: img.naturalWidth, height: img.naturalHeight }); };
            img.onerror = () => { clearTimeout(tid); finish(null); };
            img.src = src;
        });
    }

    async function chooseLargestUsefulImage(urls) {
        const filtered = unique(urls).filter(isUsefulImageSrc).filter(s => !isBadPresentationBanner(s));
        if (!filtered.length) return null;

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

        const valid = (await Promise.all(filtered.map(probeImageDimensions)))
            .filter(r => r?.width > 0 && r?.height > 0 && (r.width / r.height) <= CONFIG.maxBannerRatio)
            .sort((a, b) => (b.width * b.height) - (a.width * a.height) || 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 [
            `${u.origin}${cleanPath}/_payload.json`,
            `${u.origin}${cleanPath}/_payload.js`,
            `${u.origin}/api/torrents/${hash}`
        ];
    }

    async function tryEndpoint(endpoint, pageUrl) {
        try {
            const res  = await gmFetchText(endpoint);
            const text = typeof res.responseText === 'string' ? res.responseText : '';
            if (!text) return [];
            const isJson = (res.responseHeaders || '').toLowerCase().includes('application/json') || endpoint.endsWith('.json');
            if (isJson) {
                try { return extractImageUrlsFromJson(JSON.parse(text), pageUrl); } catch { /**/ }
            }
            return extractImageUrlsFromText(text, pageUrl);
        } catch {
            return [];
        }
    }

    function cacheImageResult(hash, imgSrc) {
        if (!hash) return;
        STATE.imageCache.delete(hash);
        STATE.imageCache.set(hash, imgSrc);
        if (STATE.imageCache.size > CONFIG.imageCacheMaxSize)
            STATE.imageCache.delete(STATE.imageCache.keys().next().value);
    }

    async function fetchTorrentImage(url, callback) {
        const hash = extractTorrentHashFromHref(url);
        if (hash && STATE.imageCache.has(hash)) {
            callback(STATE.imageCache.get(hash));
            return;
        }

        const results = await Promise.all(torrentUrlCandidates(url).map(ep => tryEndpoint(ep, url)));
        const allUrls = unique(results.flat());

        const tmdb = allUrls.find(s => s.includes('image.tmdb.org'));
        if (tmdb) { cacheImageResult(hash, tmdb); callback(tmdb); return; }

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

    // ── NATIVE PREVIEW ENHANCER ───────────────────────────────────────────────

    function injectImageIntoNativePreview(card, imgSrc) {
        const wrap = document.createElement('div');
        wrap.style.cssText = 'margin:-20px -20px 16px;border-radius:14px 14px 0 0;overflow:hidden;position:relative;';

        const img = document.createElement('img');
        img.src   = imgSrc;
        img.alt   = '';
        img.style.cssText = 'display:block;width:100%;height:auto;';
        img.onerror = () => wrap.remove();

        const fade = document.createElement('div');
        fade.style.cssText = 'pointer-events:none;position:absolute;left:0;right:0;bottom:0;height:60px;background:linear-gradient(to top,rgba(0,0,0,.75),transparent);';

        wrap.append(img, fade);
        card.prepend(wrap);
    }

    function tryEnhanceNativePreviewCard(card) {
        if (card.dataset.c411Enhanced === '1') return;
        if (card.querySelector('.animate-spin')) return;
        if (card.querySelector('img')) return;
        if (!card.querySelector('p, h3')) return;

        const link = document.querySelector('a[href^="/torrents/"][data-state="open"]');
        if (!link) return;

        card.dataset.c411Enhanced = '1';
        card.style.width = '340px';

        fetchTorrentImage(link.href, imgSrc => {
            if (imgSrc && document.contains(card)) injectImageIntoNativePreview(card, imgSrc);
        });
    }

    function initNativePreviewEnhancer() {
        new MutationObserver(mutations => {
            for (const mutation of mutations) {
                if (mutation.type === 'childList') {
                    for (const node of mutation.addedNodes) {
                        if (!(node instanceof HTMLElement)) continue;
                        const card = node.classList.contains('preview-card')
                            ? node : node.querySelector('.preview-card');
                        if (card) tryEnhanceNativePreviewCard(card);
                    }
                } else if (mutation.type === 'attributes') {
                    const el = mutation.target;
                    if (el instanceof HTMLElement && el.classList.contains('preview-card'))
                        tryEnhanceNativePreviewCard(el);
                }
            }
        }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
    }

    // ── NFO MODAL ─────────────────────────────────────────────────────────────

    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:#000;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" 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:#000;color:#4ade80;" tabindex="0"></pre>
            </div>`;

        return overlay;
    }

    function wireNfoModalEvents(overlay) {
        overlay.addEventListener('click', e => { if (e.target === overlay) hideNfoModal(); });
        overlay.querySelector('#c411-nfo-close').addEventListener('click', hideNfoModal);

        overlay.addEventListener('keydown', e => {
            if (e.key !== 'Tab') return;
            const focusable = [...overlay.querySelectorAll('button,[href],[tabindex]:not([tabindex="-1"])')];
            if (!focusable.length) return;
            const [first, last] = [focusable[0], focusable[focusable.length - 1]];
            if (e.shiftKey && document.activeElement === first)       { e.preventDefault(); last.focus(); }
            else if (!e.shiftKey && document.activeElement === last)  { e.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;
        document.getElementById('c411-nfo-close')?.focus();
    }

    function hideNfoModal() {
        const overlay = document.getElementById('c411-nfo-overlay');
        if (overlay) overlay.style.display = 'none';
        try { STATE.preModalFocusElement?.focus(); } catch { /**/ }
        STATE.preModalFocusElement = null;
    }

    function initGlobalEscape() {
        if (STATE.globalEscapeBound) return;
        STATE.globalEscapeBound = true;
        document.addEventListener('keydown', e => { if (e.key === 'Escape') 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 v of candidates) {
            if (typeof v === 'string' && v.trim()) return cleanupNfoText(v);
        }
        return null;
    }

    async function fetchTorrentNfo(hash) {
        const res  = await gmFetchText(`/api/torrents/${hash}`);
        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
        );
        throw new Error(hasNfo
            ? 'NFO détecté mais contenu introuvable dans la réponse API'
            : 'NFO introuvable'
        );
    }

    // ── ACTION BUTTONS ────────────────────────────────────────────────────────

    function createActionButton(title, svg, onClick) {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.title = title;
        btn.setAttribute('aria-label', title);
        btn.setAttribute('data-state', 'closed');
        btn.setAttribute('data-grace-area-trigger', '');
        btn.setAttribute('data-slot', 'base');
        btn.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';
        btn.innerHTML = svg;
        btn.addEventListener('click', onClick);
        return btn;
    }

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

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

        const btn = event.currentTarget;
        btn.style.cssText = 'pointer-events:none;opacity:.6;';
        try {
            showNfoModal('Chargement du NFO…', 'NFO');
            showNfoModal(await fetchTorrentNfo(hash), 'NFO');
        } catch (e) {
            showNfoModal(e?.message || 'Erreur pendant le chargement du NFO.', 'Erreur');
        } finally {
            btn.style.cssText = '';
        }
    }

    function handleDownloadClick(event, linkLike) {
        event.preventDefault();
        event.stopPropagation();
        const hash = extractTorrentHashFromHref(linkLike?.href);
        if (hash) window.location.assign(`/api/torrents/${hash}/download`);
    }

    // ── TODAY PAGE ────────────────────────────────────────────────────────────

    function findTodayHeaders() {
        if (!isTodayPage()) return [];
        return Array.from(document.querySelectorAll('div.grid')).filter(row => {
            const t = row.textContent || '';
            return /Nom/.test(t) && /Taille/.test(t) && !row.querySelector('a[href^="/torrents/"]');
        });
    }

    function enhanceTodayHeader() {
        for (const header of findTodayHeaders()) {
            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.append(nfoCell, dlCell);
        }
    }

    function enhanceTodayTorrentRow(row) {
        if (!row || row.dataset.c411TodayActions === '1') return;
        if (!extractTorrentHashFromHref(row.href)) 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';
        nfoCell.appendChild(createActionButton('Afficher le NFO', NFO_ICON, e => handleNfoClick(e, row)));

        const dlCell = document.createElement('div');
        dlCell.className = 'w-8 flex items-center justify-center';
        dlCell.appendChild(createActionButton('Télécharger le .torrent', DOWNLOAD_ICON, e => handleDownloadClick(e, row)));

        row.append(nfoCell, dlCell);
    }

    function addTodayActions() {
        if (!isTodayPage()) return;
        enhanceTodayHeader();
        document.querySelectorAll('a[href^="/torrents/"].grid').forEach(enhanceTodayTorrentRow);
    }

    // ── MAIN TORRENTS PAGE ────────────────────────────────────────────────────

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

    function getMainTorrentHeaderRows() {
        return Array.from(document.querySelectorAll('div[class*="lg:grid"]')).filter(row => {
            if (!(row instanceof HTMLDivElement)) return false;
            const t = row.textContent || '';
            return /Nom/.test(t) && /Taille/.test(t) && !row.querySelector('a[href^="/torrents/"]');
        });
    }

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

        Array.from(header.children)
            .filter(el => el instanceof HTMLDivElement && el.classList.contains('w-8') &&
                          !el.textContent.trim() && !el.querySelector('svg,span'))
            .forEach(el => el.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.append(nfoCell, dlCell);
    }

    function enhanceMainTorrentRow(row) {
        if (!row || row.dataset.c411MainNfoRow === '1') return;
        const torrentLink = row.querySelector('a[href^="/torrents/"]');
        if (!torrentLink || !extractTorrentHashFromHref(torrentLink.href)) return;
        const downloadCell = Array.from(row.children).find(c => c.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';
        nfoCell.appendChild(createActionButton('Afficher le NFO', NFO_ICON, e => handleNfoClick(e, torrentLink)));
        row.insertBefore(nfoCell, downloadCell);
    }

    function addMainTorrentActions() {
        if (!isMainTorrentsPage()) return;
        getMainTorrentHeaderRows().forEach(enhanceMainHeaderRow);
        getMainTorrentGridRows().forEach(enhanceMainTorrentRow);
    }

    function findOverviewSlotGroups() {
        if (!isMainTorrentsPage()) return [];
        return Array.from(document.querySelectorAll('div.children-fade-in')).filter(group => {
            const rows = getDirectChildAnchorRows(group);
            return rows.length && rows.some(r =>
                findChildByClassIncludes(r, 'hidden lg:grid') && findChildByClassIncludes(r, '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;

        infoLine.insertBefore(
            createActionButton('Afficher le NFO', NFO_ICON, e => handleNfoClick(e, rowLink)),
            downloadButton
        );
        mobileRow.dataset.c411SlotMobileActions = '1';
    }

    function enhanceOverviewSlotRow(row) {
        if (!row || row.dataset.c411OverviewSlotRow === '1') return;
        if (!extractTorrentHashFromHref(row.href)) 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(c =>
                c instanceof HTMLElement && c.querySelector?.('button[data-slot="base"]')
            );
            if (downloadCell) {
                const nfoCell = document.createElement('div');
                nfoCell.className = 'w-8 flex justify-center';
                nfoCell.appendChild(createActionButton('Afficher le NFO', NFO_ICON, e => handleNfoClick(e, row)));
                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())
            getDirectChildAnchorRows(group).forEach(enhanceOverviewSlotRow);
    }

    // ── DETAILS PAGE ──────────────────────────────────────────────────────────

    function findDetailsSlotContainers() {
        if (!isTorrentDetailsPage()) return [];
        return Array.from(document.querySelectorAll('div.slot-fade-in'))
            .filter(c => getDirectChildAnchorRows(c).length > 0);
    }

    function createInlineSlotActionsCell(rowLink) {
        const wrapper = document.createElement('div');
        wrapper.className = 'flex items-center gap-1 shrink-0';
        wrapper.appendChild(createActionButton('Afficher le NFO', NFO_ICON, e => handleNfoClick(e, rowLink)));
        wrapper.appendChild(createActionButton('Télécharger le .torrent', DOWNLOAD_ICON, e => handleDownloadClick(e, rowLink)));
        return wrapper;
    }

    function enhanceDetailsSlotRow(row) {
        if (!row || row.dataset.c411DetailsSlotRow === '1') return;
        if (!extractTorrentHashFromHref(row.href)) 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(b =>
            b.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\d+(?:[.,]\d+)?\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())
            getDirectChildAnchorRows(container).forEach(enhanceDetailsSlotRow);
    }

    // ── OBSERVER / ROUTING ────────────────────────────────────────────────────

    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;

        for (const method of ['pushState', 'replaceState']) {
            const orig = history[method];
            history[method] = function (...args) {
                const result = orig.apply(this, args);
                try { scheduleRerun(); } catch (e) { debug(`history.${method}:`, e); }
                return result;
            };
        }

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

    // ── BOOT ──────────────────────────────────────────────────────────────────

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

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

        initUnifiedObserver();
        bindRouteHooks();
    }

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