Jellyseerr Bulk Request Manager

Adds a bulk request management panel to Jellyseerr with filters, selection, and mass deletion

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

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

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

(I already have a user script manager, let me install it!)

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

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

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

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

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

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

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Jellyseerr Bulk Request Manager
// @namespace    https://github.com/jellyseerr-bulk-manager
// @version      2.1
// @description  Adds a bulk request management panel to Jellyseerr with filters, selection, and mass deletion
// @match        http://*/*
// @match        https://*/*
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // --- Detect Jellyseerr ---
  function isJellyseerr() {
    return !!(
      document.querySelector('meta[property="og:site_name"][content*="ellyseerr"]') ||
      document.querySelector('meta[property="og:site_name"][content*="verseerr"]') ||
      (document.querySelector("#__next") && document.querySelector('a[href="/requests"]'))
    );
  }

  function waitAndInit() {
    let attempts = 0;
    const check = setInterval(() => {
      attempts++;
      if (isJellyseerr()) {
        clearInterval(check);
        bootstrap();
      } else if (attempts > 20) {
        clearInterval(check);
      }
    }, 500);
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", waitAndInit);
  } else {
    waitAndInit();
  }

  function bootstrap() {
    const API = `${window.location.origin}/api/v1`;
    const TMDB_IMG = "https://image.tmdb.org/t/p";

    const FILTERS = [
      { value: "all", label: "All" },
      { value: "pending", label: "Pending" },
      { value: "approved", label: "Approved" },
      { value: "processing", label: "Processing" },
      { value: "available", label: "Available" },
      { value: "unavailable", label: "Unavailable" },
      { value: "failed", label: "Failed" },
      { value: "deleted", label: "Deleted Media" },
    ];

    let panelOpen = false;
    let currentFilter = "all";
    let allRequests = [];
    let selectedIds = new Set();
    let mediaCache = new Map(); // tmdbId-type -> { title, posterPath, year }

    // ===================== STYLES =====================
    GM_addStyle(`
      /* --- FAB --- */
      #jbd-fab {
        position: fixed;
        bottom: 24px;
        right: 24px;
        z-index: 99990;
        width: 52px;
        height: 52px;
        border-radius: 14px;
        background: linear-gradient(135deg, #6366f1, #4f46e5);
        color: #fff;
        border: none;
        font-size: 22px;
        cursor: pointer;
        box-shadow: 0 4px 20px rgba(79,70,229,0.45), 0 0 0 0 rgba(79,70,229,0);
        display: flex;
        align-items: center;
        justify-content: center;
        transition: transform 0.2s, box-shadow 0.2s;
      }
      #jbd-fab:hover {
        transform: translateY(-2px) scale(1.05);
        box-shadow: 0 6px 28px rgba(79,70,229,0.55);
      }
      #jbd-fab svg { width: 24px; height: 24px; }

      /* --- Backdrop --- */
      #jbd-backdrop {
        position: fixed; inset: 0; z-index: 99992;
        background: rgba(0,0,0,0.4);
        backdrop-filter: blur(2px);
        opacity: 0; pointer-events: none;
        transition: opacity 0.3s;
      }
      #jbd-backdrop.visible { opacity: 1; pointer-events: auto; }

      /* --- Panel --- */
      #jbd-panel {
        position: fixed; top: 0; right: 0;
        width: 520px; max-width: 95vw; height: 100vh;
        z-index: 99995;
        background: #0f1521;
        border-left: 1px solid rgba(255,255,255,0.06);
        display: flex; flex-direction: column;
        font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
        color: #d1d5db;
        transform: translateX(100%);
        transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
      }
      #jbd-panel.open { transform: translateX(0); }

      /* --- Header --- */
      .jbd-header {
        padding: 18px 20px 14px;
        display: flex; align-items: center; justify-content: space-between;
        border-bottom: 1px solid rgba(255,255,255,0.06);
        background: rgba(15,21,33,0.95);
        backdrop-filter: blur(12px);
        flex-shrink: 0;
      }
      .jbd-header h2 {
        margin: 0; font-size: 17px; font-weight: 700; color: #f9fafb;
        display: flex; align-items: center; gap: 8px;
      }
      .jbd-header h2 svg { width: 20px; height: 20px; color: #818cf8; }
      .jbd-close {
        background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
        color: #9ca3af; width: 32px; height: 32px; border-radius: 8px;
        cursor: pointer; display: flex; align-items: center; justify-content: center;
        font-size: 18px; transition: all 0.15s;
      }
      .jbd-close:hover { background: rgba(255,255,255,0.1); color: #fff; }

      /* --- Filters --- */
      .jbd-filters {
        padding: 12px 20px;
        display: flex; flex-wrap: wrap; gap: 6px;
        border-bottom: 1px solid rgba(255,255,255,0.06);
        flex-shrink: 0;
      }
      .jbd-chip {
        padding: 5px 14px; border-radius: 20px;
        border: 1px solid rgba(255,255,255,0.1);
        background: transparent; color: #9ca3af;
        font-size: 12px; font-weight: 500; cursor: pointer;
        transition: all 0.15s;
      }
      .jbd-chip:hover { border-color: #6366f1; color: #c7d2fe; }
      .jbd-chip.active { background: #4f46e5; border-color: #4f46e5; color: #fff; }
      .jbd-chip.deleted-active { background: #dc2626; border-color: #dc2626; color: #fff; }

      /* --- Actions bar --- */
      .jbd-actions {
        padding: 10px 20px;
        display: flex; align-items: center; gap: 8px;
        border-bottom: 1px solid rgba(255,255,255,0.06);
        flex-shrink: 0;
      }
      .jbd-actions button {
        border: none; border-radius: 8px;
        padding: 7px 14px; font-size: 12px; font-weight: 600;
        cursor: pointer; color: #fff; transition: all 0.15s;
        display: flex; align-items: center; gap: 5px;
      }
      .jbd-btn-ghost { background: rgba(255,255,255,0.06); color: #d1d5db; }
      .jbd-btn-ghost:hover { background: rgba(255,255,255,0.1); }
      .jbd-btn-red { background: #dc2626; }
      .jbd-btn-red:hover { background: #b91c1c; }
      .jbd-btn-red:disabled { opacity: 0.4; cursor: not-allowed; }
      .jbd-btn-orange { background: #d97706; }
      .jbd-btn-orange:hover { background: #b45309; }
      .jbd-count {
        margin-left: auto;
        font-size: 13px; font-weight: 600; color: #818cf8;
      }

      /* --- List --- */
      .jbd-list {
        flex: 1; overflow-y: auto; padding: 0; margin: 0; list-style: none;
      }
      .jbd-list::-webkit-scrollbar { width: 5px; }
      .jbd-list::-webkit-scrollbar-track { background: transparent; }
      .jbd-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }

      /* --- Request item --- */
      .jbd-item {
        display: flex; align-items: center; gap: 12px;
        padding: 10px 20px;
        border-bottom: 1px solid rgba(255,255,255,0.03);
        cursor: pointer; transition: background 0.12s;
        position: relative;
      }
      .jbd-item:hover { background: rgba(255,255,255,0.03); }
      .jbd-item.selected { background: rgba(79,70,229,0.12); }
      .jbd-item.selected::before {
        content: '';
        position: absolute; left: 0; top: 0; bottom: 0;
        width: 3px; background: #6366f1; border-radius: 0 3px 3px 0;
      }

      .jbd-item input[type="checkbox"] {
        width: 16px; height: 16px;
        accent-color: #6366f1; cursor: pointer; flex-shrink: 0;
      }

      .jbd-poster {
        width: 38px; height: 57px;
        border-radius: 6px; object-fit: cover;
        flex-shrink: 0; background: rgba(255,255,255,0.05);
      }
      .jbd-poster-placeholder {
        width: 38px; height: 57px;
        border-radius: 6px; flex-shrink: 0;
        background: rgba(255,255,255,0.05);
        display: flex; align-items: center; justify-content: center;
        color: rgba(255,255,255,0.15); font-size: 16px;
      }

      .jbd-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
      .jbd-title {
        font-size: 13px; font-weight: 600; color: #f3f4f6;
        white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      }
      .jbd-title.loading { color: #6b7280; font-style: italic; }
      .jbd-meta {
        font-size: 11px; color: #6b7280;
        display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
      }
      .jbd-meta-sep { color: rgba(255,255,255,0.1); }
      .jbd-type-badge {
        font-size: 9px; font-weight: 700; text-transform: uppercase;
        padding: 1px 6px; border-radius: 4px; letter-spacing: 0.5px;
      }
      .jbd-type-movie { background: rgba(59,130,246,0.15); color: #60a5fa; }
      .jbd-type-tv { background: rgba(168,85,247,0.15); color: #c084fc; }

      .jbd-badge {
        font-size: 10px; font-weight: 700;
        padding: 3px 10px; border-radius: 12px;
        text-transform: uppercase; letter-spacing: 0.3px;
        flex-shrink: 0; white-space: nowrap;
      }
      .jbd-s-pending { background: rgba(234,179,8,0.15); color: #facc15; }
      .jbd-s-approved { background: rgba(34,197,94,0.15); color: #4ade80; }
      .jbd-s-declined { background: rgba(239,68,68,0.15); color: #f87171; }
      .jbd-s-available { background: rgba(59,130,246,0.15); color: #60a5fa; }
      .jbd-s-processing { background: rgba(14,165,233,0.15); color: #38bdf8; }
      .jbd-s-failed { background: rgba(239,68,68,0.2); color: #fca5a5; }
      .jbd-s-deleted { background: rgba(239,68,68,0.25); color: #fca5a5; border: 1px solid rgba(239,68,68,0.3); }

      /* --- States --- */
      .jbd-state {
        display: flex; flex-direction: column; align-items: center;
        justify-content: center; padding: 48px 20px; color: #6b7280;
        text-align: center; gap: 12px;
      }
      .jbd-state svg { width: 40px; height: 40px; color: #374151; }
      .jbd-spinner {
        width: 32px; height: 32px;
        border: 3px solid rgba(255,255,255,0.06);
        border-top-color: #6366f1;
        border-radius: 50%;
        animation: jbd-spin 0.7s linear infinite;
      }
      @keyframes jbd-spin { to { transform: rotate(360deg); } }

      /* --- Progress overlay --- */
      .jbd-overlay {
        position: fixed; inset: 0; z-index: 99999;
        background: rgba(0,0,0,0.75); backdrop-filter: blur(4px);
        display: flex; align-items: center; justify-content: center;
      }
      .jbd-progress-card {
        background: #1f2937; border: 1px solid rgba(255,255,255,0.08);
        border-radius: 16px; padding: 32px 40px;
        text-align: center; color: #e5e7eb; min-width: 340px;
      }
      .jbd-progress-card h3 { margin: 0 0 20px; font-size: 17px; font-weight: 700; }
      .jbd-pbar-track {
        background: rgba(255,255,255,0.06); border-radius: 8px;
        height: 10px; overflow: hidden; margin-bottom: 14px;
      }
      .jbd-pbar-fill {
        background: linear-gradient(90deg, #6366f1, #818cf8);
        height: 100%; width: 0%; transition: width 0.25s; border-radius: 8px;
      }
      .jbd-pbar-text { font-size: 13px; color: #9ca3af; }

      /* --- Footer --- */
      .jbd-footer {
        padding: 10px 20px;
        border-top: 1px solid rgba(255,255,255,0.06);
        font-size: 11px; color: #4b5563; text-align: center;
        flex-shrink: 0;
      }
      .jbd-footer a { color: #6366f1; text-decoration: none; }
      .jbd-footer a:hover { text-decoration: underline; }
    `);

    // ===================== DOM =====================

    // FAB
    const fab = document.createElement("button");
    fab.id = "jbd-fab";
    fab.title = "Bulk Request Manager";
    fab.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 5.25h16.5m-16.5-10.5h16.5"/></svg>`;
    document.body.appendChild(fab);

    // Backdrop
    const backdrop = document.createElement("div");
    backdrop.id = "jbd-backdrop";
    document.body.appendChild(backdrop);

    // Panel
    const panel = document.createElement("div");
    panel.id = "jbd-panel";
    panel.innerHTML = `
      <div class="jbd-header">
        <h2>
          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15a2.25 2.25 0 0 1 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z"/></svg>
          Bulk Request Manager
        </h2>
        <button class="jbd-close" id="jbd-close">&times;</button>
      </div>
      <div class="jbd-filters" id="jbd-filters"></div>
      <div class="jbd-actions">
        <button class="jbd-btn-ghost" id="jbd-selall">Select All</button>
        <button class="jbd-btn-ghost" id="jbd-desel">Deselect</button>
        <button class="jbd-btn-ghost" id="jbd-refresh">
          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="width:14px;height:14px"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"/></svg>
          Reload
        </button>
        <button class="jbd-btn-red" id="jbd-delete" disabled>Delete</button>
        <button class="jbd-btn-orange" id="jbd-purge">Purge Orphans</button>
        <span class="jbd-count" id="jbd-count">0 selected</span>
      </div>
      <ul class="jbd-list" id="jbd-list"></ul>
      <div class="jbd-footer">Jellyseerr Bulk Manager &middot; <a href="https://github.com" target="_blank">GitHub</a></div>
    `;
    document.body.appendChild(panel);

    // Build filter chips
    const filtersEl = document.getElementById("jbd-filters");
    FILTERS.forEach((f) => {
      const chip = document.createElement("button");
      chip.className = "jbd-chip" + (f.value === currentFilter ? " active" : "");
      chip.textContent = f.label;
      chip.dataset.filter = f.value;
      chip.addEventListener("click", () => {
        currentFilter = f.value;
        filtersEl.querySelectorAll(".jbd-chip").forEach((c) => {
          c.classList.remove("active", "deleted-active");
        });
        chip.classList.add(f.value === "deleted" ? "deleted-active" : "active");
        selectedIds.clear();
        syncUI();
        fetchRequests();
      });
      filtersEl.appendChild(chip);
    });

    // Events
    fab.addEventListener("click", togglePanel);
    backdrop.addEventListener("click", togglePanel);
    document.getElementById("jbd-close").addEventListener("click", togglePanel);
    document.getElementById("jbd-selall").addEventListener("click", selectAll);
    document.getElementById("jbd-desel").addEventListener("click", deselectAll);
    document.getElementById("jbd-refresh").addEventListener("click", () => fetchRequests());
    document.getElementById("jbd-delete").addEventListener("click", deleteSelected);
    document.getElementById("jbd-purge").addEventListener("click", purgeOrphanedMedia);

    // ===================== PANEL TOGGLE =====================
    function togglePanel() {
      panelOpen = !panelOpen;
      panel.classList.toggle("open", panelOpen);
      backdrop.classList.toggle("visible", panelOpen);
      fab.style.display = panelOpen ? "none" : "flex";
      if (panelOpen && allRequests.length === 0) fetchRequests();
    }

    // ===================== API =====================
    async function fetchRequests() {
      showLoading();
      allRequests = [];
      selectedIds.clear();
      syncUI();

      try {
        let page = 1;
        let totalPages = 1;
        const filterParam = currentFilter === "all" ? "" : `&filter=${currentFilter}`;

        while (page <= totalPages) {
          const res = await fetch(
            `${API}/request?take=100&skip=${(page - 1) * 100}${filterParam}&sort=added&sortDirection=desc`
          );
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          const data = await res.json();
          totalPages = data.pageInfo.pages;
          allRequests.push(...data.results);
          page++;
        }

        renderList();
        // Fetch media details (titles, posters) in background
        fetchAllMediaDetails();
      } catch (err) {
        console.error("JBD:", err);
        showEmpty("Failed to load requests.");
      }
    }

    async function fetchMediaDetails(tmdbId, type) {
      const key = `${type}-${tmdbId}`;
      if (mediaCache.has(key)) return mediaCache.get(key);

      try {
        const endpoint = type === "movie" ? "movie" : "tv";
        const res = await fetch(`${API}/${endpoint}/${tmdbId}`);
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        const info = {
          title: data.title || data.name || "Unknown",
          posterPath: data.posterPath || null,
          year: (data.releaseDate || data.firstAirDate || "").substring(0, 4),
          overview: (data.overview || "").substring(0, 120),
        };
        mediaCache.set(key, info);
        return info;
      } catch {
        const fallback = { title: `${type === "movie" ? "Movie" : "TV Show"} #${tmdbId}`, posterPath: null, year: "", overview: "" };
        mediaCache.set(`${type}-${tmdbId}`, fallback);
        return fallback;
      }
    }

    async function fetchAllMediaDetails() {
      // Deduplicate by tmdbId+type
      const toFetch = new Map();
      allRequests.forEach((req) => {
        if (!req.media) return;
        const key = `${req.type}-${req.media.tmdbId}`;
        if (!mediaCache.has(key) && !toFetch.has(key)) {
          toFetch.set(key, { tmdbId: req.media.tmdbId, type: req.type });
        }
      });

      // Fetch in batches of 5 concurrently
      const entries = [...toFetch.values()];
      for (let i = 0; i < entries.length; i += 5) {
        const batch = entries.slice(i, i + 5);
        await Promise.all(batch.map((e) => fetchMediaDetails(e.tmdbId, e.type)));
        // Update visible items as details come in
        updateMediaInList();
      }
    }

    function updateMediaInList() {
      document.querySelectorAll(".jbd-item[data-cache-key]").forEach((li) => {
        const key = li.dataset.cacheKey;
        const info = mediaCache.get(key);
        if (!info) return;

        const titleEl = li.querySelector(".jbd-title");
        if (titleEl && titleEl.classList.contains("loading")) {
          titleEl.textContent = info.title;
          titleEl.classList.remove("loading");
        }

        const posterEl = li.querySelector(".jbd-poster-placeholder");
        if (posterEl && info.posterPath) {
          const img = document.createElement("img");
          img.className = "jbd-poster";
          img.src = `${TMDB_IMG}/w92${info.posterPath}`;
          img.alt = info.title;
          img.loading = "lazy";
          posterEl.replaceWith(img);
        }

        const yearEl = li.querySelector(".jbd-year");
        if (yearEl && info.year) {
          yearEl.textContent = info.year;
        }
      });
    }

    // ===================== RENDER =====================
    function showLoading() {
      document.getElementById("jbd-list").innerHTML = `
        <div class="jbd-state">
          <div class="jbd-spinner"></div>
          <div>Loading requests...</div>
        </div>`;
    }

    function showEmpty(msg) {
      document.getElementById("jbd-list").innerHTML = `
        <div class="jbd-state">
          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25 2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"/></svg>
          <div>${msg || "No requests found for this filter."}</div>
        </div>`;
    }

    function renderList() {
      const list = document.getElementById("jbd-list");
      if (allRequests.length === 0) { showEmpty(); return; }

      list.innerHTML = "";
      allRequests.forEach((req) => {
        const li = document.createElement("li");
        li.className = "jbd-item" + (selectedIds.has(req.id) ? " selected" : "");

        const media = req.media || {};
        const cacheKey = `${req.type}-${media.tmdbId}`;
        li.dataset.cacheKey = cacheKey;
        li.dataset.reqId = req.id;

        const cached = mediaCache.get(cacheKey);
        const title = cached ? cached.title : "Loading...";
        const titleClass = cached ? "jbd-title" : "jbd-title loading";
        const year = cached ? cached.year || "" : "";
        const posterPath = cached ? cached.posterPath : null;

        const statusInfo = getStatusInfo(req);
        const user = req.requestedBy ? req.requestedBy.displayName || req.requestedBy.email : "Unknown";
        const avatar = req.requestedBy && req.requestedBy.avatar
          ? req.requestedBy.avatar
          : "";
        const date = formatDate(req.createdAt);
        const typeBadge = req.type === "movie"
          ? `<span class="jbd-type-badge jbd-type-movie">Movie</span>`
          : `<span class="jbd-type-badge jbd-type-tv">TV</span>`;
        const seasons = req.seasons && req.seasons.length
          ? req.seasons.map((s) => `S${s.seasonNumber}`).join(" ")
          : "";

        li.innerHTML = `
          <input type="checkbox" ${selectedIds.has(req.id) ? "checked" : ""}>
          ${posterPath
            ? `<img class="jbd-poster" src="${TMDB_IMG}/w92${posterPath}" alt="" loading="lazy">`
            : `<div class="jbd-poster-placeholder">
                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width:18px;height:18px"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z"/></svg>
              </div>`}
          <div class="jbd-info">
            <div class="${titleClass}">${esc(title)}</div>
            <div class="jbd-meta">
              ${typeBadge}
              <span class="jbd-year">${year}</span>
              ${seasons ? `<span class="jbd-meta-sep">&middot;</span><span>${seasons}</span>` : ""}
              <span class="jbd-meta-sep">&middot;</span>
              <span>${esc(user)}</span>
              <span class="jbd-meta-sep">&middot;</span>
              <span>${date}</span>
            </div>
          </div>
          <span class="jbd-badge ${statusInfo.cls}">${statusInfo.label}</span>
        `;

        const cb = li.querySelector('input[type="checkbox"]');
        li.addEventListener("click", (e) => {
          if (e.target === cb) return;
          cb.checked = !cb.checked;
          toggleSel(req.id, cb.checked, li);
        });
        cb.addEventListener("change", () => toggleSel(req.id, cb.checked, li));

        list.appendChild(li);
      });
    }

    // ===================== SELECTION =====================
    function toggleSel(id, checked, li) {
      if (checked) { selectedIds.add(id); li.classList.add("selected"); }
      else { selectedIds.delete(id); li.classList.remove("selected"); }
      syncUI();
    }

    function selectAll() {
      allRequests.forEach((r) => selectedIds.add(r.id));
      document.querySelectorAll(".jbd-item").forEach((li) => {
        li.classList.add("selected");
        li.querySelector('input[type="checkbox"]').checked = true;
      });
      syncUI();
    }

    function deselectAll() {
      selectedIds.clear();
      document.querySelectorAll(".jbd-item").forEach((li) => {
        li.classList.remove("selected");
        li.querySelector('input[type="checkbox"]').checked = false;
      });
      syncUI();
    }

    function syncUI() {
      const n = selectedIds.size;
      document.getElementById("jbd-count").textContent =
        n === 0 ? "0 selected" : `${n} selected`;
      document.getElementById("jbd-delete").disabled = n === 0;
    }

    // ===================== DELETE =====================
    async function deleteSelected() {
      const n = selectedIds.size;
      if (n === 0) return;
      if (!confirm(`Delete ${n} request(s)?\n\nThis will remove both the request(s) and their associated media entries so the content can be re-requested.\n\nThis action cannot be undone.`)) return;

      // Build a list of { requestId, mediaId, mediaStatus } for each selected request
      const toDelete = [];
      for (const id of selectedIds) {
        const req = allRequests.find((r) => r.id === id);
        if (req) {
          toDelete.push({
            requestId: req.id,
            mediaId: req.media ? req.media.id : null,
            mediaStatus: req.media ? req.media.status : null,
          });
        }
      }

      const totalSteps = toDelete.length;
      const overlay = document.createElement("div");
      overlay.className = "jbd-overlay";
      overlay.innerHTML = `
        <div class="jbd-progress-card">
          <h3>Deleting requests...</h3>
          <div class="jbd-pbar-track"><div class="jbd-pbar-fill" id="jbd-pf"></div></div>
          <div class="jbd-pbar-text" id="jbd-pt">0 / ${totalSteps}</div>
        </div>`;
      document.body.appendChild(overlay);

      let done = 0, errors = 0;
      // Collect unique media IDs to delete after all requests are removed
      const mediaIdsToDelete = new Set();

      // Step 1: Delete all requests
      for (const item of toDelete) {
        try {
          const res = await fetch(`${API}/request/${item.requestId}`, { method: "DELETE" });
          if (!res.ok) { errors++; console.error(`JBD: delete request ${item.requestId} -> ${res.status}`); }
          else if (item.mediaId) { mediaIdsToDelete.add(item.mediaId); }
        } catch (err) { errors++; console.error(`JBD: delete request ${item.requestId}`, err); }
        done++;
        document.getElementById("jbd-pf").style.width = Math.round((done / totalSteps) * 100) + "%";
        document.getElementById("jbd-pt").textContent =
          `${done} / ${totalSteps}` + (errors ? ` (${errors} error(s))` : "");
      }

      // Step 2: Delete associated media entries so the content no longer appears as "requested"
      if (mediaIdsToDelete.size > 0) {
        overlay.querySelector("h3").textContent = "Cleaning up media entries...";
        document.getElementById("jbd-pf").style.width = "0%";
        document.getElementById("jbd-pt").textContent = `0 / ${mediaIdsToDelete.size}`;

        let mediaDone = 0, mediaErrors = 0;
        for (const mediaId of mediaIdsToDelete) {
          try {
            const res = await fetch(`${API}/media/${mediaId}`, { method: "DELETE" });
            // 404 is fine — media may already be gone after request deletion
            if (!res.ok && res.status !== 404) {
              mediaErrors++;
              console.error(`JBD: delete media ${mediaId} -> ${res.status}`);
            }
          } catch (err) { mediaErrors++; console.error(`JBD: delete media ${mediaId}`, err); }
          mediaDone++;
          document.getElementById("jbd-pf").style.width = Math.round((mediaDone / mediaIdsToDelete.size) * 100) + "%";
          document.getElementById("jbd-pt").textContent =
            `${mediaDone} / ${mediaIdsToDelete.size}` + (mediaErrors ? ` (${mediaErrors} error(s))` : "");
        }
        errors += mediaErrors;
      }

      overlay.querySelector("h3").textContent = "Done!";
      document.getElementById("jbd-pt").textContent =
        `${done - errors} deleted` + (errors ? `, ${errors} error(s)` : "");

      setTimeout(() => {
        overlay.remove();
        selectedIds.clear();
        syncUI();
        fetchRequests();
      }, 1200);
    }

    // ===================== PURGE ORPHANED MEDIA =====================
    async function purgeOrphanedMedia() {
      const btn = document.getElementById("jbd-purge");
      btn.textContent = "Scanning...";
      btn.disabled = true;

      try {
        // Fetch ALL media entries (no "deleted" filter available, so we get everything)
        const allMedia = [];
        let page = 1;
        let totalPages = 1;

        while (page <= totalPages) {
          const res = await fetch(`${API}/media?take=100&skip=${(page - 1) * 100}`);
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          const data = await res.json();
          totalPages = data.pageInfo.pages;
          allMedia.push(...data.results);
          page++;
        }

        // Filter for media with status 6 (DELETED) — these are orphaned/stale entries
        // MediaStatus: 1=UNKNOWN, 2=PENDING, 3=PROCESSING, 4=PARTIALLY_AVAILABLE, 5=AVAILABLE, 6=BLOCKLISTED, 7=DELETED
        const orphaned = allMedia.filter((m) => m.status === 7);

        if (orphaned.length === 0) {
          alert("No orphaned media entries found. Everything is clean!");
          btn.textContent = "Purge Orphans";
          btn.disabled = false;
          return;
        }

        if (!confirm(`Found ${orphaned.length} orphaned media entry/entries (deleted content still tracked by Jellyseerr).\n\nPurging these will allow you to re-request this content.\n\nProceed?`)) {
          btn.textContent = "Purge Orphans";
          btn.disabled = false;
          return;
        }

        // Show progress
        const overlay = document.createElement("div");
        overlay.className = "jbd-overlay";
        overlay.innerHTML = `
          <div class="jbd-progress-card">
            <h3>Purging orphaned media...</h3>
            <div class="jbd-pbar-track"><div class="jbd-pbar-fill" id="jbd-pf"></div></div>
            <div class="jbd-pbar-text" id="jbd-pt">0 / ${orphaned.length}</div>
          </div>`;
        document.body.appendChild(overlay);

        let done = 0, errors = 0;
        for (const media of orphaned) {
          try {
            const res = await fetch(`${API}/media/${media.id}`, { method: "DELETE" });
            if (!res.ok && res.status !== 404) {
              errors++;
              console.error(`JBD: purge media ${media.id} -> ${res.status}`);
            }
          } catch (err) { errors++; console.error(`JBD: purge media ${media.id}`, err); }
          done++;
          document.getElementById("jbd-pf").style.width = Math.round((done / orphaned.length) * 100) + "%";
          document.getElementById("jbd-pt").textContent =
            `${done} / ${orphaned.length}` + (errors ? ` (${errors} error(s))` : "");
        }

        overlay.querySelector("h3").textContent = "Done!";
        document.getElementById("jbd-pt").textContent =
          `${done - errors} purged` + (errors ? `, ${errors} error(s)` : "");

        setTimeout(() => {
          overlay.remove();
          fetchRequests(); // Refresh the list
        }, 1200);

      } catch (err) {
        console.error("JBD: purge error", err);
        alert("Error scanning media entries. Check the console for details.");
      }

      btn.textContent = "Purge Orphans";
      btn.disabled = false;
    }

    // ===================== HELPERS =====================
    function getStatusInfo(req) {
      const s = req.status;
      const ms = req.media ? req.media.status : null;
      // MediaStatus: 1=UNKNOWN, 2=PENDING, 3=PROCESSING, 4=PARTIALLY_AVAILABLE, 5=AVAILABLE, 6=BLOCKLISTED, 7=DELETED
      if (ms === 7) return { label: "Deleted", cls: "jbd-s-deleted" };
      if (s === 1) return { label: "Pending", cls: "jbd-s-pending" };
      if (s === 3) return { label: "Declined", cls: "jbd-s-declined" };
      if (ms === 5 || ms === 4) return { label: "Available", cls: "jbd-s-available" };
      if (ms === 3) return { label: "Processing", cls: "jbd-s-processing" };
      if (s === 2) return { label: "Approved", cls: "jbd-s-approved" };
      return { label: "Unknown", cls: "jbd-s-pending" };
    }

    function formatDate(dateStr) {
      try {
        return new Date(dateStr).toLocaleDateString("en-US", {
          month: "short", day: "numeric", year: "numeric",
        });
      } catch { return ""; }
    }

    function esc(str) {
      const d = document.createElement("span");
      d.textContent = str || "";
      return d.innerHTML;
    }
  }
})();