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