Ajoute le delta upload/download, les previews d'images, l'ouverture NFO et le bouton .torrent. Version 2 : accessibilité, robustesse, cache.
// ==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();
}
})();