server/src/routes/library.ts
Rama main · réplica de lectura del repositorio original.
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import mediaItemsRepo from '../db/repositories/mediaItems';
import collectionsRepo from '../db/repositories/collections';
import settingsRepo from '../db/repositories/settings';
import { logActivity } from '../db/repositories/activity';
import { getPlexService, getSonarrService } from '../services/init';
import {
buildSonarrHistoryEvents,
buildSonarrSeriesDetail,
type SonarrEpisodeQueued,
} from '../services/sonarrSeriesDetail';
import episodeDeletionsRepo from '../db/repositories/episodeDeletions';
import {
EPISODE_DELETION_ACTIONS,
executeQueuedDeletions,
queueEpisodeDeletions,
resolveTargets,
type EpisodeDeletionAction,
} from '../services/episodeDeletions';
import {
isSyncInProgress,
runLibrarySync,
subscribeToSync,
getSyncProgressLog,
} from '../services/syncCoordinator';
import logger from '../utils/logger';
import { openSseStream } from '../utils/sse';
import { formatBytes } from '../utils/format';
import { toThumbnailUrl } from '../utils/posterUrl';
import { loadInProgressConfig, isProtectedInProgress, watchProgress } from '../rules/inProgress';
import { viewersFor } from '../services/viewers';
const router = Router();
// Query parameter schema for library filtering
const LibraryFiltersSchema = z.object({
type: z.enum(['movie', 'show', 'episode']).optional(),
status: z.enum(['monitored', 'flagged', 'pending_deletion', 'protected', 'deleted', 'watched', 'unwatched', 'queued']).optional(),
search: z.string().optional(),
page: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : 1)),
limit: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : 50)),
offset: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined)),
sortBy: z.string().optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
minSize: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined)),
maxSize: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined)),
unwatchedDays: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined)),
protected: z
.string()
.optional()
.transform((val) => (val === 'true' ? true : val === 'false' ? false : undefined)),
});
// GET /api/library - Get library items with filters
router.get('/', (req: Request, res: Response) => {
try {
const parseResult = LibraryFiltersSchema.safeParse(req.query);
if (!parseResult.success) {
res.status(400).json({
success: false,
error: 'Invalid query parameters',
details: parseResult.error.issues,
});
return;
}
const filters = parseResult.data;
// Calculate offset from page if not provided directly
const offset = filters.offset ?? (filters.page - 1) * filters.limit;
// Map client status values to server status values
let serverStatus: 'monitored' | 'flagged' | 'pending_deletion' | 'protected' | 'deleted' | undefined;
let watchedFilter: boolean | undefined;
if (filters.status === 'queued') {
serverStatus = 'pending_deletion';
} else if (filters.status === 'watched') {
watchedFilter = true;
} else if (filters.status === 'unwatched') {
watchedFilter = false;
} else if (filters.status && ['monitored', 'flagged', 'pending_deletion', 'protected', 'deleted'].includes(filters.status)) {
serverStatus = filters.status as 'monitored' | 'flagged' | 'pending_deletion' | 'protected' | 'deleted';
}
// Build filters for mediaItemsRepo - ALL filtering happens at DB level now
const repoFilters = {
type: filters.type as 'movie' | 'show' | 'episode' | undefined,
status: serverStatus,
search: filters.search,
limit: filters.limit,
offset: offset,
minSize: filters.minSize,
maxSize: filters.maxSize,
watched: watchedFilter,
unwatchedDays: filters.unwatchedDays,
isProtected: filters.protected,
// Deleted items are tombstones; hide them from every view except the
// explicit "Deleted" filter (which sets serverStatus === 'deleted').
excludeDeleted: serverStatus !== 'deleted',
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
};
// Get items from repository - filtering, sorting, and pagination all happen at DB level
const result = mediaItemsRepo.getAll(repoFilters);
const items = result.data;
// Calculate summary stats
const totalSize = items.reduce((sum, item) => sum + (item.file_size || 0), 0);
const movieCount = items.filter((i) => i.type === 'movie').length;
const showCount = items.filter((i) => i.type === 'show').length;
const episodeCount = items.filter((i) => i.type === 'episode').length;
// Calculate pagination
const totalPages = Math.ceil(result.total / filters.limit);
// Batch-fetch protected collections for all items on this page (single query).
const itemIds = items.map((item) => item.id);
const protectedCollectionsMap = collectionsRepo.findProtectedForItems(itemIds);
// Transform items to match client expected format.
// Derive protection status from both the item flag and collection membership.
const transformedItems = items.map((item) => {
const protectedCollections = protectedCollectionsMap.get(item.id) ?? [];
const collectionProtected = protectedCollections.length > 0;
return {
id: String(item.id),
title: item.title,
type: item.type === 'show' ? 'tv' : item.type, // Map 'show' to 'tv' for client
year: item.year,
size: item.file_size || 0,
posterUrl: toThumbnailUrl(item.poster_url),
watched: (item.play_count || 0) > 0,
lastWatched: item.last_watched_at,
addedAt: item.added_at || item.created_at,
status: item.status === 'pending_deletion' ? 'queued' : item.status === 'monitored' ? 'active' : item.status,
isProtected: item.is_protected || collectionProtected,
protectedByCollection: collectionProtected
? { id: protectedCollections[0]!.id, title: protectedCollections[0]!.title }
: null,
plexId: item.plex_id,
sonarrId: item.sonarr_id,
radarrId: item.radarr_id,
tvdbId: item.tvdb_id,
tmdbId: item.tmdb_id,
imdbId: item.imdb_id,
playCount: item.play_count,
watchedBy: item.watched_by,
resolution: item.resolution,
codec: item.codec,
};
});
res.json({
success: true,
data: {
items: transformedItems,
total: result.total,
page: filters.page,
totalPages,
summary: {
totalSize,
movieCount,
showCount,
episodeCount,
},
},
});
} catch (error) {
logger.error('Failed to get library items:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve library items',
});
}
});
// GET /api/library/summary - Get library summary statistics
router.get('/summary', (_req: Request, res: Response) => {
try {
const stats = mediaItemsRepo.getStats();
res.json({
success: true,
data: {
total: stats.total,
byType: stats.byType,
byStatus: stats.byStatus,
totalSize: stats.totalSize,
totalSizeFormatted: formatBytes(stats.totalSize),
},
});
} catch (error) {
logger.error('Failed to get library summary:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve library summary',
});
}
});
// Schema for bulk mark-deletion request body
const BulkMarkDeletionSchema = z.object({
ids: z.array(z.number()).min(1, 'At least one ID is required'),
gracePeriodDays: z.number().optional(),
deletionAction: z.enum(['unmonitor_only', 'delete_files_only', 'unmonitor_and_delete', 'full_removal']).optional(),
resetOverseerr: z.boolean().optional(),
});
// POST /api/library/bulk/mark-deletion - Mark multiple items for deletion
// NOTE: This route MUST be defined before /:id routes to avoid matching 'bulk' as an ID
router.post('/bulk/mark-deletion', (req: Request, res: Response) => {
try {
const parseResult = BulkMarkDeletionSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
error: 'Invalid request body',
details: parseResult.error.issues,
});
return;
}
const { ids, gracePeriodDays, deletionAction: requestedDeletionAction, resetOverseerr: requestedResetOverseerr } = parseResult.data;
// Get default grace period from settings if not provided
const defaultGracePeriod = settingsRepo.getNumber('default_grace_period_days', 7);
const actualGracePeriod = gracePeriodDays ?? defaultGracePeriod;
// Get deletion action (from request or settings)
const deletionAction = requestedDeletionAction || settingsRepo.getValue('default_deletion_action') || 'unmonitor_and_delete';
// Get reset overseerr flag
const resetOverseerr = requestedResetOverseerr === true ? 1 : 0;
const results = {
success: [] as { id: number; title: string }[],
failed: [] as { id: number; error: string }[],
skipped: [] as { id: number; title: string; reason: string }[],
};
const now = new Date();
const deleteAfter = new Date(now);
deleteAfter.setDate(deleteAfter.getDate() + actualGracePeriod);
// Batch-fetch collection protection for all IDs (single query)
const protectedMap = collectionsRepo.findProtectedForItems(ids);
for (const id of ids) {
const item = mediaItemsRepo.getById(id);
if (!item) {
results.failed.push({ id, error: 'Item not found' });
continue;
}
// Skip already-deleted tombstones — there's nothing left to queue.
if (item.status === 'deleted') {
results.skipped.push({ id, title: item.title, reason: 'Item is already deleted' });
continue;
}
// Skip protected items (item-level or collection-level)
const protectedColls = protectedMap.get(id) ?? [];
if (item.is_protected || protectedColls.length > 0) {
const reason = item.is_protected
? 'Item is protected'
: `Protected via collection "${protectedColls[0]!.title}"`;
results.skipped.push({ id, title: item.title, reason });
continue;
}
// For already queued items, update with new options (don't reset marked_at)
const isAlreadyQueued = item.status === 'pending_deletion';
// Mark for deletion with all options (or update existing)
const updatedItem = mediaItemsRepo.update(id, {
status: 'pending_deletion',
marked_at: isAlreadyQueued ? (item.marked_at ?? undefined) : now.toISOString(),
delete_after: deleteAfter.toISOString(),
deletion_action: deletionAction,
reset_overseerr: resetOverseerr,
});
if (updatedItem) {
results.success.push({ id, title: item.title });
} else {
results.failed.push({ id, error: 'Failed to update' });
}
}
logger.info(`Bulk mark for deletion: ${results.success.length} marked, ${results.skipped.length} skipped, ${results.failed.length} failed`);
for (const item of results.success) {
logActivity({
eventType: 'manual_action',
action: 'item_queued',
actorType: 'user',
targetType: 'media_item',
targetId: item.id,
targetTitle: item.title,
});
}
res.json({
success: true,
data: results,
message: `${results.success.length} item(s) marked for deletion`,
});
} catch (error) {
logger.error('Failed to bulk mark items for deletion:', error);
res.status(500).json({
success: false,
error: 'Failed to bulk mark items for deletion',
});
}
});
// Schema for bulk protect request body
const BulkProtectSchema = z.object({
ids: z.array(z.number()).min(1, 'At least one ID is required'),
reason: z.string().optional(),
});
// POST /api/library/bulk/protect - Protect multiple items from deletion
// NOTE: This route MUST be defined before /:id routes to avoid matching 'bulk' as an ID
router.post('/bulk/protect', (req: Request, res: Response) => {
try {
const parseResult = BulkProtectSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
error: 'Invalid request body',
details: parseResult.error.issues,
});
return;
}
const { ids, reason } = parseResult.data;
const protectReason = reason || 'Bulk protected';
const results = {
success: [] as { id: number; title: string }[],
failed: [] as { id: number; error: string }[],
skipped: [] as { id: number; title: string; reason: string }[],
};
for (const id of ids) {
const item = mediaItemsRepo.getById(id);
if (!item) {
results.failed.push({ id, error: 'Item not found' });
continue;
}
// Skip already protected items
if (item.is_protected) {
results.skipped.push({ id, title: item.title, reason: 'Already protected' });
continue;
}
// Protect the item
const updatedItem = mediaItemsRepo.protect(id, protectReason);
if (updatedItem) {
results.success.push({ id, title: item.title });
} else {
results.failed.push({ id, error: 'Failed to protect' });
}
}
logger.info(`Bulk protect: ${results.success.length} protected, ${results.skipped.length} skipped, ${results.failed.length} failed`);
for (const item of results.success) {
logActivity({
eventType: 'protection',
action: 'protected',
actorType: 'user',
targetType: 'media_item',
targetId: item.id,
targetTitle: item.title,
});
}
res.json({
success: true,
data: results,
message: `${results.success.length} item(s) protected`,
});
} catch (error) {
logger.error('Failed to bulk protect items:', error);
res.status(500).json({
success: false,
error: 'Failed to bulk protect items',
});
}
});
// GET /api/library/plex-libraries - Get available Plex libraries for exclusion config
router.get('/plex-libraries', async (_req: Request, res: Response) => {
try {
const plex = getPlexService();
if (!plex) {
res.status(400).json({
success: false,
error: 'Plex is not configured',
});
return;
}
const libraries = await plex.getLibraries();
const excludedKeysRaw = settingsRepo.getValue('excluded_library_keys');
let excludedKeys: string[] = [];
try {
excludedKeys = excludedKeysRaw ? JSON.parse(excludedKeysRaw) : [];
} catch {
logger.warn('Failed to parse excluded_library_keys setting, ignoring');
}
res.json({
success: true,
data: libraries.map((lib) => ({
key: lib.key,
title: lib.title,
type: lib.type,
excluded: excludedKeys.includes(lib.key),
})),
});
} catch (error) {
logger.error('Failed to get Plex libraries:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve Plex libraries',
});
}
});
// PUT /api/library/plex-libraries/exclusions - Save library exclusions and purge items from newly-excluded libraries
router.put('/plex-libraries/exclusions', async (req: Request, res: Response) => {
try {
const { excludedKeys } = req.body as { excludedKeys: string[] };
if (!Array.isArray(excludedKeys) || excludedKeys.some((k) => typeof k !== 'string' || k.trim() === '')) {
res.status(400).json({ success: false, error: 'excludedKeys must be an array of non-empty strings' });
return;
}
// Save the new exclusion list
settingsRepo.set({ key: 'excluded_library_keys', value: JSON.stringify(excludedKeys) });
// Purge items from ALL excluded libraries (catches stragglers from previous exclusions too)
let totalRemoved = 0;
const plex = getPlexService();
for (const key of excludedKeys) {
// Try by library_key column (items scanned after migration 9)
const removed = mediaItemsRepo.deleteByLibraryKey(key);
totalRemoved += removed;
// Fetch Plex library items and delete by plex_id, then by title as fallback
if (plex) {
try {
const items = await plex.getLibraryItems(key);
// Delete by plex_id (legacy items without library_key)
const plexIds = items.map((item) => item.ratingKey).filter(Boolean);
if (plexIds.length > 0) {
const removedByPlex = mediaItemsRepo.deleteByPlexIds(plexIds);
totalRemoved += removedByPlex;
if (removedByPlex > 0) {
logger.info(`Removed ${removedByPlex} items from library ${key} by plex_id`);
}
}
// Final fallback: delete by title match for any remaining stragglers
const titles = items.map((item) => item.title).filter(Boolean);
if (titles.length > 0) {
const removedByTitle = mediaItemsRepo.deleteByTitles(titles);
totalRemoved += removedByTitle;
if (removedByTitle > 0) {
logger.info(`Removed ${removedByTitle} stragglers from library ${key} by title match`);
}
}
} catch (plexError) {
logger.warn(`Failed to fetch Plex library ${key} for purge:`, plexError);
}
}
}
// Clean up orphaned items not found in any included library
if (plex) {
try {
const allLibraries = await plex.getLibraries();
const includedKeys = allLibraries
.filter((lib) => !excludedKeys.includes(lib.key))
.filter((lib) => lib.type === 'movie' || lib.type === 'show');
if (includedKeys.length === 0) {
// All libraries excluded — delete everything remaining
const { getDatabase } = await import('../db');
const db = getDatabase();
const result = db.prepare('DELETE FROM media_items').run();
totalRemoved += result.changes;
if (result.changes > 0) {
logger.info(`Removed ${result.changes} orphaned items (all libraries excluded)`);
}
} else {
// Collect all valid plex_ids from included libraries
const includedPlexIds = new Set<string>();
for (const lib of includedKeys) {
try {
const items = await plex.getLibraryItems(lib.key);
for (const item of items) {
if (item.ratingKey) includedPlexIds.add(item.ratingKey);
}
} catch { /* skip failed library fetches */ }
}
// Delete any items whose plex_id is not in any included library
if (includedPlexIds.size > 0) {
const { getDatabase } = await import('../db');
const db = getDatabase();
const allItems = db.prepare<[], { id: number; plex_id: string | null }>('SELECT id, plex_id FROM media_items').all();
const orphanIds = allItems
.filter((item) => !item.plex_id || !includedPlexIds.has(item.plex_id))
.map((item) => item.id);
if (orphanIds.length > 0) {
const CHUNK = 500;
for (let i = 0; i < orphanIds.length; i += CHUNK) {
const chunk = orphanIds.slice(i, i + CHUNK);
const placeholders = chunk.map(() => '?').join(',');
db.prepare(`DELETE FROM media_items WHERE id IN (${placeholders})`).run(...chunk);
}
totalRemoved += orphanIds.length;
logger.info(`Removed ${orphanIds.length} orphaned items not in any included library`);
}
}
}
} catch (orphanError) {
logger.warn('Failed to clean up orphaned items:', orphanError);
}
}
if (totalRemoved > 0) {
logger.info(`Removed ${totalRemoved} items total from excluded/orphaned libraries`);
}
res.json({
success: true,
data: { removedItems: totalRemoved, excludedKeys },
message: totalRemoved > 0
? `Saved. Removed ${totalRemoved} items from excluded libraries.`
: 'Library exclusions saved.',
});
} catch (error) {
logger.error('Failed to save library exclusions:', error);
res.status(500).json({ success: false, error: 'Failed to save library exclusions' });
}
});
// GET /api/library/sync/status - Check if sync is in progress
router.get('/sync/status', (_req: Request, res: Response) => {
res.json({
success: true,
data: {
inProgress: isSyncInProgress(),
},
});
});
// GET /api/library/:id - Get a single media item by ID
router.get('/:id', (req: Request, res: Response) => {
try {
const id = parseInt(req.params['id'] as string, 10);
if (isNaN(id)) {
res.status(400).json({
success: false,
error: 'Invalid ID parameter',
});
return;
}
const item = mediaItemsRepo.getById(id);
if (!item) {
res.status(404).json({
success: false,
error: 'Media item not found',
});
return;
}
// Enrich with collection-derived protection
const protectedCollections = collectionsRepo.findProtectedContainingItem(id);
const inProgress = loadInProgressConfig();
const enriched = {
...item,
is_protected: item.is_protected || protectedCollections.length > 0,
protected_by_collection: protectedCollections.length > 0
? { id: protectedCollections[0]!.id, title: protectedCollections[0]!.title }
: null,
// Shows: how far through anyone is, and whether that keeps rules off it.
watch_progress: watchProgress(item, inProgress.recentDays),
in_progress_protected: isProtectedInProgress(item, inProgress),
// Who watched it and how far each got (shows and movies).
viewers: viewersFor(item, inProgress.recentDays),
};
res.json({
success: true,
data: enriched,
});
} catch (error) {
logger.error('Failed to get media item:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve media item',
});
}
});
/**
* Resolve the Sonarr series for a library item.
*
* Prefers the stored Sonarr ID and falls back to a TVDB lookup for rows synced
* before the match ran (or where Sonarr re-added the series).
*/
async function resolveSonarrSeriesId(item: {
type: string;
sonarr_id: number | null;
tvdb_id: number | null;
}): Promise<number | null> {
if (item.type !== 'show' && item.type !== 'episode') return null;
const sonarr = getSonarrService();
if (!sonarr) return null;
if (item.sonarr_id) return item.sonarr_id;
if (!item.tvdb_id) return null;
const matched = await sonarr.getSeriesByTvdbId(item.tvdb_id);
return matched?.id ?? null;
}
const EpisodeDeletionSchema = z.object({
episodeIds: z.array(z.number().int().positive()).optional(),
seasonNumbers: z.array(z.number().int().min(0)).optional(),
deletionAction: z.enum(EPISODE_DELETION_ACTIONS).optional(),
gracePeriodDays: z.number().int().min(0).max(365).optional(),
// 'now' bypasses the grace period and runs the deletion immediately.
mode: z.enum(['queue', 'now']).optional(),
});
// GET /api/library/:id/sonarr - Sonarr season/episode breakdown for a show
//
// Live passthrough (no caching): the panel is only rendered on the detail view
// of a show, and stale file/queue state would be worse than a slower load.
router.get('/:id/sonarr', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params['id'] as string, 10);
if (isNaN(id)) {
res.status(400).json({ success: false, error: 'Invalid ID parameter' });
return;
}
const item = mediaItemsRepo.getById(id);
if (!item) {
res.status(404).json({ success: false, error: 'Media item not found' });
return;
}
const sonarr = getSonarrService();
if (!sonarr) {
res.json({ success: true, data: { configured: false, linked: false } });
return;
}
if (item.type !== 'show' && item.type !== 'episode') {
res.json({ success: true, data: { configured: true, linked: false } });
return;
}
const seriesId = await resolveSonarrSeriesId(item);
if (!seriesId) {
res.json({ success: true, data: { configured: true, linked: false } });
return;
}
const series = await sonarr.getSeriesById(seriesId);
const [episodes, files] = await Promise.all([
sonarr.getEpisodes(seriesId),
sonarr.getEpisodeFiles(seriesId),
]);
// Queue, quality profile and tags are garnish - a failure there should not
// cost the user the season breakdown.
const [queueResult, profilesResult, tagsResult, historyResult] = await Promise.allSettled([
sonarr.getQueue(),
sonarr.getQualityProfiles(),
sonarr.getTags(),
sonarr.getSeriesHistory(seriesId),
]);
const queue = queueResult.status === 'fulfilled' ? queueResult.value : [];
const history =
historyResult.status === 'fulfilled'
? buildSonarrHistoryEvents(historyResult.value, episodes)
: [];
const qualityProfileName =
profilesResult.status === 'fulfilled'
? profilesResult.value.get(series.qualityProfileId)
: undefined;
const tagLabels = tagsResult.status === 'fulfilled' ? tagsResult.value : undefined;
// Drop queue rows for episodes Sonarr no longer lists, then attach what's
// left so the UI can show what is already queued for deletion.
episodeDeletionsRepo.pruneMissingEpisodes(id, episodes.map((episode) => episode.id));
const queuedByEpisodeId = new Map<number, SonarrEpisodeQueued>(
episodeDeletionsRepo.getPendingForItem(id).map((row) => [
row.episode_id,
{
id: row.id,
action: row.deletion_action,
markedAt: row.marked_at,
deleteAfter: row.delete_after,
},
])
);
const detail = buildSonarrSeriesDetail({
series,
episodes,
files,
queue,
queuedByEpisodeId,
...(qualityProfileName ? { qualityProfileName } : {}),
...(tagLabels ? { tagLabels } : {}),
});
res.json({
success: true,
data: {
configured: true,
linked: true,
fetchedAt: new Date().toISOString(),
history,
...detail,
},
});
} catch (error) {
logger.error('Failed to get Sonarr details for media item:', error);
res.status(502).json({
success: false,
error: 'Failed to reach Sonarr. Check the connection in Settings.',
});
}
});
// POST /api/library/sync - Sync library from Plex (trigger a scan)
router.post('/sync', async (_req: Request, res: Response) => {
if (isSyncInProgress()) {
res.status(409).json({
success: false,
error: 'A sync is already in progress',
});
return;
}
res.status(202).json({
success: true,
message: 'Library sync started',
});
const outcome = await runLibrarySync();
if (outcome.status === 'completed' && outcome.result) {
logger.info('Library sync completed', {
itemsScanned: outcome.result.itemsScanned,
itemsAdded: outcome.result.itemsAdded,
itemsUpdated: outcome.result.itemsUpdated,
errors: outcome.result.errors.length,
});
}
});
/**
* Pipe sync events into an SSE response, closing it once the run reports
* 'complete'. See openSseStream for why writes after that must not happen.
*/
function streamSyncEvents(res: Response, replay: unknown[] = []): () => void {
let unsubscribe: () => void = () => {};
const stream = openSseStream(res, () => unsubscribe());
const forward = (data: unknown) => {
stream.send(data);
if ((data as { stage?: string } | null)?.stage === 'complete') stream.close();
};
// Replay buffered events so a reconnecting client catches up
for (const event of replay) forward(event);
if (!stream.closed) unsubscribe = subscribeToSync(forward);
return stream.close;
}
// GET /api/library/sync/stream - Subscribe to in-progress sync events (reconnect-safe)
router.get('/sync/stream', (_req: Request, res: Response) => {
if (!isSyncInProgress()) {
res.status(204).end();
return;
}
streamSyncEvents(res, getSyncProgressLog());
});
// POST /api/library/sync/stream - Start sync with SSE progress streaming
router.post('/sync/stream', async (_req: Request, res: Response) => {
if (isSyncInProgress()) {
res.status(409).json({
success: false,
error: 'A sync is already in progress',
});
return;
}
const close = streamSyncEvents(res);
try {
const outcome = await runLibrarySync();
if (outcome.status === 'completed' && outcome.result) {
logger.info('Library sync completed (streamed)', {
itemsScanned: outcome.result.itemsScanned,
itemsAdded: outcome.result.itemsAdded,
itemsUpdated: outcome.result.itemsUpdated,
errors: outcome.result.errors.length,
});
}
} finally {
close();
}
});
// POST /api/library/:id/sonarr/deletions - Queue (or immediately run) episode
// and season deletions for a show.
router.post('/:id/sonarr/deletions', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params['id'] as string, 10);
if (isNaN(id)) {
res.status(400).json({ success: false, error: 'Invalid ID parameter' });
return;
}
const parsed = EpisodeDeletionSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({ success: false, error: 'Invalid request body' });
return;
}
const {
episodeIds = [],
seasonNumbers = [],
deletionAction = 'unmonitor_and_delete',
gracePeriodDays = 7,
mode = 'queue',
} = parsed.data;
if (episodeIds.length === 0 && seasonNumbers.length === 0) {
res.status(400).json({ success: false, error: 'Select at least one episode or season' });
return;
}
const item = mediaItemsRepo.getById(id);
if (!item) {
res.status(404).json({ success: false, error: 'Media item not found' });
return;
}
// Protection is show-wide: a protected show shields its episodes too.
if (item.is_protected) {
res.status(409).json({ success: false, error: 'Cannot delete episodes of a protected item' });
return;
}
const protectedCollections = collectionsRepo.findProtectedContainingItem(id);
if (protectedCollections.length > 0) {
res.status(409).json({
success: false,
error: `Cannot delete episodes: item is protected by collection "${protectedCollections[0]!.title}"`,
});
return;
}
const sonarr = getSonarrService();
if (!sonarr) {
res.status(400).json({ success: false, error: 'Sonarr is not configured' });
return;
}
const seriesId = await resolveSonarrSeriesId(item);
if (!seriesId) {
res.status(400).json({ success: false, error: 'This item is not matched to a series in Sonarr' });
return;
}
const [episodes, files] = await Promise.all([
sonarr.getEpisodes(seriesId),
sonarr.getEpisodeFiles(seriesId),
]);
const action = deletionAction as EpisodeDeletionAction;
const targets = resolveTargets({ episodes, files, episodeIds, seasonNumbers, action });
if (targets.length === 0) {
res.status(400).json({
success: false,
error: 'Nothing to do for the selected episodes with that action',
});
return;
}
// Immediate deletions still go through the queue table so they land in the
// same history, activity log and status bookkeeping as queued ones.
const queued = queueEpisodeDeletions({
item,
seriesId,
targets,
action,
gracePeriodDays: mode === 'now' ? 0 : gracePeriodDays,
...(mode === 'now' ? { skipActivityLog: true } : {}),
});
if (mode !== 'now') {
res.json({
success: true,
data: {
queued: queued.length,
alreadyQueued: targets.length - queued.length,
deleted: 0,
failed: 0,
freedBytes: 0,
},
message: `Queued ${queued.length} episode(s) for deletion`,
});
return;
}
const result = await executeQueuedDeletions(queued);
res.json({
success: true,
data: {
queued: 0,
alreadyQueued: targets.length - queued.length,
deleted: result.deleted,
failed: result.failed,
freedBytes: result.freedBytes,
errors: result.outcomes.filter((o) => !o.success).map((o) => ({ title: o.label, error: o.error })),
},
message: `Deleted ${result.deleted} episode(s), ${result.failed} failed`,
});
} catch (error) {
logger.error('Failed to delete episodes:', error);
res.status(500).json({ success: false, error: 'Failed to delete episodes' });
}
});
// POST /api/library/:id/sonarr/deletions/cancel - Take episodes back out of the queue
router.post('/:id/sonarr/deletions/cancel', (req: Request, res: Response) => {
try {
const id = parseInt(req.params['id'] as string, 10);
if (isNaN(id)) {
res.status(400).json({ success: false, error: 'Invalid ID parameter' });
return;
}
const parsed = z
.object({ episodeIds: z.array(z.number().int().positive()).min(1) })
.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({ success: false, error: 'Provide the episode IDs to cancel' });
return;
}
const item = mediaItemsRepo.getById(id);
if (!item) {
res.status(404).json({ success: false, error: 'Media item not found' });
return;
}
const cancelled = episodeDeletionsRepo.cancelByEpisodeIds(id, parsed.data.episodeIds);
if (cancelled > 0) {
logActivity({
eventType: 'manual_action',
action: 'episodes_unqueued',
actorType: 'user',
actorName: 'Manual action',
targetType: 'media_item',
targetId: id,
targetTitle: item.title,
metadata: JSON.stringify({ episodes: cancelled }),
});
}
res.json({
success: true,
data: { cancelled },
message: `Removed ${cancelled} episode(s) from the deletion queue`,
});
} catch (error) {
logger.error('Failed to cancel episode deletions:', error);
res.status(500).json({ success: false, error: 'Failed to cancel episode deletions' });
}
});
// Schema for mark-deletion request body
const MarkDeletionSchema = z.object({
gracePeriodDays: z.number().optional(),
deletionAction: z.enum(['unmonitor_only', 'delete_files_only', 'unmonitor_and_delete', 'full_removal']).optional(),
resetOverseerr: z.boolean().optional(),
});
// POST /api/library/:id/mark-deletion - Mark an item for deletion (add to queue)
router.post('/:id/mark-deletion', (req: Request, res: Response) => {
try {
const id = parseInt(req.params['id'] as string, 10);
if (isNaN(id)) {
res.status(400).json({
success: false,
error: 'Invalid ID parameter',
});
return;
}
const item = mediaItemsRepo.getById(id);
if (!item) {
res.status(404).json({
success: false,
error: 'Media item not found',
});
return;
}
// Already-deleted items are tombstones — there's nothing left to queue.
if (item.status === 'deleted') {
res.status(409).json({
success: false,
error: 'Cannot queue an already-deleted item for deletion',
});
return;
}
// Check if item is protected (item-level or collection-level)
if (item.is_protected) {
res.status(409).json({
success: false,
error: 'Cannot mark a protected item for deletion',
});
return;
}
const protectedCollections = collectionsRepo.findProtectedContainingItem(id);
if (protectedCollections.length > 0) {
res.status(409).json({
success: false,
error: `Item is protected via collection "${protectedCollections[0]!.title}"`,
});
return;
}
// Parse optional deletion options from request body
const parseResult = MarkDeletionSchema.safeParse(req.body);
// Get grace period (from request or settings)
let gracePeriodDays: number;
if (parseResult.success && parseResult.data.gracePeriodDays !== undefined) {
gracePeriodDays = parseResult.data.gracePeriodDays;
} else {
// Get default grace period from settings (default to 7 days)
gracePeriodDays = settingsRepo.getNumber('deletion_grace_period_days', 7);
}
// Get deletion action (from request or settings)
const deletionAction = parseResult.success && parseResult.data.deletionAction
? parseResult.data.deletionAction
: settingsRepo.getValue('default_deletion_action') || 'unmonitor_and_delete';
// Get reset overseerr flag
const resetOverseerr = parseResult.success && parseResult.data.resetOverseerr === true ? 1 : 0;
// Calculate delete_after date
const now = new Date();
const deleteAfter = new Date(now);
deleteAfter.setDate(deleteAfter.getDate() + gracePeriodDays);
// For already queued items, preserve the original marked_at date
const isAlreadyQueued = item.status === 'pending_deletion';
// Update the item status with all options
const updatedItem = mediaItemsRepo.update(id, {
status: 'pending_deletion',
marked_at: isAlreadyQueued ? (item.marked_at ?? undefined) : now.toISOString(),
delete_after: deleteAfter.toISOString(),
deletion_action: deletionAction,
reset_overseerr: resetOverseerr,
});
if (!updatedItem) {
res.status(500).json({
success: false,
error: 'Failed to update media item',
});
return;
}
logger.info(`Marked item for deletion: ${item.title} (ID: ${id}), delete after: ${deleteAfter.toISOString()}`);
logActivity({
eventType: 'manual_action',
action: 'item_queued',
actorType: 'user',
targetType: 'media_item',
targetId: id,
targetTitle: item.title,
});
res.json({
success: true,
data: updatedItem,
message: `Item marked for deletion. Will be deleted after ${deleteAfter.toISOString()}`,
});
} catch (error) {
logger.error('Failed to mark item for deletion:', error);
res.status(500).json({
success: false,
error: 'Failed to mark item for deletion',
});
}
});
// Schema for protect request body
const ProtectSchema = z.object({
reason: z.string().optional(),
});
// POST /api/library/:id/protect - Protect an item from deletion
router.post('/:id/protect', (req: Request, res: Response) => {
try {
const id = parseInt(req.params['id'] as string, 10);
if (isNaN(id)) {
res.status(400).json({
success: false,
error: 'Invalid ID parameter',
});
return;
}
const item = mediaItemsRepo.getById(id);
if (!item) {
res.status(404).json({
success: false,
error: 'Media item not found',
});
return;
}
// Parse optional reason from request body
const parseResult = ProtectSchema.safeParse(req.body);
const reason = parseResult.success && parseResult.data.reason ? parseResult.data.reason : 'Manually protected';
// Protect the item
const updatedItem = mediaItemsRepo.protect(id, reason);
if (!updatedItem) {
res.status(500).json({
success: false,
error: 'Failed to protect media item',
});
return;
}
logger.info(`Protected item: ${item.title} (ID: ${id}), reason: ${reason}`);
logActivity({
eventType: 'protection',
action: 'protected',
actorType: 'user',
targetType: 'media_item',
targetId: id,
targetTitle: item.title,
});
res.json({
success: true,
data: updatedItem,
message: 'Item protected from deletion',
});
} catch (error) {
logger.error('Failed to protect item:', error);
res.status(500).json({
success: false,
error: 'Failed to protect item',
});
}
});
// DELETE /api/library/:id/protect - Remove protection from an item
router.delete('/:id/protect', (req: Request, res: Response) => {
try {
const id = parseInt(req.params['id'] as string, 10);
if (isNaN(id)) {
res.status(400).json({
success: false,
error: 'Invalid ID parameter',
});
return;
}
const item = mediaItemsRepo.getById(id);
if (!item) {
res.status(404).json({
success: false,
error: 'Media item not found',
});
return;
}
// Check if item is actually protected
if (!item.is_protected) {
res.status(409).json({
success: false,
error: 'Item is not protected',
});
return;
}
// Remove protection
const updatedItem = mediaItemsRepo.unprotect(id);
if (!updatedItem) {
res.status(500).json({
success: false,
error: 'Failed to remove protection from media item',
});
return;
}
logger.info(`Removed protection from item: ${item.title} (ID: ${id})`);
logActivity({
eventType: 'protection',
action: 'unprotected',
actorType: 'user',
targetType: 'media_item',
targetId: id,
targetTitle: item.title,
});
res.json({
success: true,
data: updatedItem,
message: 'Protection removed from item',
});
} catch (error) {
logger.error('Failed to remove protection from item:', error);
res.status(500).json({
success: false,
error: 'Failed to remove protection from item',
});
}
});
export default router;