Greasy Fork is available in English.
Adds a bulk request management panel to Jellyseerr with filters, selection, and mass deletion
// ==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">×</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 · <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">·</span><span>${seasons}</span>` : ""}
<span class="jbd-meta-sep">·</span>
<span>${esc(user)}</span>
<span class="jbd-meta-sep">·</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;
}
}
})();