C411 - Versions audio

Affiche un tableau des autres editions / formats disponibles pour un meme album sur les pages de torrents audio C411 (equivalent du bloc « Versions » natif cote video).

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 - Versions audio
// @namespace    https://c411.org/
// @version      2026.05.14-1
// @description  Affiche un tableau des autres editions / formats disponibles pour un meme album sur les pages de torrents audio C411 (equivalent du bloc « Versions » natif cote video).
// @author       CuOgmy
// @license      MIT
// @homepageURL  https://greasyfork.org/fr/scripts/577231-c411-versions-audio
// @supportURL   https://greasyfork.org/fr/scripts/577231-c411-versions-audio/feedback
// @match        https://c411.org/*
// @icon         https://c411.org/favicon.ico
// @grant        none
// @run-at       document-end
// @compatible   chrome Tampermonkey
// @compatible   chrome Violentmonkey
// @compatible   firefox Tampermonkey
// @compatible   firefox Violentmonkey
// @compatible   edge Tampermonkey
// ==/UserScript==

(function () {
    'use strict';

    // ─── 00-config.js ───
    // ═══════════════════════════════════════════════════════════════════════
    // SECTION : CONFIGURATION
    // ═══════════════════════════════════════════════════════════════════════

    const CONFIG = {
        // Catégorie « Audio » dans la nomenclature C411 (cf. /user/integrations).
        audioCategoryId: 3,

        // Timeout des appels API (ms).
        timeout: 7000,

        // Nombre maximum de candidats demandés par requête de recherche.
        // Au-delà de quelques dizaines, c'est qu'il y a un faux match côté query.
        perPage: 50,

        // Année plancher / plafond pour ancrer le parsing du titre.
        // Pattern guide : Artiste.Album.Année.Codec...
        yearRegex: /\b(19\d{2}|20\d{2})\b/,

        // Délai et nombre max de tentatives pour injecter le tableau dans
        // le DOM (le site est rendu via Nuxt SSR + hydratation Vue, donc
        // certains points d'ancrage peuvent apparaître tardivement).
        injectRetryDelay: 200,
        injectMaxRetries: 25,

        // Debounce du routeur SPA (ms).
        routerDebounce: 50,

        // Identifiant unique du bloc injecté, sert à éviter les doublons
        // et à nettoyer lors d'une navigation interne.
        elementId: 'c411-audio-versions',

        // Active des logs verbeux en console.
        debug: false
    };

    // ─── 10-utils.js ───
    // ═══════════════════════════════════════════════════════════════════════
    // SECTION : UTILITAIRES
    // ═══════════════════════════════════════════════════════════════════════

    function debug(...args) {
        if (CONFIG.debug) console.log('[c411-audio-versions]', ...args);
    }

    // Extrait l'infoHash (40 chars hex SHA1) d'une URL de torrent.
    // Retourne null si la page courante n'est pas une fiche torrent.
    function extractInfoHashFromUrl(pathname) {
        const match = pathname.match(/^\/torrents\/([a-f0-9]{40})\b/i);
        return match ? match[1].toLowerCase() : null;
    }

    // Normalisation pour matching : minuscules, sans diacritiques, sans
    // ponctuation. Permet de tolérer les variations mineures dans la frappe
    // d'un même album entre uploads (ex. apostrophes, tirets, etc.).
    function normalize(s) {
        if (!s) return '';
        return s
            .toLowerCase()
            .normalize('NFD')
            .replace(/[\u0300-\u036f]/g, '')
            .replace(/[^a-z0-9]+/g, ' ')
            .replace(/\s+/g, ' ')
            .trim();
    }

    // Qualificatifs d'édition fréquents qu'on retire en suffixe d'un préfixe
    // normalisé pour le matching. Permet de regrouper « Nirvana Nevermind »
    // et « Nirvana Nevermind Remaster » comme variantes du même album.
    // Liste volontairement conservatrice — on n'enlève que des termes qui
    // dénotent clairement une édition, pas des mots qui pourraient faire
    // partie d'un titre d'album (ex. « Live » est ambigu, on l'exclut).
    const EDITION_QUALIFIERS = new Set([
        'remaster', 'remastered', 'remasterise', 'remasterisee', 'remasterized',
        'remix', 'remixed',
        'deluxe', 'deluxeedition',
        'edition', 'special',
        'limited', 'collector', 'collectors',
        'anniversary', 'anniversaire',
        'extended', 'expanded', 'complete', 'ultimate', 'definitive',
        'bonus', 'bonustracks',
        'reissue', 'reissued',
        'digipack', 'digipak'
        // Note : 'ed' volontairement exclu (ambigu — peut faire partie d'un
        // nom d'artiste ou d'album, ex. « Ed Sheeran »).
    ]);

    // Retire les qualificatifs d'édition en suffixe d'une chaîne normalisée
    // (déjà passée par normalize()). Renvoie la chaîne « courte ».
    function stripEditionQualifiers(normalized) {
        if (!normalized) return normalized;
        const tokens = normalized.split(' ').filter(Boolean);
        while (tokens.length > 1 && EDITION_QUALIFIERS.has(tokens[tokens.length - 1])) {
            tokens.pop();
        }
        return tokens.join(' ');
    }

    // Découpe le titre d'un torrent en {prefix, query, strippedQuery, year}.
    //
    // - Le préfixe est tout ce qui précède la DERNIÈRE année trouvée. C'est
    //   robuste face aux titres dont le nom d'album contient une année
    //   (ex. « 1984.George.Orwell.2010.MP3… » → préfixe « 1984.George.Orwell »).
    // - La query reproduit le préfixe avec les points convertis en espaces.
    // - La strippedQuery retire en plus les qualificatifs d'édition en
    //   suffixe (Remaster, Deluxe…). C'est elle qu'on envoie à l'API : sans
    //   ce strip, naviguer sur la fiche d'un Remaster ne ramènerait pas
    //   l'album original (l'API fait un AND sur les mots de la query).
    function parseTitlePrefix(name) {
        if (!name || typeof name !== 'string') return null;

        const re = new RegExp(CONFIG.yearRegex.source, 'g');
        let match;
        let lastMatch = null;
        while ((match = re.exec(name)) !== null) lastMatch = match;
        if (!lastMatch) return null;

        const prefix = name.slice(0, lastMatch.index).replace(/\.+$/, '').trim();
        if (!prefix) return null;

        const query = prefix.replace(/\./g, ' ').trim();
        if (!query) return null;

        // Strip les qualificatifs d'édition pour la query API. On opère sur
        // les tokens originaux (split par '.') en testant chacun après
        // normalisation, ce qui préserve la casse des autres mots.
        const tokens = prefix.split('.').filter(Boolean);
        while (tokens.length > 1 && EDITION_QUALIFIERS.has(normalize(tokens[tokens.length - 1]))) {
            tokens.pop();
        }
        const strippedQuery = tokens.join(' ').trim() || query;

        return { query, strippedQuery, prefix, year: lastMatch[0] };
    }

    // Extrait les métadonnées audio depuis le titre conforme au guide C411 :
    //   Artiste.Album.Année.[Source.]Codec.[Qualité]-TAG
    // Toutes les clefs sont optionnelles (null si non trouvée).
    function parseAudioMeta(name) {
        const meta = { year: null, source: null, codec: null, quality: null, tag: null };
        if (!name) return meta;

        const yearMatch = name.match(CONFIG.yearRegex);
        if (yearMatch) meta.year = yearMatch[0];

        // Source explicitée dans le titre. D'après le guide C411, la source
        // n'apparaît dans le titre que dans deux cas : CD-Rip (juste après
        // l'année) ou codec ISO (Bluray/SACD/DVD-A précédant ISO). On ancre
        // donc la recherche après la position de l'année pour éviter les
        // faux positifs sur des albums dont le NOM contient « WEB », « DTS »…
        if (yearMatch) {
            const afterYear = name.slice(yearMatch.index + yearMatch[0].length);
            const sourceMatch = afterYear.match(/\b(CD-Rip|Bluray|BluRay|Blu-Ray|DVD-?A|DVD-Audio|SACD|DTS|Vinyle|WEB)\b/i);
            if (sourceMatch) meta.source = sourceMatch[1].replace(/^Blu-?Ray$/i, 'Bluray');
        }

        // Codec audio (priorité aux formats lossless usuels).
        const codecMatch = name.match(/\b(FLAC|ALAC|WAV|WV|AIF|AIFF|MP3|M4A|AAC|OGG|Opus|Vorbis|WMA|ISO)\b/i);
        if (codecMatch) meta.codec = codecMatch[1].toUpperCase();

        // Qualité dans crochets : [16bit.44.1kHz], [320kbps], [VBR]…
        // ATTENTION : le titre peut contenir d'AUTRES crochets en début
        // (ex. « [DISCOGRAPHIE] », « [INTEGRALE] », « [SAMPLE] » d'après le
        // guide). Il faut donc choisir le crochet dont le contenu ressemble
        // à une qualité audio (présence de « bit », « kHz », « kbps » ou
        // « VBR »), pas le premier rencontré.
        const qualityRegex = /\[([^\]]*(?:\d+\s*bit|\d+(?:\.\d+)?\s*k\s*Hz|\d+\s*kbps|VBR)[^\]]*)\]/i;
        const qualityMatch = name.match(qualityRegex);
        if (qualityMatch) meta.quality = qualityMatch[1];

        // Tag d'équipe (suffixe -TAG en fin de titre).
        const tagMatch = name.match(/-([A-Za-z0-9]+)\s*$/);
        if (tagMatch) meta.tag = tagMatch[1];

        return meta;
    }

    function formatBytes(bytes) {
        if (!Number.isFinite(bytes) || bytes <= 0) return '—';
        const units = ['o', 'Ko', 'Mo', 'Go', 'To'];
        let value = bytes;
        let i = 0;
        while (value >= 1024 && i < units.length - 1) {
            value /= 1024;
            i++;
        }
        const decimals = i === 0 ? 0 : (value < 10 ? 2 : (value < 100 ? 1 : 0));
        return `${value.toFixed(decimals)} ${units[i]}`;
    }

    // Petite garde anti-DoS si jamais l'API renvoie n'importe quoi.
    function isLikelyValidTorrent(t) {
        return !!(t && typeof t === 'object'
            && typeof t.infoHash === 'string'
            && /^[a-f0-9]{40}$/i.test(t.infoHash)
            && typeof t.name === 'string'
            && t.name.length > 0
            && t.name.length < 1000);
    }

    // ─── Tri du tableau « Versions » ─────────────────────────────────────────
    //
    // Collator du tri alphabétique : localeCompare fr, insensible casse +
    // diacritiques, numeric:true pour classer « Album.2 » avant « Album.10 ».
    const SORT_COLLATOR = new Intl.Collator('fr', { sensitivity: 'base', numeric: true });

    // Extracteurs tolérants : l'API peut renvoyer un champ manquant ou
    // non numérique — on ramène tout à une valeur comparable.
    function torrentYear(t) {
        const y = parseAudioMeta(t && t.name).year;
        return y ? Number(y) : null;
    }
    function torrentId(t) {
        const n = Number(t && t.id);
        return Number.isFinite(n) ? n : 0;
    }
    function torrentSeeders(t) {
        const n = Number(t && t.seeders);
        return Number.isFinite(n) ? n : 0;
    }
    function torrentSize(t) {
        const n = Number(t && t.size);
        return Number.isFinite(n) ? n : 0;
    }

    // Critères de tri. `compare` encode le sens « naturel » — celui qui est
    // le plus utile au premier affichage ; le toggle de sens l'inverse.
    // `label` est affiché dans l'en-tête du bloc.
    const SORT_CRITERIA = {
        // A → Z.
        alpha: {
            label: 'Alphabétique',
            compare: (a, b) => SORT_COLLATOR.compare(a.name || '', b.name || '')
        },
        // Année récente → ancienne. Les titres sans année parsable sont
        // regroupés à une extrémité de la liste — cas théorique, tous les
        // candidats du tableau ayant passé parseTitlePrefix ont une année.
        year: {
            label: 'Année',
            compare: (a, b) => {
                const ya = torrentYear(a);
                const yb = torrentYear(b);
                if (ya === yb) return 0;
                if (ya === null) return 1;
                if (yb === null) return -1;
                return yb - ya;
            }
        },
        // Ancien → récent : restaure l'ordre de poste original sur le site.
        chrono: {
            label: 'Chronologique',
            compare: (a, b) => torrentId(a) - torrentId(b)
        },
        // Mieux partagé en haut.
        seeders: {
            label: 'Seeders',
            compare: (a, b) => torrentSeeders(b) - torrentSeeders(a)
        },
        // Plus gros en haut (repère lossless vs lossy d'un coup d'œil).
        size: {
            label: 'Taille',
            compare: (a, b) => torrentSize(b) - torrentSize(a)
        }
    };

    // Ordre de rotation des critères au clic sur le contrôle de tri.
    const SORT_ORDER = ['alpha', 'year', 'chrono', 'seeders', 'size'];

    // Renvoie une COPIE triée de `torrents`. `criterion` est une clef de
    // SORT_CRITERIA (fallback 'alpha' si inconnue) ; `reversed` inverse le
    // sens naturel. Tie-break stable par id croissant pour que deux releases
    // équivalentes gardent un ordre déterministe entre deux rendus.
    function sortTorrents(torrents, criterion, reversed) {
        const def = SORT_CRITERIA[criterion] || SORT_CRITERIA.alpha;
        const dir = reversed ? -1 : 1;
        return torrents.slice().sort((a, b) => {
            const cmp = def.compare(a, b);
            if (cmp !== 0) return cmp * dir;
            return torrentId(a) - torrentId(b);
        });
    }

    // ─── 20-api.js ───
    // ═══════════════════════════════════════════════════════════════════════
    // SECTION : API C411
    // ═══════════════════════════════════════════════════════════════════════
    //
    // Toutes les requêtes ciblent /api/* et sont same-origin : les cookies
    // de session sont transmis automatiquement avec credentials: 'include'.
    // Inutile de passer par GM_xmlhttpRequest.

    async function fetchJson(url) {
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), CONFIG.timeout);
        try {
            const res = await fetch(url, {
                credentials: 'include',
                headers: { accept: 'application/json' },
                signal: controller.signal
            });
            if (!res.ok) {
                // Codes méritant un log visible (utilisateur peut comprendre
                // pourquoi le bloc n'apparaît pas en ouvrant la console) :
                //   401/403 : session expirée → reconnexion nécessaire
                //   429     : rate-limit côté serveur
                //   5xx     : panne / maintenance C411
                if (res.status === 401 || res.status === 403) {
                    // eslint-disable-next-line no-console
                    console.info('[c411-audio-versions] API auth refusée (' + res.status + ') — session expirée ?');
                } else if (res.status === 429) {
                    // eslint-disable-next-line no-console
                    console.warn('[c411-audio-versions] API rate-limit (429) — pause recommandée');
                } else if (res.status >= 500) {
                    // eslint-disable-next-line no-console
                    console.warn('[c411-audio-versions] API erreur serveur (' + res.status + ')');
                } else {
                    debug('fetch non-ok', url, res.status);
                }
                return null;
            }
            return await res.json();
        } catch (err) {
            // AbortError = timeout local, distinct d'une vraie erreur réseau.
            if (err && err.name === 'AbortError') {
                debug('fetch timeout', url);
            } else {
                debug('fetch error', url, err);
            }
            return null;
        } finally {
            clearTimeout(timeoutId);
        }
    }

    // GET /api/torrents/{infoHash} — fiche complète (l'id numérique ne marche
    // pas, l'API attend l'infoHash 40 chars hex SHA1).
    async function fetchTorrent(infoHash) {
        if (!infoHash) return null;
        return fetchJson(`/api/torrents/${encodeURIComponent(infoHash)}`);
    }

    // GET /api/torrents?name=...&category=3&subcat=... — recherche/listing.
    // Le filtre name= fait un AND sur les mots côté serveur, donc envoyer
    // « Artiste Album » sans formatage suffit pour ratisser toutes les
    // éditions (FLAC, MP3, Remaster…).
    async function searchTorrents(query, subcategoryId) {
        if (!query) return [];
        const params = new URLSearchParams({
            page: '1',
            perPage: String(CONFIG.perPage),
            sortBy: 'relevance',
            sortOrder: 'desc',
            name: query,
            category: String(CONFIG.audioCategoryId)
        });
        if (subcategoryId) params.set('subcat', String(subcategoryId));

        const data = await fetchJson(`/api/torrents?${params.toString()}`);
        if (!data || !Array.isArray(data.data)) return [];
        return data.data.filter(isLikelyValidTorrent);
    }

    // ─── 30-render.js ───
    // ═══════════════════════════════════════════════════════════════════════
    // SECTION : RENDU DU TABLEAU « VERSIONS »
    // ═══════════════════════════════════════════════════════════════════════

    const STYLES = `
.c411-av-block {
    margin: 0 0 .75rem 0;
    font-family: inherit;
}
/* Le summary imite le style des boutons natifs « Afficher le NFO » /
   « Afficher les fichiers » : pas de padding, pas de fond, pas de
   bordure. Conteneur flex pleine largeur — la pleine largeur sert à
   caler le contrôle de tri à droite via margin-left:auto ; l'alignement
   vertical du chevron avec les blocs natifs tient à align-items:center
   et au fait que le chevron est le premier enfant. */
.c411-av-block > summary {
    list-style: none;
    cursor: pointer;
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: .5rem;
    padding: 0;
    background: transparent;
    border: none;
    font-size: .875rem;
    font-weight: 500;
    color: #34d399;
    transition: color .15s;
    user-select: none;
}
.c411-av-block > summary::-webkit-details-marker { display: none; }
.c411-av-block > summary::marker { content: ''; }
.c411-av-block > summary:hover { color: #6ee7b7; }
.c411-av-chevron,
.c411-av-header-icon {
    width: 1rem;
    height: 1rem;
    flex-shrink: 0;
    background-color: currentColor;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    -webkit-mask-position: center;
            mask-position: center;
    -webkit-mask-size: contain;
            mask-size: contain;
}
.c411-av-chevron {
    -webkit-mask-image: var(--c411-av-chevron-svg);
            mask-image: var(--c411-av-chevron-svg);
    transition: transform .2s ease;
    transform: rotate(0deg);
}
.c411-av-block[open] > summary .c411-av-chevron {
    transform: rotate(90deg);
}
.c411-av-header-icon {
    -webkit-mask-image: var(--c411-av-stack-svg);
            mask-image: var(--c411-av-stack-svg);
}
.c411-av-count {
    font-size: .75rem;
    opacity: .7;
    font-weight: 400;
    margin-left: .125rem;
}
.c411-av-terms {
    display: inline-flex;
    flex-wrap: wrap;
    gap: .25rem;
    margin-left: .25rem;
    align-items: center;
}
.c411-av-term {
    display: inline-flex;
    align-items: center;
    gap: .25rem;
    padding: .125rem .25rem .125rem .5rem;
    background: rgba(100, 116, 139, .25);
    border-radius: 9999px;
    font-size: .6875rem;
    font-weight: 500;
    color: #cbd5e1;
    line-height: 1.3;
}
.c411-av-term.is-only {
    padding-right: .5rem;
    opacity: .65;
}
.c411-av-term-close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: .875rem;
    height: .875rem;
    border-radius: 9999px;
    cursor: pointer;
    font-size: .75rem;
    line-height: 1;
    color: #94a3b8;
    transition: background .15s, color .15s;
    user-select: none;
}
.c411-av-term-close:hover {
    background: rgba(239, 68, 68, .25);
    color: #fca5a5;
}
.c411-av-table {
    margin-top: .5rem;
    border: 1px solid rgba(100, 116, 139, .3);
    border-radius: .5rem;
    overflow: hidden;
    background: rgba(15, 23, 42, .35);
}
.c411-av-row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: .5rem;
    padding: .5rem .75rem;
    text-decoration: none;
    color: inherit;
    border-bottom: 1px solid rgba(100, 116, 139, .15);
    transition: background .15s;
    font-size: .8125rem;
}
.c411-av-row:last-child {
    border-bottom: none;
}
.c411-av-row:hover {
    background: rgba(16, 185, 129, .07);
}
.c411-av-row.is-current {
    background: rgba(16, 185, 129, .14);
    box-shadow: inset 3px 0 0 0 #34d399;
}
.c411-av-name {
    flex: 1 1 280px;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-weight: 500;
    color: #e2e8f0;
}
.c411-av-row.is-current .c411-av-name {
    color: #ffffff;
}
.c411-av-badges {
    display: flex;
    flex-wrap: wrap;
    gap: .25rem;
    align-items: center;
}
.c411-av-badge {
    display: inline-flex;
    align-items: center;
    padding: .125rem .5rem;
    border-radius: .25rem;
    font-size: .6875rem;
    font-weight: 600;
    background: rgba(100, 116, 139, .25);
    color: #cbd5e1;
    white-space: nowrap;
    line-height: 1.4;
}
.c411-av-badge.codec   { background: rgba(99, 102, 241, .22); color: #c7d2fe; }
.c411-av-badge.quality { background: rgba(168, 85, 247, .20); color: #e9d5ff; }
.c411-av-badge.source  { background: rgba(245, 158, 11, .22); color: #fde68a; }
.c411-av-badge.tag     { background: rgba(100, 116, 139, .25); color: #cbd5e1; }
.c411-av-badge.year    { background: rgba(20, 184, 166, .15); color: #99f6e4; }
.c411-av-badge.is-clickable {
    cursor: pointer;
    transition: filter .15s, box-shadow .15s;
}
.c411-av-badge.is-clickable:hover {
    filter: brightness(1.25);
    box-shadow: 0 0 0 1px rgba(255, 255, 255, .18);
}
.c411-av-badge.is-clickable:focus-visible {
    outline: 1px solid #34d399;
    outline-offset: 1px;
}
.c411-av-meta {
    display: flex;
    gap: .875rem;
    align-items: center;
    flex-shrink: 0;
    margin-left: auto;
}
.c411-av-size {
    font-variant-numeric: tabular-nums;
    color: #94a3b8;
    min-width: 4.5rem;
    text-align: right;
}
.c411-av-stats {
    font-size: .75rem;
    color: #94a3b8;
    font-variant-numeric: tabular-nums;
    display: inline-flex;
    gap: .375rem;
}
.c411-av-stats .seed { color: #34d399; }
.c411-av-stats .leech { color: #fb923c; }
.c411-av-stats .arrow {
    display: inline-block;
    margin-right: 1px;
    font-size: .625rem;
}
.c411-av-download {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.5rem;
    height: 1.5rem;
    border-radius: .25rem;
    cursor: pointer;
    color: #94a3b8;
    background: transparent;
    border: none;
    padding: 0;
    transition: color .15s, background .15s;
    user-select: none;
}
.c411-av-download:hover {
    color: #34d399;
    background: rgba(16, 185, 129, .12);
}
.c411-av-download:focus-visible {
    outline: 1px solid #34d399;
    outline-offset: 1px;
}
.c411-av-download::before {
    content: '';
    width: 1rem;
    height: 1rem;
    background-color: currentColor;
    -webkit-mask-image: var(--c411-av-download-svg);
            mask-image: var(--c411-av-download-svg);
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    -webkit-mask-position: center;
            mask-position: center;
    -webkit-mask-size: contain;
            mask-size: contain;
}
/* Contrôle de tri, calé à droite du summary via margin-left:auto. Couleur
   slate explicite : sans ça il hériterait du vert du summary (et de son
   hover), alors qu'on le veut discret et visuellement distinct du bouton
   de pliage. */
.c411-av-sort {
    display: inline-flex;
    align-items: center;
    gap: .25rem;
    margin-left: auto;
    color: #94a3b8;
    font-weight: 400;
}
.c411-av-sort-btn,
.c411-av-sort-dir {
    display: inline-flex;
    align-items: center;
    gap: .25rem;
    padding: .125rem .375rem;
    border-radius: .25rem;
    cursor: pointer;
    transition: background .15s, color .15s;
    user-select: none;
}
.c411-av-sort-btn:hover,
.c411-av-sort-dir:hover {
    background: rgba(100, 116, 139, .25);
    color: #e2e8f0;
}
.c411-av-sort-btn:focus-visible,
.c411-av-sort-dir:focus-visible {
    outline: 1px solid #34d399;
    outline-offset: 1px;
}
.c411-av-sort-label {
    font-size: .6875rem;
    font-weight: 500;
}
.c411-av-sort-icon,
.c411-av-sort-dir-icon {
    width: .875rem;
    height: .875rem;
    flex-shrink: 0;
    background-color: currentColor;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    -webkit-mask-position: center;
            mask-position: center;
    -webkit-mask-size: contain;
            mask-size: contain;
}
.c411-av-sort-icon {
    -webkit-mask-image: var(--c411-av-sort-svg);
            mask-image: var(--c411-av-sort-svg);
}
.c411-av-sort-dir-icon {
    -webkit-mask-image: var(--c411-av-sort-dir-svg);
            mask-image: var(--c411-av-sort-dir-svg);
    transition: transform .2s ease;
}
.c411-av-sort-dir.is-reversed .c411-av-sort-dir-icon {
    transform: rotate(180deg);
}
@media (max-width: 640px) {
    .c411-av-name { flex-basis: 100%; }
    .c411-av-meta { margin-left: 0; }
}
`;

    const STACK_ICON_DATA_URI =
        "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>" +
        "<path fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' " +
        "d='M6 6.878V6a2.25 2.25 0 0 1 2.25-2.25h7.5A2.25 2.25 0 0 1 18 6v.878m-12 0q.354-.126.75-.128h10.5q.396.002.75.128m-12 0A2.25 2.25 0 0 0 4.5 9v.878m13.5-3A2.25 2.25 0 0 1 19.5 9v.878m0 0a2.3 2.3 0 0 0-.75-.128H5.25q-.396.002-.75.128m15 0A2.25 2.25 0 0 1 21 12v6a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 18v-6c0-.98.626-1.813 1.5-2.122'/></svg>\")";

    const CHEVRON_ICON_DATA_URI =
        "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>" +
        "<path fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' " +
        "d='m8.25 4.5l7.5 7.5l-7.5 7.5'/></svg>\")";

    // Flèche descendante dans une corbeille — pictogramme « télécharger »
    // standard. Encodé en data URI pour rester self-contained.
    const DOWNLOAD_ICON_DATA_URI =
        "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>" +
        "<path fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' " +
        "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 12L12 16.5m0 0L7.5 12m4.5 4.5V3'/></svg>\")";

    // Deux flèches opposées — pictogramme « trier » générique du bouton de
    // critère.
    const SORT_ICON_DATA_URI =
        "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>" +
        "<path fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' " +
        "d='M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5'/></svg>\")";

    // Flèche simple — sens « naturel » du tri. La classe .is-reversed la
    // fait pivoter de 180° pour signaler le sens inversé.
    const SORT_DIR_ICON_DATA_URI =
        "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>" +
        "<path fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' " +
        "d='M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3'/></svg>\")";

    function ensureStyles() {
        if (document.getElementById('c411-av-styles')) return;
        const style = document.createElement('style');
        style.id = 'c411-av-styles';
        style.textContent =
            `:root {\n` +
            `    --c411-av-stack-svg: ${STACK_ICON_DATA_URI};\n` +
            `    --c411-av-chevron-svg: ${CHEVRON_ICON_DATA_URI};\n` +
            `    --c411-av-download-svg: ${DOWNLOAD_ICON_DATA_URI};\n` +
            `    --c411-av-sort-svg: ${SORT_ICON_DATA_URI};\n` +
            `    --c411-av-sort-dir-svg: ${SORT_DIR_ICON_DATA_URI};\n` +
            `}\n${STYLES}`;
        document.head.appendChild(style);
    }

    // Active un élément span comme un bouton accessible : clic + Entrée +
    // Espace, avec preventDefault/stopPropagation pour éviter que l'event
    // remonte au <a> parent (qui naviguerait vers la fiche torrent).
    function bindButtonish(el, handler) {
        el.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            handler();
        });
        el.addEventListener('keydown', (e) => {
            if (e.key === 'Enter' || e.key === ' ') {
                e.preventDefault();
                e.stopPropagation();
                handler();
            }
        });
    }

    function makeBadge(text, type, options) {
        const span = document.createElement('span');
        span.className = `c411-av-badge${type ? ' ' + type : ''}`;
        span.textContent = text;
        if (options && typeof options.onClick === 'function') {
            span.classList.add('is-clickable');
            span.setAttribute('role', 'button');
            span.setAttribute('tabindex', '0');
            span.title = options.title || `Ajouter « ${text} » à la recherche`;
            bindButtonish(span, () => options.onClick(text));
        }
        return span;
    }

    function buildRow(torrent, isCurrent, rowOptions) {
        const a = document.createElement('a');
        a.className = 'c411-av-row' + (isCurrent ? ' is-current' : '');
        a.href = `/torrents/${torrent.infoHash}`;
        a.dataset.infohash = torrent.infoHash;

        const name = document.createElement('span');
        name.className = 'c411-av-name';
        name.textContent = torrent.name;
        name.title = torrent.name;
        a.appendChild(name);

        const meta = parseAudioMeta(torrent.name);
        const badges = document.createElement('span');
        badges.className = 'c411-av-badges';

        // Helper : retourne les options { onClick } pour rendre un badge
        // cliquable, ou null si onAddTerm n'est pas fourni. Le filtre côté
        // 90-init.js sait gérer les valeurs multi-mots (ex. qualité
        // « 16bit.44.1kHz ») via expansion en sous-mots, donc tous les
        // badges sont cliquables.
        const onAdd = rowOptions && rowOptions.onAddTerm;
        const clickOpts = (value) => onAdd ? { onClick: () => onAdd(value) } : null;

        if (meta.year) badges.appendChild(makeBadge(meta.year, 'year', clickOpts(meta.year)));
        if (meta.source) badges.appendChild(makeBadge(meta.source, 'source', clickOpts(meta.source)));
        if (meta.codec) badges.appendChild(makeBadge(meta.codec, 'codec', clickOpts(meta.codec)));
        if (meta.quality) badges.appendChild(makeBadge(meta.quality, 'quality', clickOpts(meta.quality)));
        if (meta.tag && meta.tag.toUpperCase() !== 'NOTAG') {
            badges.appendChild(makeBadge(meta.tag, 'tag', clickOpts(meta.tag)));
        }
        a.appendChild(badges);

        const metaBlock = document.createElement('span');
        metaBlock.className = 'c411-av-meta';

        const size = document.createElement('span');
        size.className = 'c411-av-size';
        size.textContent = formatBytes(torrent.size);
        metaBlock.appendChild(size);

        const stats = document.createElement('span');
        stats.className = 'c411-av-stats';
        const seeders = Number(torrent.seeders || 0);
        const leechers = Number(torrent.leechers || 0);

        // Note : on construit chaque badge avec textContent uniquement
        // (pas de innerHTML avec concaténation) — défense en profondeur
        // contre une éventuelle régression côté API qui ferait passer une
        // valeur non-numérique non échappée.
        const seed = document.createElement('span');
        seed.className = 'seed';
        const seedArrow = document.createElement('span');
        seedArrow.className = 'arrow';
        seedArrow.textContent = '▲';
        seed.appendChild(seedArrow);
        seed.appendChild(document.createTextNode(String(seeders)));

        const leech = document.createElement('span');
        leech.className = 'leech';
        const leechArrow = document.createElement('span');
        leechArrow.className = 'arrow';
        leechArrow.textContent = '▼';
        leech.appendChild(leechArrow);
        leech.appendChild(document.createTextNode(String(leechers)));

        stats.append(seed, leech);
        metaBlock.appendChild(stats);

        // Bouton de téléchargement direct du .torrent. On utilise un <span>
        // plutôt qu'un <a> ou <button> car la ligne entière est déjà un <a>
        // et HTML interdit l'imbrication. preventDefault/stopPropagation
        // empêchent le clic de remonter au <a> parent (qui naviguerait
        // vers la fiche torrent).
        if (torrent.infoHash) {
            const dl = document.createElement('span');
            dl.className = 'c411-av-download';
            dl.setAttribute('role', 'button');
            dl.setAttribute('tabindex', '0');
            dl.setAttribute('aria-label', 'Télécharger le .torrent');
            dl.title = 'Télécharger le .torrent';
            const downloadUrl = `/api/torrents/${encodeURIComponent(torrent.infoHash)}/download`;
            bindButtonish(dl, () => { location.assign(downloadUrl); });
            metaBlock.appendChild(dl);
        }

        a.appendChild(metaBlock);
        return a;
    }

    // Construit la rangée de badges « termes de recherche » à insérer dans
    // le summary. Chaque terme est un objet {value, scope} ; on n'affiche
    // que la value, le scope est purement interne au filtre. Chaque badge
    // propose un × pour retirer le terme et déclencher un re-fetch via
    // `onRemoveTerm`. Le dernier terme restant n'a pas de × (cas dégradé :
    // on n'autorise pas une query vide).
    function buildTermsRow(terms, onRemoveTerm) {
        if (!terms || terms.length === 0) return null;

        const container = document.createElement('span');
        container.className = 'c411-av-terms';

        const isOnly = terms.length === 1;
        terms.forEach((term, idx) => {
            const value = term && term.value ? term.value : '';
            const badge = document.createElement('span');
            badge.className = 'c411-av-term' + (isOnly ? ' is-only' : '');
            if (term && term.scope === 'name') badge.classList.add('is-name-scope');

            const txt = document.createElement('span');
            txt.textContent = value;
            badge.appendChild(txt);

            if (!isOnly && typeof onRemoveTerm === 'function') {
                const close = document.createElement('span');
                close.className = 'c411-av-term-close';
                close.textContent = '×';
                close.title = `Retirer « ${value} »`;
                close.setAttribute('role', 'button');
                close.setAttribute('aria-label', `Retirer le terme ${value}`);
                // stopPropagation + preventDefault impératifs : sans ça,
                // chaque clic sur × replierait aussi le <details> parent
                // (le <summary> toggle l'attribut open au moindre clic).
                close.addEventListener('click', (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    onRemoveTerm(idx);
                });
                badge.appendChild(close);
            }

            container.appendChild(badge);
        });

        return container;
    }

    // Construit le contrôle de tri calé à droite du <summary> : un bouton
    // principal qui fait tourner le critère (icône + libellé) et un petit
    // bouton qui inverse le sens. Les deux passent par bindButtonish, qui
    // stoppe la propagation — sans ça, chaque clic replierait le <details>.
    // Renvoie null si aucun callback n'est fourni (rendu sans interactivité).
    function buildSortControl(sort, onCycle, onToggleDir) {
        if (typeof onCycle !== 'function' && typeof onToggleDir !== 'function') {
            return null;
        }
        const criterion = (sort && sort.criterion) || 'alpha';
        const reversed = !!(sort && sort.reversed);
        const def = SORT_CRITERIA[criterion] || SORT_CRITERIA.alpha;

        const container = document.createElement('span');
        container.className = 'c411-av-sort';

        if (typeof onCycle === 'function') {
            const btn = document.createElement('span');
            btn.className = 'c411-av-sort-btn';
            btn.setAttribute('role', 'button');
            btn.setAttribute('tabindex', '0');
            btn.title = 'Changer le critère de tri';

            const icon = document.createElement('span');
            icon.className = 'c411-av-sort-icon';
            icon.setAttribute('aria-hidden', 'true');

            const label = document.createElement('span');
            label.className = 'c411-av-sort-label';
            label.textContent = def.label;

            btn.append(icon, label);
            bindButtonish(btn, onCycle);
            container.appendChild(btn);
        }

        if (typeof onToggleDir === 'function') {
            const dir = document.createElement('span');
            dir.className = 'c411-av-sort-dir' + (reversed ? ' is-reversed' : '');
            dir.setAttribute('role', 'button');
            dir.setAttribute('tabindex', '0');
            dir.setAttribute('aria-label', 'Inverser le sens du tri');
            dir.title = reversed
                ? 'Sens inversé — cliquer pour revenir au sens normal'
                : 'Inverser le sens du tri';

            const dirIcon = document.createElement('span');
            dirIcon.className = 'c411-av-sort-dir-icon';
            dirIcon.setAttribute('aria-hidden', 'true');

            dir.appendChild(dirIcon);
            bindButtonish(dir, onToggleDir);
            container.appendChild(dir);
        }

        return container;
    }

    function buildVersionsBlock(torrents, currentHash, options) {
        ensureStyles();
        const opts = options || {};

        // <details> natif HTML : gère le pliage sans JS, accessible clavier,
        // chevron pivotant en CSS via [open]. Pleine largeur garantie par
        // le fait que <details> est block-level par défaut.
        const wrapper = document.createElement('details');
        wrapper.id = CONFIG.elementId;
        wrapper.className = 'c411-av-block';
        wrapper.open = true;

        const summary = document.createElement('summary');
        const chevron = document.createElement('span');
        chevron.className = 'c411-av-chevron';
        chevron.setAttribute('aria-hidden', 'true');
        const icon = document.createElement('span');
        icon.className = 'c411-av-header-icon';
        icon.setAttribute('aria-hidden', 'true');
        const label = document.createElement('span');
        label.textContent = 'Versions disponibles';
        const count = document.createElement('span');
        count.className = 'c411-av-count';
        count.textContent = `(${torrents.length})`;
        summary.append(chevron, icon, label, count);

        const termsRow = buildTermsRow(opts.terms, opts.onRemoveTerm);
        if (termsRow) summary.appendChild(termsRow);

        const sortControl = buildSortControl(opts.sort, opts.onCycleSort, opts.onToggleSortDir);
        if (sortControl) summary.appendChild(sortControl);

        wrapper.appendChild(summary);

        const table = document.createElement('div');
        table.className = 'c411-av-table';

        // Tri selon le critère courant (cf. SORT_CRITERIA dans 10-utils.js).
        // Par défaut alphabétique ; le contrôle dans le summary fait tourner
        // le critère et inverse le sens.
        const sortState = opts.sort || { criterion: 'alpha', reversed: false };
        const sorted = sortTorrents(torrents, sortState.criterion, sortState.reversed);

        const rowOptions = {
            onAddTerm: opts.onAddTerm
        };
        sorted.forEach(t => {
            const isCurrent = t.infoHash && t.infoHash.toLowerCase() === currentHash;
            table.appendChild(buildRow(t, isCurrent, rowOptions));
        });

        wrapper.appendChild(table);
        return wrapper;
    }

    // Détecte si un container dispose ses enfants en stack vertical (tops
    // différents) plutôt qu'en row horizontale (tops identiques). C'est plus
    // robuste qu'inspecter les classes Tailwind ou getComputedStyle, parce
    // que ça teste directement le rendu effectif.
    //
    // Implémentation : on lit getBoundingClientRect() une seule fois par
    // enfant et on les met en cache. Sans ce cache, chaque appel forçait
    // un layout reflow synchrone — coûteux dans la boucle de
    // findInjectionPoint() qui itère sur 12 niveaux × N enfants.
    function isVerticalStack(parent) {
        const rects = [];
        for (const child of parent.children) {
            const r = child.getBoundingClientRect();
            if (r.width > 0 && r.height > 0) rects.push(r);
        }
        if (rects.length < 2) return true;
        const firstTop = rects[0].top;
        for (let i = 1; i < rects.length; i++) {
            if (Math.abs(rects[i].top - firstTop) > 4) return true;
        }
        return false;
    }

    // Repère un point d'ancrage dans la fiche torrent. On vise les boutons
    // « Afficher le NFO » / « Afficher les fichiers » qui forment un bloc
    // distinct juste avant les détails étendus. Pour éviter de se retrouver
    // à l'intérieur d'un container 2-colonnes (NFO / Télécharger), on remonte
    // dans la hiérarchie jusqu'au premier parent qui est en stack vertical —
    // ce qui garantit l'insertion en pleine largeur.
    function findInjectionPoint() {
        const candidates = Array.from(document.querySelectorAll('button, a'));
        for (const el of candidates) {
            const txt = (el.textContent || '').trim();
            if (!/^Afficher\s+(le\s+NFO|les\s+fichiers)/i.test(txt)) continue;

            let cur = el;
            for (let i = 0; i < 12 && cur && cur.parentElement; i++) {
                const parent = cur.parentElement;
                if (parent === document.body) break;
                if (isVerticalStack(parent)) {
                    return { parent, before: cur };
                }
                cur = parent;
            }
        }

        // Fallback : juste après le H1 du titre torrent, en se positionnant
        // avant le bloc d'infos qui le suit immédiatement. Si on en arrive
        // là, c'est que la stratégie 1 (texte « Afficher le NFO / les
        // fichiers ») a échoué — probablement parce que le site a renommé
        // ces boutons. On le signale pour faciliter le diagnostic.
        debug('findInjectionPoint: primary strategy (NFO/files button text) failed, trying H1 fallback');
        const h1 = document.querySelector('h1');
        if (h1) {
            let block = h1;
            while (block && block.parentElement && block.parentElement.children.length === 1) {
                block = block.parentElement;
            }
            const next = block && block.nextElementSibling;
            if (next && next.parentElement && isVerticalStack(next.parentElement)) {
                debug('findInjectionPoint: H1 fallback succeeded');
                return { parent: next.parentElement, before: next };
            }
        }

        debug('findInjectionPoint: all strategies failed, no injection point found');
        return null;
    }

    function injectVersionsBlock(torrents, currentHash, options) {
        const existing = document.getElementById(CONFIG.elementId);
        // Si un bloc existe déjà et qu'on a un point d'injection, on échange
        // l'ancien par le nouveau au même endroit (limite le flicker lors
        // d'un rerender suite à retrait d'un terme).
        if (existing && existing.parentElement) {
            const block = buildVersionsBlock(torrents, currentHash, options);
            existing.parentElement.replaceChild(block, existing);
            return true;
        }

        const point = findInjectionPoint();
        if (!point) return false;

        const block = buildVersionsBlock(torrents, currentHash, options);
        point.parent.insertBefore(block, point.before);
        return true;
    }

    function removeInjectedBlock() {
        const existing = document.getElementById(CONFIG.elementId);
        if (existing) existing.remove();
    }

    // ─── 90-init.js ───
    // ═══════════════════════════════════════════════════════════════════════
    // SECTION : INITIALISATION & ROUTEUR SPA
    // ═══════════════════════════════════════════════════════════════════════
    //
    // Le site C411 est une application Nuxt (SPA) : naviguer d'une fiche
    // torrent à une autre ne déclenche pas de rechargement complet. Il faut
    // donc patcher history.pushState / replaceState et écouter popstate
    // pour relancer le traitement à chaque changement d'URL.
    //
    // pageState garde l'état lié à la fiche courante : hash, torrent,
    // sous-catégorie, termes de recherche actifs, et dernière liste filtrée
    // (mémorisée pour le re-render au changement de tri). Il est remplacé à
    // chaque navigation, et réassigné à chaque ajout/retrait de terme via les
    // badges interactifs.
    //
    // refreshGeneration est un compteur monotone : chaque appel à
    // refreshVersionsBlock incrémente la génération, et toute exécution
    // asynchrone vérifie qu'elle est toujours la « courante » avant
    // d'injecter. Cela neutralise les courses lors de clics rapides sur
    // les badges × (un seul refresh aboutit, le dernier).

    let lastProcessedHash = null;
    let processingHash = null;
    let routerTimer = null;
    let pageState = null;
    let refreshGeneration = 0;

    // État de tri du tableau « Versions ». Variables au niveau module : elles
    // survivent à une navigation SPA mais sont réinitialisées par tout
    // rechargement complet de la page — ce qui inclut le clic sur une ligne du
    // tableau (lien <a> natif). Pas de persistance localStorage : le tri repart
    // donc du défaut dès qu'on ouvre une fiche par un lien classique.
    // `sortCriterion` est une clef de SORT_ORDER, `sortReversed` inverse le
    // sens naturel du critère.
    let sortCriterion = SORT_ORDER[0];
    let sortReversed = false;

    function isOnTorrentPage(pathname) {
        return /^\/torrents\/[a-f0-9]{40}\b/i.test(pathname || location.pathname);
    }

    // Logs explicites pour les cas non pris en charge — visibles même hors
    // mode debug, pour que l'utilisateur qui ouvre la console comprenne
    // pourquoi le bloc « Versions disponibles » n'apparaît pas. Niveau
    // info (pas warn) car ce sont des limitations connues, pas des erreurs.
    function logUnhandled(reason, detail) {
        // eslint-disable-next-line no-console
        console.info('[c411-audio-versions] cas non géré: ' + reason, detail || '');
    }

    // Filtre client. Chaque terme actif est un objet {value, scope} :
    //   - scope 'prefix' : tous les sous-mots normalisés doivent figurer
    //     dans le préfixe du candidat (avant l'année). Scope des termes
    //     extraits du titre courant (artiste + album).
    //   - scope 'name'   : tous les sous-mots normalisés doivent figurer
    //     dans le nom complet. Scope des termes ajoutés via clic sur un
    //     badge (codec, source, année, tag, qualité) — ces infos sont APRÈS
    //     l'année dans le titre, donc absentes du préfixe.
    //
    // Un terme peut être multi-mots : « 16bit.44.1kHz » se normalise en
    // ["16bit", "44", "1khz"] et exige que les 3 mots soient présents.
    // Cela permet de proposer le badge qualité comme cliquable malgré son
    // format avec points/crochets.
    function filterByTerms(candidates, terms) {
        const expandedTerms = terms.map(t => ({
            words: normalize(t.value).split(' ').filter(Boolean),
            scope: t.scope
        })).filter(t => t.words.length > 0);
        if (expandedTerms.length === 0) return [];
        return candidates.filter(t => {
            const tParsed = parseTitlePrefix(t.name);
            if (!tParsed) return false;
            const prefixWords = new Set(normalize(tParsed.prefix).split(' ').filter(Boolean));
            const fullWords = new Set(normalize(t.name).split(' ').filter(Boolean));
            return expandedTerms.every(term => {
                const haystack = term.scope === 'name' ? fullWords : prefixWords;
                return term.words.every(w => haystack.has(w));
            });
        });
    }

    // Construit l'objet d'options passé au rendu : termes courants, callbacks
    // d'ajout/retrait de terme, état de tri et callbacks de tri. `hash` est
    // capturé pour que les callbacks différés ignorent les clics parvenus
    // après une navigation vers une autre fiche.
    function buildRenderOptions(hash) {
        return {
            terms: pageState ? pageState.terms : [],
            onRemoveTerm: (idx) => {
                if (!pageState || pageState.hash !== hash) return;
                if (pageState.terms.length <= 1) return;
                pageState = {
                    ...pageState,
                    terms: pageState.terms.filter((_, i) => i !== idx)
                };
                refreshVersionsBlock();
            },
            // Ajout via clic sur un badge de ligne (codec, source, année, tag).
            // Scope 'name' : on matche dans le nom complet, pas le préfixe,
            // car ces infos sont après l'année dans le titre.
            onAddTerm: (value) => {
                if (!pageState || pageState.hash !== hash) return;
                const v = String(value || '').trim();
                if (!v) return;
                const norm = normalize(v);
                if (!norm) return;
                if (pageState.terms.some(t => normalize(t.value) === norm)) return;
                pageState = {
                    ...pageState,
                    terms: pageState.terms.concat({ value: v, scope: 'name' })
                };
                refreshVersionsBlock();
            },
            sort: { criterion: sortCriterion, reversed: sortReversed },
            // Rotation du critère et inversion du sens : purement client,
            // donc re-render direct sans refetch API (rerenderForSort).
            onCycleSort: () => {
                if (!pageState || pageState.hash !== hash) return;
                const i = SORT_ORDER.indexOf(sortCriterion);
                sortCriterion = SORT_ORDER[(i + 1) % SORT_ORDER.length];
                rerenderForSort();
            },
            onToggleSortDir: () => {
                if (!pageState || pageState.hash !== hash) return;
                sortReversed = !sortReversed;
                rerenderForSort();
            }
        };
    }

    // Re-render du bloc avec l'état de tri courant, SANS refetch : le tri est
    // purement client, on réutilise la dernière liste filtrée mémorisée sur
    // pageState. Si le bloc n'est plus dans le DOM (navigation entre-temps),
    // on laisse tomber — le prochain refresh complet le reconstruira.
    function rerenderForSort() {
        if (!pageState || !Array.isArray(pageState.filtered)) return;
        if (extractInfoHashFromUrl(location.pathname) !== pageState.hash) return;
        injectVersionsBlock(pageState.filtered, pageState.hash, buildRenderOptions(pageState.hash));
    }

    // (Re)génère le tableau pour pageState courant, avec un appel API. Appelée
    // à l'initialisation d'une fiche et à chaque ajout/retrait de terme via les
    // badges (un changement de tri, lui, passe par rerenderForSort sans
    // refetch). Plusieurs appels concurrents peuvent coexister (clics rapides
    // sur ×) ; seul le plus récent (génération la plus haute) aboutit à une
    // injection — les autres avortent silencieusement avant le render.
    async function refreshVersionsBlock() {
        if (!pageState) return;
        const myGen = ++refreshGeneration;
        const { hash, currentTorrent, subcatId, terms } = pageState;
        if (terms.length === 0) return;

        // Tokeniser chaque terme sur les non-alphanumériques avant l'envoi
        // à l'API : sans ça, un terme comme « 16bit.44.1kHz » serait cherché
        // littéralement (sous-chaîne avec points), ce qui ne matcherait que
        // par hasard. En split on garantit un AND sur des mots simples,
        // cohérent avec le filtre client.
        const apiQuery = terms
            .flatMap(t => String(t.value || '').split(/[^A-Za-z0-9]+/).filter(Boolean))
            .join(' ');
        debug(`refreshing for terms [${terms.map(t => `${t.value}(${t.scope})`).join(', ')}] (gen=${myGen})`);
        const candidates = await searchTorrents(apiQuery, subcatId);
        debug(`API returned ${candidates.length} candidates for query "${apiQuery}"`);

        // Garde anti-navigation : ne rien injecter si l'utilisateur a quitté
        // la fiche, si pageState a été remplacé, ou si un refresh plus récent
        // a été demandé entre-temps.
        if (myGen !== refreshGeneration) return;
        if (extractInfoHashFromUrl(location.pathname) !== hash) return;
        if (!pageState || pageState.hash !== hash) return;

        const filtered = filterByTerms(candidates, terms);

        // S'assurer que le torrent courant reste visible — repère pour
        // l'utilisateur de « où il est » dans la liste, même après retraits.
        // Exception : si l'utilisateur a ajouté un filtre via badge (scope
        // 'name') et que le torrent courant ne le respecte pas, on le retire
        // pour éviter une ligne « accrochée » qui ne matche pas la requête.
        const hasCurrent = filtered.some(t => t.infoHash && t.infoHash.toLowerCase() === hash);
        const hasNameScopedTerm = terms.some(t => t.scope === 'name');
        if (!hasCurrent && isLikelyValidTorrent(currentTorrent) && !hasNameScopedTerm) {
            filtered.unshift(currentTorrent);
        }

        debug(`${filtered.length} matched after filtering`);
        if (filtered.length === 0) return;

        // Mémoriser la liste filtrée sur pageState : le tri étant purement
        // client, un changement de tri se contente d'un re-render
        // (rerenderForSort) sans repasser par l'API.
        pageState = { ...pageState, filtered };

        const renderOptions = buildRenderOptions(hash);

        // Injection avec retry : le DOM Vue peut ne pas être hydraté
        // au moment où l'on revient (notamment après une navigation SPA).
        // À chaque tick, on revalide la génération courante : si l'utilisateur
        // a cliqué un autre × pendant le retry, on aborte.
        let attempts = 0;
        const tryInject = () => {
            if (myGen !== refreshGeneration) return;
            if (extractInfoHashFromUrl(location.pathname) !== hash) return;
            if (!pageState || pageState.hash !== hash) return;
            if (injectVersionsBlock(filtered, hash, renderOptions)) {
                debug('injected versions block');
                return;
            }
            attempts++;
            if (attempts >= CONFIG.injectMaxRetries) {
                debug('giving up on injection after max retries');
                return;
            }
            setTimeout(tryInject, CONFIG.injectRetryDelay);
        };
        tryInject();
    }

    async function processCurrentPage() {
        const pathname = location.pathname;

        if (!isOnTorrentPage(pathname)) {
            removeInjectedBlock();
            lastProcessedHash = null;
            pageState = null;
            return;
        }

        const hash = extractInfoHashFromUrl(pathname);
        if (!hash) return;
        if (hash === lastProcessedHash) return;
        if (hash === processingHash) return;

        processingHash = hash;
        // Marqueur déterminant si on doit poser lastProcessedHash dans le
        // finally. On ne le fait QUE si la fiche a pu être chargée et
        // identifiée — un échec réseau doit pouvoir être réessayé à la
        // prochaine activation (utilisateur qui recharge ou navigue
        // ailleurs puis revient).
        let torrentFetched = false;
        try {
            debug('processing torrent', hash);

            const torrent = await fetchTorrent(hash);
            if (!torrent || !isLikelyValidTorrent(torrent)) {
                debug('failed to fetch valid torrent (will retry on next visit)');
                return;
            }
            torrentFetched = true;

            // Guard explicite : si l'API renvoie une catégorie absente ou
            // sans id numérique, on refuse de traiter (sécurité contre un
            // changement de schéma API).
            if (!torrent.category || typeof torrent.category.id !== 'number') {
                logUnhandled('catégorie absente ou malformée dans la réponse API', torrent.category);
                return;
            }
            if (torrent.category.id !== CONFIG.audioCategoryId) {
                debug('not audio category, skipping', torrent.category);
                return;
            }

            const parsed = parseTitlePrefix(torrent.name);
            if (!parsed) {
                // Cas typiques : [DISCOGRAPHIE], [INTEGRALE], podcast sans
                // année dans le titre, samples avec [SAMPLE]. Tous documentés
                // comme limites V1 dans le README.
                logUnhandled(
                    `titre non parsable (pas d'année trouvée) — limite connue pour ` +
                    `[DISCOGRAPHIE]/[INTEGRALE]/podcasts/samples`,
                    torrent.name
                );
                return;
            }

            // Termes initiaux (artiste + album), scope 'prefix' : ils doivent
            // matcher avant l'année dans les titres candidats. Le user pourra
            // en retirer via les × et en ajouter via clic sur badge ; les
            // ajouts via badge sont scope 'name' (cf. onAddTerm).
            const initialTerms = (parsed.strippedQuery || parsed.query)
                .split(' ')
                .map(t => t.trim())
                .filter(Boolean)
                .map(value => ({ value, scope: 'prefix' }));

            if (initialTerms.length === 0) {
                logUnhandled('aucun terme de recherche extractible du titre', parsed);
                return;
            }

            pageState = {
                hash,
                currentTorrent: torrent,
                subcatId: torrent.subcategory && torrent.subcategory.id,
                terms: initialTerms
            };

            await refreshVersionsBlock();
        } finally {
            processingHash = null;
            // On marque le hash comme traité dès que la fiche a été chargée
            // (qu'elle ait été audio ou non, parsable ou non). Une fiche ne
            // change pas de catégorie ni de titre, donc retraiter ne
            // changerait rien — autant éviter le refetch.
            if (torrentFetched) {
                lastProcessedHash = hash;
            }
        }
    }

    function scheduleProcess() {
        if (routerTimer) clearTimeout(routerTimer);
        routerTimer = setTimeout(() => {
            routerTimer = null;
            processCurrentPage();
        }, CONFIG.routerDebounce);
    }

    // Marqueur posé sur la fonction patchée. Empêche de patcher en chaîne
    // si le script est réinjecté (rechargement Tampermonkey, désactivation
    // / réactivation dans la même page) — sans cela, chaque réinjection
    // empilerait un wrapper, fuyant de la mémoire et risquant de casser la
    // chaîne de patches.
    const PATCH_MARKER = '__c411AudioVersionsPatched';

    function setupRouter() {
        if (history.pushState && history.pushState[PATCH_MARKER]) {
            debug('history.pushState already patched by us, skipping');
            return;
        }

        const origPush = history.pushState;
        const patchedPush = function () {
            const ret = origPush.apply(this, arguments);
            scheduleProcess();
            return ret;
        };
        patchedPush[PATCH_MARKER] = true;
        history.pushState = patchedPush;

        const origReplace = history.replaceState;
        const patchedReplace = function () {
            const ret = origReplace.apply(this, arguments);
            scheduleProcess();
            return ret;
        };
        patchedReplace[PATCH_MARKER] = true;
        history.replaceState = patchedReplace;

        window.addEventListener('popstate', scheduleProcess);
    }

    setupRouter();
    scheduleProcess();

})();