server/src/routes/collections.ts
Rama main · réplica de lectura del repositorio original.
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import collectionsRepo, { type Collection } from '../db/repositories/collections';
import mediaItemsRepo from '../db/repositories/mediaItems';
import settingsRepo from '../db/repositories/settings';
import { logActivity } from '../db/repositories/activity';
import { getDatabase } from '../db';
import { getRadarrService } from '../services/init';
import logger from '../utils/logger';
import { toThumbnailUrl } from '../utils/posterUrl';
const router = Router();
/**
* Convert a Collection DB row to camelCase client payload.
*/
function toClient(col: Collection): Record<string, unknown> {
return {
id: col.id,
tmdbId: col.tmdb_id,
title: col.title,
overview: col.overview,
posterUrl: toThumbnailUrl(col.poster_url),
itemCount: col.item_count,
isProtected: col.is_protected,
protectionReason: col.protection_reason,
protectedAt: col.protected_at,
lastSyncedAt: col.last_synced_at,
createdAt: col.created_at,
updatedAt: col.updated_at,
};
}
// GET /api/collections
router.get('/', (_req: Request, res: Response) => {
try {
const collections = collectionsRepo.findAll();
res.json({
success: true,
data: collections.map(toClient),
});
} catch (error) {
logger.error('Failed to list collections:', error);
res.status(500).json({ success: false, error: 'Failed to list collections' });
}
});
// GET /api/collections/: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 col = collectionsRepo.findById(id);
if (!col) {
res.status(404).json({ success: false, error: 'Collection not found' });
return;
}
res.json({ success: true, data: toClient(col) });
} catch (error) {
logger.error('Failed to get collection:', error);
res.status(500).json({ success: false, error: 'Failed to get collection' });
}
});
// GET /api/collections/:id/items
router.get('/:id/items', (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 col = collectionsRepo.findById(id);
if (!col) {
res.status(404).json({ success: false, error: 'Collection not found' });
return;
}
const mediaItemIds = collectionsRepo.getMediaItemIds(id);
const items = mediaItemIds
.map((mid) => mediaItemsRepo.getById(mid))
.filter((item): item is NonNullable<typeof item> => item !== null)
.map((item) => ({
id: item.id,
title: item.title,
type: item.type === 'show' ? 'tv' : item.type,
year: item.year,
size: item.file_size || 0,
posterUrl: toThumbnailUrl(item.poster_url),
status: item.status,
isProtected: item.is_protected || false,
radarrId: item.radarr_id,
tmdbId: item.tmdb_id,
imdbId: item.imdb_id,
}));
res.json({ success: true, data: items });
} catch (error) {
logger.error('Failed to get collection items:', error);
res.status(500).json({ success: false, error: 'Failed to get collection items' });
}
});
// POST /api/collections/sync
router.post('/sync', async (_req: Request, res: Response) => {
try {
const radarr = getRadarrService();
if (!radarr) {
res.status(400).json({ success: false, error: 'Radarr is not configured' });
return;
}
const result = await radarr.syncCollections();
res.json({
success: true,
data: {
collectionsSynced: result.collectionsSynced,
itemsMatched: result.itemsMatched,
},
message: `Synced ${result.collectionsSynced} collections, matched ${result.itemsMatched} items`,
});
} catch (error) {
logger.error('Failed to sync collections:', error);
res.status(500).json({
success: false,
error: 'Failed to sync collections',
});
}
});
const ProtectionSchema = z.object({
isProtected: z.boolean(),
reason: z.string().optional().nullable(),
});
// PATCH /api/collections/:id/protection
router.patch('/:id/protection', (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 = ProtectionSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
error: 'Invalid request body',
details: parsed.error.issues,
});
return;
}
const existing = collectionsRepo.findById(id);
if (!existing) {
res.status(404).json({ success: false, error: 'Collection not found' });
return;
}
const db = getDatabase();
const updated = db.transaction(() => {
const result = collectionsRepo.setProtection(
id,
parsed.data.isProtected,
parsed.data.reason ?? null
);
if (!result) return null;
const itemCount = collectionsRepo.getMediaItemIds(id).length;
const metaObj = {
collectionId: id,
itemCount,
isProtected: parsed.data.isProtected,
reason: parsed.data.reason ?? null,
};
logActivity({
eventType: 'protection',
action: parsed.data.isProtected ? 'collection_protected' : 'collection_unprotected',
actorType: 'user',
targetType: 'collection',
targetId: id,
targetTitle: existing.title,
metadata: JSON.stringify(metaObj),
});
return result;
})();
if (!updated) {
res.status(500).json({ success: false, error: 'Failed to update protection' });
return;
}
logger.info(
`Collection ${id} (${existing.title}) protection set to ${parsed.data.isProtected}`
);
res.json({ success: true, data: toClient(updated) });
} catch (error) {
logger.error('Failed to update collection protection:', error);
res.status(500).json({ success: false, error: 'Failed to update collection protection' });
}
});
const QueueCollectionSchema = z.object({
deletionAction: z.enum(['unmonitor_only', 'delete_files_only', 'unmonitor_and_delete', 'full_removal']),
gracePeriodDays: z.number().optional(),
resetOverseerr: z.boolean().optional(),
});
// POST /api/collections/:id/queue - Queue all items in a collection for deletion
router.post('/:id/queue', (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 col = collectionsRepo.findById(id);
if (!col) {
res.status(404).json({ success: false, error: 'Collection not found' });
return;
}
const parsed = QueueCollectionSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
error: 'Invalid request body',
details: parsed.error.issues,
});
return;
}
const { deletionAction, resetOverseerr: requestedResetOverseerr } = parsed.data;
// Get grace period from request or settings
const defaultGracePeriod = settingsRepo.getNumber('default_grace_period_days', 7);
const gracePeriodDays = parsed.data.gracePeriodDays ?? defaultGracePeriod;
const resetOverseerr = requestedResetOverseerr === true ? 1 : 0;
const now = new Date();
const deleteAfter = new Date(now.getTime() + gracePeriodDays * 86400000);
const mediaItemIds = collectionsRepo.getMediaItemIds(id);
const protectedMap = collectionsRepo.findProtectedForItems(mediaItemIds);
let queued = 0;
let totalSize = 0;
const skippedReasons: Record<string, number> = {};
for (const itemId of mediaItemIds) {
const item = mediaItemsRepo.getById(itemId);
if (!item) {
continue;
}
// Skip already queued items
if (item.status === 'pending_deletion') {
skippedReasons['already_queued'] = (skippedReasons['already_queued'] || 0) + 1;
continue;
}
// Skip protected items (item-level or collection-level)
if (item.is_protected || (protectedMap.get(itemId)?.length ?? 0) > 0) {
skippedReasons['protected'] = (skippedReasons['protected'] || 0) + 1;
continue;
}
const updated = mediaItemsRepo.update(itemId, {
status: 'pending_deletion',
marked_at: now.toISOString(),
delete_after: deleteAfter.toISOString(),
deletion_action: deletionAction,
reset_overseerr: resetOverseerr,
});
if (updated) {
queued++;
totalSize += item.file_size || 0;
logActivity({
eventType: 'manual_action',
action: 'item_queued',
actorType: 'user',
targetType: 'media_item',
targetId: itemId,
targetTitle: item.title,
});
}
}
const skipped = Object.values(skippedReasons).reduce((sum, n) => sum + n, 0);
logger.info(
`Collection ${id} (${col.title}) queued for deletion: ${queued} queued, ${skipped} skipped`
);
res.json({
success: true,
data: {
queued,
skipped,
skippedReasons,
totalSize,
},
});
} catch (error) {
logger.error('Failed to queue collection for deletion:', error);
res.status(500).json({ success: false, error: 'Failed to queue collection for deletion' });
}
});
export default router;