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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();