diff --git a/public/app.js b/public/app.js index 6e5f65e..c7ea835 100644 --- a/public/app.js +++ b/public/app.js @@ -457,6 +457,49 @@ function updateDownloadCard(card, download) { } } +async function handleBlocklistSearch(btn, download) { + if (!confirm(`Blocklist "${download.title}" and trigger a new search?\n\nThis will:\n• Remove the download from the download client\n• Add this release to the blocklist\n• Trigger an automatic search for a new release`)) return; + + btn.disabled = true; + btn.textContent = '⏳ Working…'; + + try { + const res = await fetch('/api/dashboard/blocklist-search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + arrQueueId: download.arrQueueId, + arrType: download.arrType, + arrInstanceUrl: download.arrInstanceUrl, + arrInstanceKey: download.arrInstanceKey, + arrContentId: download.arrContentId, + arrContentType: download.arrContentType + }) + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${res.status}`); + } + + btn.textContent = '✓ Done — searching…'; + btn.className = 'blocklist-search-btn success'; + } catch (err) { + console.error('[Blocklist] Error:', err); + btn.disabled = false; + btn.textContent = '⛔ Blocklist & Search'; + btn.className = 'blocklist-search-btn error'; + btn.title = `Failed: ${err.message}`; + setTimeout(() => { + btn.className = 'blocklist-search-btn'; + btn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search'; + }, 4000); + } +} + function createDownloadCard(download) { const card = document.createElement('div'); card.className = `download-card ${download.type}`; @@ -511,6 +554,15 @@ function createDownloadCard(download) { issueBadge.textContent = 'Import Pending'; issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n')); header.appendChild(issueBadge); + + if (isAdmin && download.arrQueueId) { + const blBtn = document.createElement('button'); + blBtn.className = 'blocklist-search-btn'; + blBtn.textContent = '⛔ Blocklist & Search'; + blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search'; + blBtn.addEventListener('click', () => handleBlocklistSearch(blBtn, download)); + header.appendChild(blBtn); + } } const title = document.createElement('h3'); diff --git a/public/style.css b/public/style.css index 6384c86..a69933a 100644 --- a/public/style.css +++ b/public/style.css @@ -1151,6 +1151,39 @@ body { pointer-events: none; } +.blocklist-search-btn { + font-size: 0.68rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + border: 1px solid var(--error, #e74c3c); + background: transparent; + color: var(--error, #e74c3c); + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, color 0.15s; +} + +.blocklist-search-btn:hover:not(:disabled) { + background: var(--error, #e74c3c); + color: #fff; +} + +.blocklist-search-btn:disabled { + opacity: 0.6; + cursor: default; +} + +.blocklist-search-btn.success { + border-color: var(--success, #27ae60); + color: var(--success, #27ae60); +} + +.blocklist-search-btn.error { + background: var(--error, #e74c3c); + color: #fff; +} + .download-user-badge { padding: 2px 8px; border-radius: 10px; diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 596c712..7160b03 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -321,6 +321,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); + if (issues) { + dlObj.arrQueueId = sonarrMatch.id; + dlObj.arrType = 'sonarr'; + dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; + dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; + dlObj.arrContentId = sonarrMatch.episodeId || null; + dlObj.arrContentType = 'episode'; + } } userDownloads.push(dlObj); } @@ -363,6 +371,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); + if (issues) { + dlObj.arrQueueId = radarrMatch.id; + dlObj.arrType = 'radarr'; + dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; + dlObj.arrInstanceKey = radarrMatch._instanceKey || null; + dlObj.arrContentId = radarrMatch.movieId || null; + dlObj.arrContentType = 'movie'; + } } userDownloads.push(dlObj); } @@ -520,6 +536,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); + if (sonarrIssues) { + download.arrQueueId = sonarrMatch.id; + download.arrType = 'sonarr'; + download.arrInstanceUrl = sonarrMatch._instanceUrl || null; + download.arrInstanceKey = sonarrMatch._instanceKey || null; + download.arrContentId = sonarrMatch.episodeId || null; + download.arrContentType = 'episode'; + } } userDownloads.push(download); continue; // Skip to next torrent @@ -555,6 +579,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); + if (radarrIssues) { + download.arrQueueId = radarrMatch.id; + download.arrType = 'radarr'; + download.arrInstanceUrl = radarrMatch._instanceUrl || null; + download.arrInstanceKey = radarrMatch._instanceKey || null; + download.arrContentId = radarrMatch.movieId || null; + download.arrContentType = 'movie'; + } } userDownloads.push(download); continue; // Skip to next torrent @@ -1059,4 +1091,68 @@ router.get('/stream', requireAuth, async (req, res) => { }); }); +/** + * POST /api/dashboard/blocklist-search + * + * Admin-only. Removes a queue item from Sonarr/Radarr with blocklist=true + * (so the release is not grabbed again), then immediately triggers a new + * automatic search for the same episode/movie. + * + * Body: { + * arrQueueId: number — Sonarr/Radarr queue record id + * arrType: 'sonarr'|'radarr' + * arrInstanceUrl: string — base URL of the arr instance + * arrInstanceKey: string — API key for the arr instance + * arrContentId: number — episodeId (Sonarr) or movieId (Radarr) + * arrContentType: 'episode'|'movie' + * } + */ +router.post('/blocklist-search', requireAuth, async (req, res) => { + try { + const user = req.user; + if (!user.isAdmin) { + return res.status(403).json({ error: 'Admin access required' }); + } + + const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body; + + if (!arrQueueId || !arrType || !arrInstanceUrl || !arrInstanceKey || !arrContentId || !arrContentType) { + return res.status(400).json({ error: 'Missing required fields' }); + } + if (arrType !== 'sonarr' && arrType !== 'radarr') { + return res.status(400).json({ error: 'arrType must be sonarr or radarr' }); + } + + const headers = { 'X-Api-Key': arrInstanceKey }; + + // Step 1: Remove from queue with blocklist=true + await axios.delete(`${arrInstanceUrl}/api/v3/queue/${arrQueueId}`, { + headers, + params: { removeFromClient: true, blocklist: true } + }); + + // Step 2: Trigger a new automatic search + let commandBody; + if (arrType === 'sonarr' && arrContentType === 'episode') { + commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] }; + } else if (arrType === 'radarr' && arrContentType === 'movie') { + commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] }; + } + + if (commandBody) { + await axios.post(`${arrInstanceUrl}/api/v3/command`, commandBody, { headers }); + } + + // Invalidate the poll cache so the next SSE push reflects the removed item + const { pollAllServices } = require('../utils/poller'); + pollAllServices().catch(() => {}); + + console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`); + res.json({ ok: true }); + } catch (err) { + console.error('[Dashboard] blocklist-search error:', sanitizeError(err)); + res.status(502).json({ error: 'Failed to blocklist and search', details: sanitizeError(err) }); + } +}); + module.exports = router; diff --git a/server/utils/poller.js b/server/utils/poller.js index 9d267c3..a631ca5 100644 --- a/server/utils/poller.js +++ b/server/utils/poller.js @@ -160,8 +160,11 @@ async function pollAllServices() { records: sonarrQueues.flatMap(q => { const inst = sonarrInstances.find(i => i.id === q.instance); const url = inst ? inst.url : null; + const key = inst ? inst.apiKey : null; return (q.data.records || []).map(r => { if (r.series) r.series._instanceUrl = url; + r._instanceUrl = url; + r._instanceKey = key; return r; }); }) @@ -175,8 +178,11 @@ async function pollAllServices() { records: radarrQueues.flatMap(q => { const inst = radarrInstances.find(i => i.id === q.instance); const url = inst ? inst.url : null; + const key = inst ? inst.apiKey : null; return (q.data.records || []).map(r => { if (r.movie) r.movie._instanceUrl = url; + r._instanceUrl = url; + r._instanceKey = key; return r; }); })