Jellyseerr Bulk Request Manager

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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