server/src/routes/queue.ts
Rama main · réplica de lectura del repositorio original.
import { Router, Request, Response } from 'express';
import mediaItemsRepo from '../db/repositories/mediaItems';
import type { MediaItem } from '../types';
import historyRepo from '../db/repositories/historyRepo';
import { logActivity } from '../db/repositories/activity';
import logger from '../utils/logger';
import { openSseStream } from '../utils/sse';
import { toThumbnailUrl } from '../utils/posterUrl';
import { getDeletionService, getUnactionableReason } from '../services/deletion';
import episodeDeletionsRepo, { type EpisodeDeletion } from '../db/repositories/episodeDeletions';
import { episodeLabel, executeQueuedDeletions } from '../services/episodeDeletions';
import { getSonarrService, getRadarrService, getOverseerrService } from '../services/init';
import { DeletionAction, DELETION_ACTION_LABELS } from '../rules/types';
import rulesRepo from '../db/repositories/rules';
import { getNotificationService } from '../notifications';
// Progress event type
interface DeletionProgress {
stage: 'starting' | 'unmonitoring' | 'deleting_files' | 'resetting_overseerr' | 'complete' | 'error';
message: string;
fileProgress?: {
current: number;
total: number;
fileName: string;
status: 'deleting' | 'deleted' | 'failed';
};
result?: {
success: boolean;
fileSizeFreed?: number;
overseerrReset?: boolean;
error?: string;
};
}
const router = Router();
/**
* Fire a DELETION_COMPLETE notification covering the items deleted in a single
* request (manual Process Queue, Delete Now, or the SSE stream). Silently
* no-ops when no items were deleted.
*/
async function sendDeletionCompleteNotification(
items: Array<{ title: string; type: string; ruleId?: number | null }>,
spaceFreedBytes: number,
errorCount: number
): Promise<void> {
if (items.length === 0) return;
const ruleNameCache = new Map<number, string>();
const resolveRuleName = (ruleId: number | null | undefined): string | undefined => {
if (ruleId === null || ruleId === undefined) return undefined;
if (!ruleNameCache.has(ruleId)) {
const rule = rulesRepo.rules.getById(ruleId);
ruleNameCache.set(ruleId, rule?.name ?? `Rule #${ruleId}`);
}
return ruleNameCache.get(ruleId);
};
try {
await getNotificationService().notify('DELETION_COMPLETE', {
itemsDeleted: items.length,
spaceFreedBytes,
spaceFreedGB: (spaceFreedBytes / (1024 * 1024 * 1024)).toFixed(2),
errors: errorCount,
items: items.map((i) => ({
title: i.title,
type: i.type,
ruleName: resolveRuleName(i.ruleId),
})),
});
} catch (notifyError) {
logger.error('Failed to send deletion complete notification:', notifyError);
}
}
/**
* Normalize deletion action values to handle legacy/malformed values
*/
function normalizeDeletionAction(action: string | undefined): DeletionAction {
if (!action) return DeletionAction.UNMONITOR_AND_DELETE;
// Map legacy values to current enum values
const legacyMappings: Record<string, DeletionAction> = {
'delete_files': DeletionAction.DELETE_FILES_ONLY,
'unmonitor': DeletionAction.UNMONITOR_ONLY,
'full_delete': DeletionAction.FULL_REMOVAL,
'remove': DeletionAction.FULL_REMOVAL,
};
// Check if it's a legacy value
if (legacyMappings[action]) {
return legacyMappings[action];
}
// Check if it's a valid current enum value
const validActions = Object.values(DeletionAction) as string[];
if (validActions.includes(action)) {
return action as DeletionAction;
}
// Default fallback
logger.warn(`Unknown deletion action "${action}", defaulting to UNMONITOR_AND_DELETE`);
return DeletionAction.UNMONITOR_AND_DELETE;
}
interface QueueItemResponse {
id: string;
mediaItemId: string;
/** 'media' is a whole movie/show row; 'episode' is a single queued episode. */
kind: 'media' | 'episode';
title: string;
type: string;
size: number;
posterUrl?: string;
queuedAt: string;
deleteAt: string;
/** Name of the rule that queued the item; absent when queued by hand. */
matchedRule?: string;
/** Id of that rule, so the client can link back to it. */
ruleId?: string;
daysRemaining: number;
deletionAction: DeletionAction;
deletionActionLabel: string;
resetOverseerr: boolean;
requestedBy?: string;
tmdbId?: number;
overseerrResetAt?: string;
seasonNumber?: number;
episodeNumber?: number;
}
/** Queue ids carry an `ep-` prefix for episode rows so one route serves both. */
function parseQueueId(raw: string): { kind: 'media' | 'episode'; id: number } | null {
const isEpisode = raw.startsWith('ep-');
const id = parseInt(isEpisode ? raw.slice(3) : raw, 10);
if (isNaN(id)) return null;
return { kind: isEpisode ? 'episode' : 'media', id };
}
function daysUntil(deleteAfter: string, now: Date): number {
return Math.max(0, Math.ceil((new Date(deleteAfter).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)));
}
/**
* Resolves rule names for queue attribution, caching per request so a queue of
* hundreds of items queued by the same rule costs one lookup.
*/
function createRuleNameResolver(): (id: number | null | undefined) => string | undefined {
const cache = new Map<number, string | undefined>();
return (id) => {
if (id === null || id === undefined) return undefined;
if (!cache.has(id)) cache.set(id, rulesRepo.rules.getById(id)?.name);
return cache.get(id);
};
}
/** Map a media row pending deletion into the shape the Queue page renders. */
function mediaRowToQueueItem(
item: MediaItem,
now: Date,
ruleName: (id: number | null | undefined) => string | undefined
): QueueItemResponse {
// Extract extended fields
const itemAny = item as any;
const deletionAction = normalizeDeletionAction(itemAny.deletion_action);
const matchedRuleId = itemAny.matched_rule_id as number | null | undefined;
const matchedRule = ruleName(matchedRuleId);
return {
id: String(item.id),
mediaItemId: String(item.id),
kind: 'media',
title: item.title,
type: item.type === 'show' ? 'tv' : item.type,
size: item.file_size || 0,
posterUrl: toThumbnailUrl(item.poster_url) || undefined,
queuedAt: item.marked_at!,
deleteAt: item.delete_after!,
daysRemaining: daysUntil(item.delete_after!, now),
deletionAction,
deletionActionLabel: DELETION_ACTION_LABELS[deletionAction] || deletionAction,
resetOverseerr: Boolean(itemAny.reset_overseerr),
requestedBy: itemAny.requested_by || undefined,
tmdbId: itemAny.tmdb_id || undefined,
overseerrResetAt: itemAny.overseerr_reset_at || undefined,
// Only surface the id alongside a name — a rule that has since been
// deleted would otherwise link nowhere.
...(matchedRule ? { matchedRule, ruleId: String(matchedRuleId) } : {}),
};
}
/** Map a queued episode row into the same shape the Queue page already renders. */
function episodeRowToQueueItem(row: EpisodeDeletion, now: Date): QueueItemResponse {
const deletionAction = normalizeDeletionAction(row.deletion_action);
const show = mediaItemsRepo.getById(row.media_item_id);
return {
id: `ep-${row.id}`,
mediaItemId: String(row.media_item_id),
kind: 'episode',
title: episodeLabel(row.series_title, row.season_number, row.episode_number, row.episode_title),
type: 'episode',
size: row.file_size || 0,
posterUrl: toThumbnailUrl(show?.poster_url ?? null) || undefined,
queuedAt: row.marked_at,
deleteAt: row.delete_after,
daysRemaining: daysUntil(row.delete_after, now),
deletionAction,
deletionActionLabel: DELETION_ACTION_LABELS[deletionAction] || deletionAction,
resetOverseerr: false,
seasonNumber: row.season_number,
episodeNumber: row.episode_number,
};
}
function pendingEpisodeQueueItems(now: Date): QueueItemResponse[] {
return episodeDeletionsRepo.getAllPending().map((row) => episodeRowToQueueItem(row, now));
}
/**
* Run one queued episode over SSE, using the same progress envelope the Queue
* page already renders for whole-item deletions.
*/
async function streamEpisodeDeletion(rowId: number, res: Response): Promise<void> {
const row = episodeDeletionsRepo.getById(rowId);
if (!row || row.status !== 'pending') {
res.status(404).json({ success: false, error: 'Episode is not in the deletion queue' });
return;
}
const title = episodeLabel(row.series_title, row.season_number, row.episode_number, row.episode_title);
// The deletion runs to the end even if the client goes away.
const stream = openSseStream(res);
const send = (progress: DeletionProgress) => stream.send(progress);
try {
send({ stage: 'starting', message: `Starting deletion of "${title}"...` });
send({ stage: 'deleting_files', message: 'Deleting episode in Sonarr...' });
const result = await executeQueuedDeletions([row]);
const outcome = result.outcomes[0];
if (outcome?.success) {
await sendDeletionCompleteNotification([{ title, type: 'episode' }], result.freedBytes, 0);
send({
stage: 'complete',
message: `"${title}" deleted successfully`,
result: { success: true, fileSizeFreed: result.freedBytes },
});
} else {
send({
stage: 'error',
message: outcome?.error || 'Failed to delete episode',
result: { success: false, error: outcome?.error || 'Failed to delete episode' },
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`Failed to stream episode deletion for "${title}": ${message}`);
send({ stage: 'error', message, result: { success: false, error: message } });
} finally {
stream.close();
}
}
// GET /api/queue/upcoming - Get upcoming deletion queue items
router.get('/upcoming', (req: Request, res: Response) => {
try {
const limit = parseInt(req.query['limit'] as string, 10) || 50;
// Get all items pending deletion
const pendingItems = mediaItemsRepo.getPendingDeletion();
const now = new Date();
const ruleName = createRuleNameResolver();
const queueItems: QueueItemResponse[] = pendingItems
.filter((item) => item.delete_after && item.marked_at)
.map<QueueItemResponse>((item) => mediaRowToQueueItem(item, now, ruleName))
.concat(pendingEpisodeQueueItems(now))
.sort((a, b) => a.daysRemaining - b.daysRemaining)
.slice(0, limit);
// Calculate totals
const totalSize = queueItems.reduce((sum, item) => sum + item.size, 0);
const readyForDeletion = queueItems.filter((item) => item.daysRemaining === 0).length;
const willResetOverseerr = queueItems.filter((item) => item.resetOverseerr).length;
res.json({
success: true,
data: queueItems,
total: queueItems.length,
summary: {
totalItems: pendingItems.length,
totalSize,
readyForDeletion,
willResetOverseerr,
},
});
} catch (error) {
logger.error('Failed to get upcoming queue:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve upcoming queue',
});
}
});
// GET /api/queue - Get full deletion queue
router.get('/', (req: Request, res: Response) => {
try {
// `limit` is optional. When omitted (or non-positive) the entire queue is
// returned — the Queue page renders every item and paginates client-side,
// so a default cap here would silently hide items.
const limitParam = parseInt(req.query['limit'] as string, 10);
const hasLimit = !Number.isNaN(limitParam) && limitParam > 0;
const offset = parseInt(req.query['offset'] as string, 10) || 0;
// Get all items pending deletion
const pendingItems = mediaItemsRepo.getPendingDeletion();
const now = new Date();
const ruleName = createRuleNameResolver();
const allQueueItems: QueueItemResponse[] = pendingItems
.filter((item) => item.delete_after && item.marked_at)
.map<QueueItemResponse>((item) => mediaRowToQueueItem(item, now, ruleName))
.concat(pendingEpisodeQueueItems(now))
.sort((a, b) => a.daysRemaining - b.daysRemaining);
const paginatedItems = hasLimit
? allQueueItems.slice(offset, offset + limitParam)
: allQueueItems.slice(offset);
// Calculate totals
const totalSize = allQueueItems.reduce((sum, item) => sum + item.size, 0);
const readyForDeletion = allQueueItems.filter((item) => item.daysRemaining === 0).length;
const willResetOverseerr = allQueueItems.filter((item) => item.resetOverseerr).length;
res.json({
success: true,
data: paginatedItems,
total: allQueueItems.length,
limit: hasLimit ? limitParam : allQueueItems.length,
offset,
summary: {
totalItems: allQueueItems.length,
totalSize,
readyForDeletion,
willResetOverseerr,
},
});
} catch (error) {
logger.error('Failed to get queue:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve queue',
});
}
});
// DELETE /api/queue/:id - Remove an item from the deletion queue (cancel deletion)
router.delete('/:id', (req: Request, res: Response) => {
try {
const parsedId = parseQueueId(req.params['id'] as string);
if (!parsedId) {
res.status(400).json({
success: false,
error: 'Invalid queue item ID',
});
return;
}
if (parsedId.kind === 'episode') {
const row = episodeDeletionsRepo.getById(parsedId.id);
if (!row || row.status !== 'pending') {
res.status(404).json({ success: false, error: 'Episode is not in the deletion queue' });
return;
}
const title = episodeLabel(row.series_title, row.season_number, row.episode_number, row.episode_title);
episodeDeletionsRepo.cancelByIds([parsedId.id]);
logActivity({
eventType: 'manual_action',
action: 'episodes_unqueued',
actorType: 'user',
targetType: 'media_item',
targetId: row.media_item_id,
targetTitle: title,
metadata: JSON.stringify({ episodes: 1 }),
});
res.json({ success: true, data: { id: `ep-${parsedId.id}` }, message: `"${title}" removed from deletion queue` });
return;
}
const id = parsedId.id;
// Verify the item exists and is in pending_deletion status
const item = mediaItemsRepo.getById(id);
if (!item) {
res.status(404).json({
success: false,
error: `Item not found: ${id}`,
});
return;
}
if (item.status !== 'pending_deletion') {
res.status(400).json({
success: false,
error: 'Item is not in the deletion queue',
});
return;
}
// Remove from deletion queue by resetting status and clearing deletion fields
const updatedItem = mediaItemsRepo.update(id, {
status: 'monitored',
marked_at: undefined,
delete_after: undefined,
});
if (!updatedItem) {
res.status(500).json({
success: false,
error: 'Failed to update item',
});
return;
}
logger.info(`Removed item "${item.title}" from deletion queue`);
logActivity({
eventType: 'manual_action',
action: 'queue_removed',
actorType: 'user',
targetType: 'media_item',
targetId: id,
targetTitle: item.title,
});
res.json({
success: true,
data: updatedItem,
message: `"${item.title}" removed from deletion queue`,
});
} catch (error) {
logger.error('Failed to remove item from queue:', error);
res.status(500).json({
success: false,
error: 'Failed to remove item from queue',
});
}
});
// POST /api/queue/process - Process the deletion queue (delete items whose grace period has expired)
router.post('/process', async (req: Request, res: Response) => {
try {
const dryRun = req.query['dryRun'] === 'true';
const force = req.query['force'] === 'true';
// Get the deletion service
const deletionService = getDeletionService();
// Get items ready for deletion
const pendingItems = mediaItemsRepo.getPendingDeletion();
const now = new Date();
// force=true processes ALL pending items (manual "Process Queue" button)
// force=false only processes items whose grace period has expired (auto-processing)
const itemsReadyForDeletion = force
? pendingItems.filter((item) => item.delete_after && item.marked_at)
: pendingItems.filter((item) => {
if (!item.delete_after) return false;
const deleteAfter = new Date(item.delete_after);
const daysRemaining = Math.max(
0,
Math.ceil((deleteAfter.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
);
return daysRemaining === 0;
});
// Queued episodes ride the same button: whatever is due (or everything,
// when forced) gets processed alongside the whole-item rows.
const episodeRowsReady = force
? episodeDeletionsRepo.getAllPending()
: episodeDeletionsRepo.getDue(now);
if (itemsReadyForDeletion.length === 0 && episodeRowsReady.length === 0) {
res.json({
success: true,
data: {
processed: 0,
deleted: 0,
failed: 0,
freedSpace: 0,
overseerrResets: 0,
dryRun,
},
message: 'No items ready for deletion',
});
return;
}
interface DeletionResultItem {
id: number;
title: string;
fileSize: number | null;
deletionAction: DeletionAction;
deletionActionLabel: string;
overseerrReset?: boolean;
overseerrError?: string;
}
const results: {
deleted: DeletionResultItem[];
failed: Array<{ id: number; title: string; error: string }>;
} = {
deleted: [],
failed: [],
};
// Collected for the batched DELETION_COMPLETE Discord notification
const notifyItems: Array<{ title: string; type: string; ruleId?: number | null }> = [];
let freedSpace = 0;
let overseerrResets = 0;
// Process each item
for (const item of itemsReadyForDeletion) {
try {
const itemAny = item as any;
const deletionAction = normalizeDeletionAction(itemAny.deletion_action);
const resetOverseerr = Boolean(itemAny.reset_overseerr);
const matchedRuleId = itemAny.matched_rule_id as number | undefined;
if (dryRun) {
// Dry run - just record what would be deleted
const actionDeletesFiles = deletionAction !== DeletionAction.UNMONITOR_ONLY;
logger.info(`[DRY RUN] Would process: "${item.title}" (action: ${deletionAction}, reset overseerr: ${resetOverseerr})`);
results.deleted.push({
id: item.id,
title: item.title,
fileSize: actionDeletesFiles ? item.file_size : 0,
deletionAction,
deletionActionLabel: DELETION_ACTION_LABELS[deletionAction] || deletionAction,
overseerrReset: resetOverseerr,
});
if (actionDeletesFiles) {
freedSpace += item.file_size || 0;
}
if (resetOverseerr) {
overseerrResets++;
}
} else {
// Actual deletion using DeletionService
const result = await deletionService.executeDelete(item as any, deletionAction, {
resetOverseerr,
ruleId: matchedRuleId,
});
if (result.success) {
results.deleted.push({
id: item.id,
title: item.title,
fileSize: result.fileSizeFreed || null,
deletionAction,
deletionActionLabel: DELETION_ACTION_LABELS[deletionAction] || deletionAction,
overseerrReset: result.overseerrReset,
overseerrError: result.overseerrError,
});
notifyItems.push({
title: item.title,
type: item.type,
ruleId: matchedRuleId ?? null,
});
freedSpace += result.fileSizeFreed || 0;
if (result.overseerrReset) {
overseerrResets++;
}
logger.info(`Processed item: "${item.title}" (action: ${deletionAction}, overseerr reset: ${result.overseerrReset})`);
} else {
results.failed.push({
id: item.id,
title: item.title,
error: result.error || 'Unknown error',
});
}
}
} catch (itemError) {
const errorMessage = itemError instanceof Error ? itemError.message : String(itemError);
logger.error(`Failed to process deletion for "${item.title}":`, itemError);
results.failed.push({
id: item.id,
title: item.title,
error: errorMessage,
});
}
}
// Episodes: dry runs only tally what would go, real runs execute them.
let episodesDeleted = 0;
let episodesFailed = 0;
if (episodeRowsReady.length > 0) {
if (dryRun) {
episodesDeleted = episodeRowsReady.length;
freedSpace += episodeRowsReady.reduce((sum, row) => sum + (row.file_size || 0), 0);
logger.info(`[DRY RUN] Would process ${episodeRowsReady.length} queued episode(s)`);
} else {
const episodeResult = await executeQueuedDeletions(episodeRowsReady);
episodesDeleted = episodeResult.deleted;
episodesFailed = episodeResult.failed;
freedSpace += episodeResult.freedBytes;
for (const outcome of episodeResult.outcomes.filter((o) => o.success)) {
notifyItems.push({ title: outcome.label, type: 'episode', ruleId: null });
}
}
}
const freedSpaceGB = (freedSpace / (1024 * 1024 * 1024)).toFixed(2);
logger.info(
`Queue processing complete: ${results.deleted.length + episodesDeleted} processed, ${results.failed.length + episodesFailed} failed, ${freedSpaceGB}GB freed, ${overseerrResets} Overseerr resets${dryRun ? ' (dry run)' : ''}`
);
if (!dryRun) {
await sendDeletionCompleteNotification(notifyItems, freedSpace, results.failed.length);
}
const totalDeleted = results.deleted.length + episodesDeleted;
const totalFailed = results.failed.length + episodesFailed;
res.json({
success: true,
data: {
processed: itemsReadyForDeletion.length + episodeRowsReady.length,
deleted: totalDeleted,
failed: totalFailed,
freedSpace,
freedSpaceFormatted: `${freedSpaceGB} GB`,
overseerrResets,
episodes: {
processed: episodeRowsReady.length,
deleted: episodesDeleted,
failed: episodesFailed,
},
dryRun,
results,
},
message: dryRun
? `Dry run complete: ${totalDeleted} item(s) would be processed`
: `Processed ${totalDeleted} item(s), ${totalFailed} failed, ${overseerrResets} Overseerr resets`,
});
} catch (error) {
logger.error('Failed to process deletion queue:', error);
res.status(500).json({
success: false,
error: 'Failed to process deletion queue',
});
}
});
// POST /api/queue/:id/delete-now - Immediately delete a single item (bypass grace period)
router.post('/:id/delete-now', async (req: Request, res: Response) => {
try {
const parsedId = parseQueueId(req.params['id'] as string);
if (!parsedId) {
res.status(400).json({
success: false,
error: 'Invalid queue item ID',
});
return;
}
if (parsedId.kind === 'episode') {
const row = episodeDeletionsRepo.getById(parsedId.id);
if (!row || row.status !== 'pending') {
res.status(404).json({ success: false, error: 'Episode is not in the deletion queue' });
return;
}
const result = await executeQueuedDeletions([row]);
const outcome = result.outcomes[0];
if (!outcome?.success) {
res.status(500).json({ success: false, error: outcome?.error || 'Failed to delete episode' });
return;
}
await sendDeletionCompleteNotification(
[{ title: outcome.label, type: 'episode' }],
result.freedBytes,
0
);
res.json({
success: true,
data: {
id: `ep-${parsedId.id}`,
title: outcome.label,
deletionAction: row.deletion_action,
fileSizeFreed: result.freedBytes,
},
message: `"${outcome.label}" deleted successfully`,
});
return;
}
const id = parsedId.id;
// Verify the item exists and is in pending_deletion status
const item = mediaItemsRepo.getById(id);
if (!item) {
res.status(404).json({
success: false,
error: `Item not found: ${id}`,
});
return;
}
if (item.status !== 'pending_deletion') {
res.status(400).json({
success: false,
error: 'Item is not in the deletion queue',
});
return;
}
// Get the deletion service
const deletionService = getDeletionService();
// Extract deletion options from the item
const itemAny = item as any;
const deletionAction = normalizeDeletionAction(itemAny.deletion_action);
const resetOverseerr = Boolean(itemAny.reset_overseerr);
const matchedRuleId = itemAny.matched_rule_id as number | undefined;
// Execute the deletion immediately
const result = await deletionService.executeDelete(item as any, deletionAction, {
resetOverseerr,
ruleId: matchedRuleId,
});
if (result.success) {
const freedSpaceGB = ((result.fileSizeFreed || 0) / (1024 * 1024 * 1024)).toFixed(2);
logger.info(`Immediately deleted: "${item.title}" (action: ${deletionAction}, freed: ${freedSpaceGB}GB, overseerr reset: ${result.overseerrReset})`);
await sendDeletionCompleteNotification(
[{ title: item.title, type: item.type, ruleId: matchedRuleId ?? null }],
result.fileSizeFreed || 0,
0
);
res.json({
success: true,
data: {
id: item.id,
title: item.title,
deletionAction,
deletionActionLabel: DELETION_ACTION_LABELS[deletionAction] || deletionAction,
fileSizeFreed: result.fileSizeFreed || 0,
fileSizeFreedFormatted: `${freedSpaceGB} GB`,
overseerrReset: result.overseerrReset,
overseerrError: result.overseerrError,
},
message: `"${item.title}" deleted successfully`,
});
} else {
res.status(500).json({
success: false,
error: result.error || 'Failed to delete item',
data: {
overseerrError: result.overseerrError,
},
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to immediately delete item: ${errorMessage}`);
res.status(500).json({
success: false,
error: errorMessage || 'Failed to delete item',
});
}
});
// POST /api/queue/:id/delete-now/stream - Delete with SSE progress streaming
router.post('/:id/delete-now/stream', async (req: Request, res: Response) => {
const parsedId = parseQueueId(req.params['id'] as string);
if (!parsedId) {
res.status(400).json({ success: false, error: 'Invalid queue item ID' });
return;
}
if (parsedId.kind === 'episode') {
await streamEpisodeDeletion(parsedId.id, res);
return;
}
const id = parsedId.id;
// Verify the item exists and is in pending_deletion status
const item = mediaItemsRepo.getById(id);
if (!item) {
res.status(404).json({ success: false, error: `Item not found: ${id}` });
return;
}
if (item.status !== 'pending_deletion') {
res.status(400).json({ success: false, error: 'Item is not in the deletion queue' });
return;
}
// The deletion runs to the end even if the client goes away.
const stream = openSseStream(res);
const sendProgress = (progress: DeletionProgress) => stream.send(progress);
try {
const itemAny = item as any;
const deletionAction = normalizeDeletionAction(itemAny.deletion_action);
const resetOverseerr = Boolean(itemAny.reset_overseerr);
const matchedRuleId = itemAny.matched_rule_id as number | undefined;
sendProgress({ stage: 'starting', message: `Starting deletion of "${item.title}"...` });
const sonarr = getSonarrService();
const radarr = getRadarrService();
const overseerr = getOverseerrService();
const unactionable = getUnactionableReason(item, { sonarr, radarr });
if (unactionable) {
throw new Error(unactionable);
}
let fileSizeFreed = 0;
let overseerrResetSuccess = false;
const deletesFiles = deletionAction !== DeletionAction.UNMONITOR_ONLY;
// Track file deletion progress
const onFileProgress = (progress: { current: number; total: number; fileName: string; status: 'deleting' | 'deleted' | 'failed' }) => {
sendProgress({
stage: 'deleting_files',
message: `Deleting file ${progress.current}/${progress.total}`,
fileProgress: progress,
});
};
// Unmonitor if needed
if (deletionAction === DeletionAction.UNMONITOR_ONLY ||
deletionAction === DeletionAction.UNMONITOR_AND_DELETE) {
sendProgress({ stage: 'unmonitoring', message: 'Unmonitoring in Sonarr/Radarr...' });
if (item.sonarr_id && sonarr) {
await sonarr.unmonitorSeries(item.sonarr_id);
}
if (item.radarr_id && radarr) {
await radarr.unmonitorMovie(item.radarr_id);
}
}
// Delete files if needed
if (deletionAction === DeletionAction.DELETE_FILES_ONLY ||
deletionAction === DeletionAction.UNMONITOR_AND_DELETE) {
sendProgress({ stage: 'deleting_files', message: 'Deleting media files...' });
if (item.sonarr_id && sonarr) {
await sonarr.deleteAllEpisodeFiles(item.sonarr_id, onFileProgress);
}
if (item.radarr_id && radarr) {
await radarr.deleteMovieFilesByMovieId(item.radarr_id, onFileProgress);
}
if (deletesFiles) {
fileSizeFreed = item.file_size || 0;
}
}
// Full removal - delete everything at once via Sonarr/Radarr
if (deletionAction === DeletionAction.FULL_REMOVAL) {
sendProgress({ stage: 'deleting_files', message: 'Removing from Sonarr/Radarr completely...' });
if (item.sonarr_id && sonarr) {
await sonarr.removeSeries(item.sonarr_id, true);
}
if (item.radarr_id && radarr) {
await radarr.removeMovie(item.radarr_id, true);
}
fileSizeFreed = item.file_size || 0;
}
// Reset Overseerr if requested
if (resetOverseerr && itemAny.tmdb_id && overseerr) {
sendProgress({ stage: 'resetting_overseerr', message: 'Resetting in Overseerr...' });
try {
const mediaType = item.type === 'movie' ? 'movie' : 'tv';
overseerrResetSuccess = await overseerr.resetMediaByTmdbId(itemAny.tmdb_id, mediaType);
if (overseerrResetSuccess) {
mediaItemsRepo.update(item.id, { overseerr_reset_at: new Date().toISOString() } as any);
}
} catch (overseerrErr) {
logger.warn(`Failed to reset in Overseerr: ${overseerrErr}`);
}
}
// Record in deletion history
historyRepo.create({
media_item_id: item.id,
title: item.title,
type: item.type,
file_size: deletesFiles ? item.file_size : null,
deletion_type: 'manual',
deleted_by_rule_id: matchedRuleId || null,
});
// Update item status
if (deletionAction !== DeletionAction.FULL_REMOVAL) {
mediaItemsRepo.update(item.id, { status: 'deleted' });
} else {
mediaItemsRepo.delete(item.id);
}
// Log activity for the timeline. Respect rule attribution if this item was
// queued by a rule — matches the behaviour of DeletionService.executeDelete
// so the Activity Log "Rule" filter catches it.
let ruleName: string | undefined;
if (matchedRuleId !== undefined) {
const rule = rulesRepo.rules.getById(matchedRuleId);
ruleName = rule?.name;
}
const activityActorName =
matchedRuleId !== undefined
? (ruleName ?? `Rule #${matchedRuleId}`)
: 'Manual deletion';
logActivity({
eventType: 'deletion',
action: 'deleted',
actorType: matchedRuleId !== undefined ? 'rule' : 'user',
actorId: matchedRuleId !== undefined ? String(matchedRuleId) : null,
actorName: activityActorName,
targetType: 'media_item',
targetId: item.id,
targetTitle: item.title,
metadata: JSON.stringify({ fileSize: fileSizeFreed, deletionAction, overseerrReset: overseerrResetSuccess }),
});
const freedSpaceGB = (fileSizeFreed / (1024 * 1024 * 1024)).toFixed(2);
logger.info(`Deleted "${item.title}" via stream (action: ${deletionAction}, freed: ${freedSpaceGB}GB, overseerr: ${overseerrResetSuccess})`);
// Fire DELETION_COMPLETE Discord notification (non-blocking, errors already logged)
await sendDeletionCompleteNotification(
[{ title: item.title, type: item.type, ruleId: matchedRuleId ?? null }],
fileSizeFreed,
0
);
// Send completion
sendProgress({
stage: 'complete',
message: `"${item.title}" deleted successfully`,
result: {
success: true,
fileSizeFreed,
overseerrReset: overseerrResetSuccess,
},
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to delete item via stream: ${errorMessage}`);
sendProgress({
stage: 'error',
message: `Failed to delete: ${errorMessage}`,
result: {
success: false,
error: errorMessage,
},
});
} finally {
stream.close();
}
});
export default router;