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