CBZ Downloader

Télécharge les galeries nhentai en .cbz — bouton individuel, lot par liste et par tag avec choix de langue

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         CBZ Downloader
// @namespace    local.cbz.downloader
// @version      0.1
// @description  Télécharge les galeries nhentai en .cbz — bouton individuel, lot par liste et par tag avec choix de langue
// @license      MIT
// @match        https://nhentai.net/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      nhentai.net
// @connect      i.nhentai.net
// @connect      i1.nhentai.net
// @connect      i2.nhentai.net
// @connect      i3.nhentai.net
// @connect      i4.nhentai.net
// @connect      i5.nhentai.net
// @connect      i6.nhentai.net
// @connect      i7.nhentai.net
// @run-at       document-idle
// @noframes
// ==/UserScript==

(function () {
  "use strict";

  const THREADS = 4;
  const EXT = { j: "jpg", p: "png", g: "gif", w: "webp" };
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  const sanitize = (s) => s.replace(/[\\/:*?"<>|]/g, "").replace(/\s+/g, " ").trim();
  const log = (...a) => console.log("[CBZ Downloader]", ...a);

  GM_addStyle(`
    .cbz-dl-btn{margin:4px;padding:6px 14px;border:0;border-radius:4px;background:#ed2553;color:#fff;font-weight:700;cursor:pointer;font-size:13px}
    .cbz-dl-btn:disabled{opacity:.75;cursor:wait}
    .cbz-dl-btn.cbz-ok{background:#2e7d32}
    .cbz-dl-btn.cbz-err{background:#c62828}
    .cbz-dl-btn.cbz-small{position:absolute;top:2px;right:2px;z-index:5;margin:0;padding:3px 7px;font-size:11px;border-radius:3px;white-space:nowrap}
    .gallery{position:relative}
    .gallery.cbz-done{box-shadow:0 0 0 2px #2e7d32}
    .gallery.cbz-failed{box-shadow:0 0 0 2px #c62828}
    #cbz-bulk-btn{margin:6px 10px;vertical-align:middle}
    .cbz-tag-btn{border:0;border-radius:3px;background:#ed2553;color:#fff;font-weight:700;cursor:pointer;font-size:9px;padding:1px 5px;margin-left:4px;vertical-align:middle}
    .cbz-tag-btn:disabled{opacity:.6;cursor:wait}
    #cbz-panel{position:fixed;top:20vh;right:0;z-index:2147483647;width:220px;background:rgba(0,0,0,.78);color:#fff;font-size:12px;border-radius:6px 0 0 6px;padding:8px 10px;font-family:sans-serif}
    #cbz-panel .cbz-title{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:6px;font-weight:700}
    #cbz-panel .cbz-bar{height:10px;background:#333;border-radius:5px;overflow:hidden}
    #cbz-panel .cbz-bar-fill{height:100%;width:0%;background:#ed2553;transition:width .2s}
    #cbz-panel .cbz-bar-fill.zip{background:#66bb6a}
    #cbz-panel .cbz-bar-fill.err{background:#f44336}
    #cbz-panel .cbz-status{margin-top:4px;text-align:right}
    #cbz-toasts{position:fixed;right:16px;bottom:16px;z-index:2147483647;display:flex;flex-direction:column;gap:8px;align-items:stretch;max-width:340px}
    .cbz-toast{padding:10px 14px;border-radius:6px;color:#fff;font-size:13px;font-family:sans-serif;box-shadow:0 2px 10px rgba(0,0,0,.5);opacity:1;transition:opacity .3s,transform .3s;word-break:break-word}
    .cbz-toast--success{background:#2e7d32}
    .cbz-toast--error{background:#c62828}
    .cbz-toast--hide{opacity:0;transform:translateY(8px)}
    #cbz-bulk-dialog{position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:sans-serif}
    #cbz-bulk-dialog .cbz-bulk-box{background:#1c1c1c;color:#fff;border-radius:8px;padding:18px 22px;max-width:430px;width:calc(100% - 40px);box-shadow:0 4px 24px rgba(0,0,0,.6)}
    #cbz-bulk-dialog .cbz-bulk-title{font-size:15px;font-weight:700;margin-bottom:6px}
    #cbz-bulk-dialog .cbz-bulk-sub{font-size:12px;opacity:.8;margin-bottom:12px}
    #cbz-bulk-dialog .cbz-bulk-buttons{display:flex;flex-direction:column;gap:8px}
    #cbz-bulk-dialog button{border:0;border-radius:4px;padding:9px 12px;font-weight:700;cursor:pointer;color:#fff;background:#ed2553;font-size:13px}
    #cbz-bulk-dialog button[data-f="english"]{background:#1976d2}
    #cbz-bulk-dialog button[data-f="japanese"]{background:#7b1fa2}
    #cbz-bulk-dialog button[data-f="chinese"]{background:#c62828}
    #cbz-bulk-dialog button.cbz-bulk-cancel{background:#555}
    #cbz-report{position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:sans-serif}
    #cbz-report .cbz-report-box{background:#1c1c1c;color:#fff;border-radius:8px;padding:16px 20px;max-width:480px;width:calc(100% - 40px)}
    #cbz-report .cbz-report-title{font-weight:700;margin-bottom:8px}
    #cbz-report .cbz-report-text{white-space:pre-wrap;background:#111;border-radius:4px;padding:8px;font-size:12px;max-height:50vh;overflow:auto}
    #cbz-report .cbz-report-ok{margin-top:10px;border:0;border-radius:4px;padding:8px 16px;background:#ed2553;color:#fff;font-weight:700;cursor:pointer}
  `);

  // ---- Mémoire persistante des statuts ----
  const STATUS_KEY = "cbz_dl_status";
  let statusMap = {};
  try { statusMap = JSON.parse(GM_getValue(STATUS_KEY, "{}")) || {}; } catch (e) { statusMap = {}; }
  const saveStatus = () => GM_setValue(STATUS_KEY, JSON.stringify(statusMap));
  const setStatus = (gid, st) => { statusMap[gid] = st; saveStatus(); };

  const btnRegistry = [];
  function registerBtn(btn, gid, label) { btnRegistry.push({ btn, gid, label }); }
  function repaintBtn(gid) {
    btnRegistry.forEach((r) => { if (String(r.gid) === String(gid)) paintStatus(r.btn, r.gid, r.label); });
  }

  function paintStatus(btn, gid, label) {
    const st = statusMap[gid];
    const gal = btn.closest(".gallery");
    btn.classList.remove("cbz-ok", "cbz-err");
    if (gal) gal.classList.remove("cbz-done", "cbz-failed");
    if (st === "ok") {
      btn.classList.add("cbz-ok");
      btn.textContent = "✓ CBZ";
      if (gal) gal.classList.add("cbz-done");
    } else if (st === "err") {
      btn.classList.add("cbz-err");
      btn.textContent = "✗ CBZ";
      if (gal) gal.classList.add("cbz-failed");
    } else {
      btn.textContent = label;
    }
  }

  // ---- Notifications ----
  let toastBox = null;
  function showToast(type, text, timeout) {
    if (!toastBox) {
      toastBox = document.createElement("div");
      toastBox.id = "cbz-toasts";
      document.body.appendChild(toastBox);
    }
    const t = document.createElement("div");
    t.className = "cbz-toast cbz-toast--" + type;
    t.textContent = text;
    toastBox.appendChild(t);
    setTimeout(() => {
      t.classList.add("cbz-toast--hide");
      setTimeout(() => t.remove(), 350);
    }, timeout || (type === "error" ? 8000 : 5000));
  }

  // ---- Rapport final permanent ----
  function showReport(text) {
    const old = document.getElementById("cbz-report");
    if (old) old.remove();
    const box = document.createElement("div");
    box.id = "cbz-report";
    box.innerHTML =
      '<div class="cbz-report-box">' +
      '<div class="cbz-report-title">Rapport du lot</div>' +
      '<pre class="cbz-report-text"></pre>' +
      '<button class="cbz-report-ok">OK</button>' +
      "</div>";
    box.querySelector(".cbz-report-text").textContent = text;
    box.querySelector(".cbz-report-ok").addEventListener("click", () => box.remove());
    document.body.appendChild(box);
  }

  // ---- Panneau de progression ----
  let panel = null;
  function showPanel(title) {
    hidePanel(0);
    panel = document.createElement("div");
    panel.id = "cbz-panel";
    panel.innerHTML =
      '<div class="cbz-title"></div>' +
      '<div class="cbz-bar"><div class="cbz-bar-fill"></div></div>' +
      '<div class="cbz-status"></div>';
    panel.querySelector(".cbz-title").textContent = title;
    document.body.appendChild(panel);
  }
  function updatePanel(percent, status, mode) {
    if (!panel) return;
    const fill = panel.querySelector(".cbz-bar-fill");
    fill.style.width = percent + "%";
    fill.classList.toggle("zip", mode === "zip");
    fill.classList.toggle("err", mode === "err");
    panel.querySelector(".cbz-status").textContent = status;
  }
  function hidePanel(delay = 3000) {
    if (!panel) return;
    const p = panel;
    panel = null;
    if (delay <= 0) p.remove();
    else setTimeout(() => p.remove(), delay);
  }

  // ---- Requêtes ----
  function req(url, type = "text") {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url,
        responseType: type,
        onload: (r) => (r.status >= 200 && r.status < 300)
          ? resolve(r.response)
          : reject(new Error(`HTTP ${r.status} : ${url}`)),
        onerror: () => reject(new Error(`Erreur réseau : ${url}`))
      });
    });
  }

  const getJSON = (url) => req(url).then((t) => JSON.parse(t));

  // ---- Infos de la galerie (+ langue) ----
  const getLangFromTags = (tags) => {
    const t = (tags || []).find((x) => x.type === "language" && x.name !== "translated");
    return t ? t.name : "japanese";
  };
  const guessLangFromTitle = (title) => {
    const t = (title || "").toLowerCase();
    if (t.includes("[chinese]") || t.includes("chinois") || t.includes("漢化") || t.includes("中国翻訳")) return "chinese";
    if (t.includes("[english]") || t.includes("translation") || t.includes("translated")) return "english";
    return "japanese";
  };

  async function getGallery(gid) {
    try {
      const d = await getJSON(`https://nhentai.net/api/v2/galleries/${gid}`);
      if (d && d.media_id && Array.isArray(d.pages)) {
        return {
          mid: String(d.media_id),
          title: (d.title && (d.title.english || d.title.japanese || d.title.pretty)) || `gallery-${gid}`,
          lang: getLangFromTags(d.tags),
          pages: d.pages.map((p, i) => ({ i: i + 1, ext: (String(p.path).match(/\.(\w+)$/) || [])[1] || "jpg" }))
        };
      }
    } catch (e) { log("API v2 indisponible :", e.message); }

    try {
      const d = await getJSON(`https://nhentai.net/api/gallery/${gid}`);
      if (d && d.media_id && d.images && d.images.pages) {
        return {
          mid: String(d.media_id),
          title: (d.title && (d.title.english || d.title.japanese)) || `gallery-${gid}`,
          lang: getLangFromTags(d.tags),
          pages: d.images.pages.map((p, i) => ({ i: i + 1, ext: EXT[p.t] || "jpg" }))
        };
      }
    } catch (e) { log("Ancienne API indisponible :", e.message); }

    const img0 = document.querySelector("#thumbnail-container img");
    const src0 = img0 && (img0.dataset.src || img0.src || "");
    const mid = (src0.match(/\/galleries\/([0-9a-z]+)\//) || [])[1];
    const thumbs = [...document.querySelectorAll("#thumbnail-container img")];
    if (mid && thumbs.length) {
      const title = ((document.querySelector("#info h1") || {}).textContent || `gallery-${gid}`).trim();
      return {
        mid,
        title,
        lang: guessLangFromTitle(title),
        pages: thumbs.map((img, idx) => {
          const m = (img.dataset.src || img.src || "").match(/\/(\d+)t?\.([^/]+)$/);
          return { i: m ? Number(m[1]) : idx + 1, ext: m ? m[2] : "jpg" };
        })
      };
    }
    throw new Error("Infos introuvables pour la galerie " + gid);
  }

  // ---- Compression en worker ----
  const ZIP_WORKER_CODE = [
    'importScripts("https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js");',
    'self.onmessage = function (e) {',
    '  var d = e.data;',
    '  var zip = new JSZip();',
    '  for (var i = 0; i < d.files.length; i++) zip.file(d.files[i].name, d.files[i].data);',
    '  zip.generateAsync({ type: "arraybuffer", compression: "STORE" }, function (meta) {',
    '    self.postMessage({ id: d.id, type: "progress", percent: meta.percent });',
    '  }).then(function (buf) {',
    '    self.postMessage({ id: d.id, type: "done", buffer: buf }, [buf]);',
    '  }, function (err) {',
    '    self.postMessage({ id: d.id, type: "error", message: String(err) });',
    '  });',
    '};'
  ].join("\n");

  let zipWorker = null;
  let workerBroken = false;
  const zipJobs = new Map();
  let jobId = 0;

  function getZipWorker() {
    if (zipWorker) return zipWorker;
    const url = URL.createObjectURL(new Blob([ZIP_WORKER_CODE], { type: "application/javascript" }));
    zipWorker = new Worker(url);
    zipWorker.onmessage = (e) => {
      const { id, type, percent, buffer, message } = e.data;
      const job = zipJobs.get(id);
      if (!job) return;
      if (type === "progress") job.onProgress && job.onProgress(percent);
      else if (type === "done") { zipJobs.delete(id); job.resolve(buffer); }
      else if (type === "error") { zipJobs.delete(id); job.reject(new Error(message)); }
    };
    zipWorker.onerror = () => {
      workerBroken = true;
      zipJobs.forEach((j) => j.reject(new Error("worker error")));
      zipJobs.clear();
    };
    return zipWorker;
  }

  function zipInWorker(files, onProgress) {
    return new Promise((resolve, reject) => {
      const id = ++jobId;
      zipJobs.set(id, { resolve, reject, onProgress });
      try {
        getZipWorker().postMessage({ id, files });
      } catch (e) {
        zipJobs.delete(id);
        reject(e);
      }
    });
  }

  async function zipMainThread(files, onProgress) {
    const zip = new JSZip();
    files.forEach((f) => zip.file(f.name, f.data));
    return zip.generateAsync({ type: "blob", compression: "STORE" }, (m) => onProgress && onProgress(m.percent));
  }

  async function compress(files, onProgress) {
    if (!workerBroken) {
      try {
        const buffer = await zipInWorker(files, onProgress);
        return new Blob([buffer], { type: "application/zip" });
      } catch (e) {
        log("Worker indisponible, compression dans l'onglet :", e.message);
      }
    }
    return zipMainThread(files, onProgress);
  }

  // ---- Cœur du téléchargement d'une galerie ----
  async function downloadGalleryCore(g, onStatus) {
    const total = g.pages.length;
    const pad = String(total).length;
    const results = new Array(total);
    const queue = [...g.pages];
    let done = 0;

    const worker = async () => {
      while (queue.length) {
        const page = queue.shift();
        const url = `https://i.nhentai.net/galleries/${g.mid}/${page.i}.${page.ext}`;
        let data = null;
        for (let essai = 0; essai < 3 && !data; essai++) {
          try { data = await req(url, "arraybuffer"); }
          catch (e) { log("nouvel essai page", page.i, e.message); await sleep(800); }
        }
        if (!data) throw new Error("Échec du téléchargement de la page " + page.i);
        results[page.i - 1] = { name: `${String(page.i).padStart(pad, "0")}.${page.ext}`, data };
        done++;
        onStatus(`${done}/${total}`, (100 * done) / total, "dl");
      }
    };

    await Promise.all(Array.from({ length: THREADS }, worker));

    const blob = await compress(results.filter(Boolean), (pct) => {
      const p = Number(pct).toFixed(0);
      onStatus(`Zip ${p}%`, pct, "zip");
    });

    const filename = `${sanitize(g.title) || `gallery-${g.mid}`}.cbz`;
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 5000);
    return filename;
  }

  // ---- Bouton individuel ----
  function makeBtn(txt, small) {
    const b = document.createElement("button");
    b.className = "cbz-dl-btn" + (small ? " cbz-small" : "");
    b.textContent = txt;
    return b;
  }

  async function run(gid, btn, label) {
    if (btn.dataset.busy) return;
    btn.dataset.busy = "1";
    btn.disabled = true;
    btn.classList.remove("cbz-ok", "cbz-err");
    let resetDelay = 5000;
    try {
      const g = await getGallery(gid);
      showPanel(g.title);
      const filename = await downloadGalleryCore(g, (txt, pct, mode) => {
        btn.textContent = txt;
        updatePanel(pct, txt, mode);
      });
      setStatus(gid, "ok");
      btn.textContent = "✓ Téléchargé avec succès";
      btn.classList.add("cbz-ok");
      updatePanel(100, "✓ Terminé", "zip");
      hidePanel(3000);
      showToast("success", `✓ Téléchargé avec succès : ${filename}`);
    } catch (e) {
      log(e);
      const msg = e && e.message ? e.message : String(e);
      btn.textContent = `✗ Erreur : ${msg}`;
      btn.classList.add("cbz-err");
      updatePanel(100, "✗ Erreur", "err");
      hidePanel(4000);
      showToast("error", `✗ Échec du téléchargement : ${msg}`);
      setStatus(gid, "err");
      resetDelay = 8000;
    } finally {
      btn.disabled = false;
      delete btn.dataset.busy;
      setTimeout(() => paintStatus(btn, gid, label), resetDelay);
    }
  }

  function initDetailPage() {
    const gid = (location.pathname.match(/\/g\/(\d+)/) || [])[1];
    const box = document.querySelector("#info .buttons") || document.querySelector("#info");
    if (!gid || !box) return;
    if (box.querySelector("[data-cbz-detail]")) return;
    const label = "Download .cbz";
    const btn = makeBtn(label, false);
    btn.dataset.cbzDetail = "1";
    box.appendChild(btn);
    registerBtn(btn, gid, label);
    paintStatus(btn, gid, label);
    btn.addEventListener("click", () => run(gid, btn, label));
  }

  function initListButtons() {
    document.querySelectorAll(".gallery").forEach((g) => {
      if (g.dataset.cbzInit) return;
      const a = g.querySelector("a.cover") || g.querySelector("a");
      if (!a) return;
      const gid = ((a.getAttribute("href") || "").match(/\/g\/(\d+)/) || [])[1];
      if (!gid) return;
      g.dataset.cbzInit = "1";
      const label = "⬇ CBZ";
      const btn = makeBtn(label, true);
      g.appendChild(btn);
      registerBtn(btn, gid, label);
      paintStatus(btn, gid, label);
      btn.addEventListener("click", (e) => {
        e.preventDefault();
        e.stopPropagation();
        run(gid, btn, label);
      });
    });
  }

  // ---- Collecte des galeries d'une liste (pagination comprise) ----
  function parseGalleries(doc, entries) {
    let added = 0;
    doc.querySelectorAll(".gallery").forEach((gEl) => {
      const a = gEl.querySelector("a.cover") || gEl.querySelector("a");
      const href = (a && a.getAttribute("href")) || "";
      const gid = (href.match(/\/g\/(\d+)/) || [])[1];
      if (gid && !entries.has(gid)) { entries.set(gid, { gid }); added++; }
    });
    return added;
  }

  function getMaxPage(doc) {
    let maxPage = 1;
    doc.querySelectorAll(".pagination a, .pagination button, .pagination span").forEach((el) => {
      const href = (el.getAttribute && el.getAttribute("href")) || "";
      const m = href.match(/[?&]page=(\d+)/);
      if (m) maxPage = Math.max(maxPage, Number(m[1]));
      const n = parseInt((el.textContent || "").trim(), 10);
      if (!isNaN(n)) maxPage = Math.max(maxPage, n);
    });
    return maxPage;
  }

  async function collectGalleriesFromUrl(url) {
    const entries = new Map();
    let firstHtml;
    try { firstHtml = await req(url); } catch (e) { return []; }
    const firstDoc = new DOMParser().parseFromString(firstHtml, "text/html");
    parseGalleries(firstDoc, entries);
    const maxPage = Math.min(getMaxPage(firstDoc), 60);
    for (let p = 2; p <= maxPage; p++) {
      const sep = url.includes("?") ? "&" : "?";
      let added = 0;
      try {
        const html = await req(`${url}${sep}page=${p}`);
        added = parseGalleries(new DOMParser().parseFromString(html, "text/html"), entries);
      } catch (e) { break; }
      if (!added) break;
    }
    return [...entries.values()];
  }

  // ---- Fenêtre de choix du lot ----
  function askBulkChoice(count, onChoice) {
    const old = document.getElementById("cbz-bulk-dialog");
    if (old) old.remove();
    const box = document.createElement("div");
    box.id = "cbz-bulk-dialog";
    box.innerHTML =
      '<div class="cbz-bulk-box">' +
      `<div class="cbz-bulk-title">Télécharger les ${count} galeries de cette liste ?</div>` +
      '<div class="cbz-bulk-sub">Choisis ce qui doit être téléchargé en .cbz :</div>' +
      '<div class="cbz-bulk-buttons">' +
      '<button data-f="all">Tout télécharger</button>' +
      '<button data-f="english">English uniquement</button>' +
      '<button data-f="japanese">Japanese uniquement</button>' +
      '<button data-f="chinese">Chinese uniquement</button>' +
      '<button data-f="cancel" class="cbz-bulk-cancel">Annuler</button>' +
      "</div></div>";
    document.body.appendChild(box);
    box.addEventListener("click", (e) => {
      const f = e.target && e.target.dataset ? e.target.dataset.f : null;
      if (!f) return;
      box.remove();
      if (f !== "cancel") onChoice(f);
    });
  }

  // ---- Téléchargement groupé + rapport ----
  let bulkRunning = false;

  async function bulkDownload(entries, filter) {
    if (bulkRunning) { showToast("error", "Un téléchargement groupé est déjà en cours"); return; }
    bulkRunning = true;
    let done = 0, skipped = 0, failed = 0;
    let firstError = "";
    const totalAll = entries.length;
    try {
      for (let idx = 0; idx < totalAll; idx++) {
        const entry = entries[idx];
        const head = `Lot ${idx + 1}/${totalAll} — `;
        showPanel(`Lot ${idx + 1}/${totalAll}`);
        updatePanel(0, head + "infos…", "dl");
        try {
          const g = await getGallery(entry.gid);
          if (filter !== "all" && (g.lang || "japanese") !== filter) { skipped++; continue; }
          await downloadGalleryCore(g, (txt, pct, mode) => updatePanel(pct, head + txt, mode));
          setStatus(entry.gid, "ok");
          repaintBtn(entry.gid);
          done++;
          log(`Lot ${idx + 1} OK :`, g.title);
        } catch (e) {
          log("Lot échec :", e);
          if (!firstError) firstError = e && e.message ? e.message : String(e);
          setStatus(entry.gid, "err");
          repaintBtn(entry.gid);
          failed++;
        }
      }
      const msg = `Lot terminé : ${done} réussi(s), ${failed} échec(s), ${skipped} ignoré(s)`;
      showToast(failed ? "error" : "success", (failed ? "✗ " : "✓ ") + msg);
      updatePanel(100, msg, failed ? "err" : "zip");
      showReport(
        `Galeries analysées : ${totalAll}\n` +
        `✓ Téléchargées : ${done}\n` +
        `✗ Échecs : ${failed}\n` +
        `— Ignorées (langue) : ${skipped}` +
        (firstError ? `\n\nPremière erreur : ${firstError}` : "")
      );
    } finally {
      bulkRunning = false;
      hidePanel(4000);
    }
  }

  async function startBulkFromUrl(url, btn) {
    if (bulkRunning) { showToast("error", "Un lot est déjà en cours"); return; }
    btn.disabled = true;
    const oldTxt = btn.textContent;
    btn.textContent = "…";
    let entries = [];
    try { entries = await collectGalleriesFromUrl(url); } catch (e) { log(e); }
    btn.disabled = false;
    btn.textContent = oldTxt;
    if (!entries.length) { showToast("error", "Aucune galerie trouvée dans cette liste"); return; }
    askBulkChoice(entries.length, (filter) => bulkDownload(entries, filter));
  }

  // ---- Bouton groupé sur les pages de liste ----
  function initBulkButton() {
    if (location.pathname.match(/^\/g\/\d+/)) return;
    if (!document.querySelector(".gallery")) return;
    if (document.getElementById("cbz-bulk-btn")) return;
    const btn = makeBtn("⬇ Tout en CBZ", false);
    btn.id = "cbz-bulk-btn";
    btn.addEventListener("click", () => startBulkFromUrl(location.href, btn));
    const header = document.querySelector("#content h1") || document.querySelector("h1");
    if (header) header.appendChild(btn);
    else (document.querySelector("#content") || document.body).prepend(btn);
  }

  // ---- NOUVEAU : petits boutons "lot" à côté de CHAQUE tag (page détail) ----
  const TAG_TYPES = ["artist", "tag", "character", "parody", "group", "language", "category"];

  function initTagBulkButtons() {
    if (!location.pathname.match(/^\/g\/\d+/)) return;
    const box = document.querySelector("#tags");
    if (!box) return;
    box.querySelectorAll("a").forEach((a) => {
      if (a.dataset.cbzBulkTag) return;
      const href = a.getAttribute("href") || "";
      if (!TAG_TYPES.some((t) => href.startsWith(`/${t}/`))) return;
      a.dataset.cbzBulkTag = "1";
      const btn = document.createElement("button");
      btn.className = "cbz-tag-btn";
      btn.textContent = "⬇";
      btn.title = "Télécharger toutes les galeries de ce tag en .cbz";
      a.parentNode.insertBefore(btn, a.nextSibling);
      btn.addEventListener("click", (e) => {
        e.preventDefault();
        e.stopPropagation();
        startBulkFromUrl(location.origin + href, btn);
      });
    });
  }

  // ---- Init ----
  initDetailPage();
  initListButtons();
  initBulkButton();
  initTagBulkButtons();

  let timer = null;
  new MutationObserver(() => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      initListButtons();
      initBulkButton();
      initTagBulkButtons();
    }, 300);
  }).observe(document.body, { childList: true, subtree: true });
})();