Ctrl K
ComunidadGuías…

server/src/routes/queue.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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;