- Consolidate 5 duplicate format functions (formatDuration, formatRelativeTime, formatFileSize, formatSubscriberCount) into shared utils/format.ts - Extract parseIdParam() route helper, replacing 22 copy-paste blocks across 9 route files - Remove dead exports: useScanStatus, useChannelContent (non-paginated), getContentItemsByStatus, deleteQueueItem, deletePlaylistsByChannelId - Fix as-any type assertion in system.ts (queueService already typed on FastifyInstance) - Net: -411 lines, 23 files touched
273 lines
7.8 KiB
TypeScript
273 lines
7.8 KiB
TypeScript
import { type FastifyInstance } from 'fastify';
|
|
import { parseIdParam } from './helpers';
|
|
import {
|
|
getAllContentItems,
|
|
getContentByChannelId,
|
|
getChannelContentPaginated,
|
|
setMonitored,
|
|
bulkSetMonitored,
|
|
} from '../../db/repositories/content-repository';
|
|
import type { PaginatedResponse, ApiResponse } from '../../types/api';
|
|
import type { ContentItem, ContentStatus, ContentType } from '../../types/index';
|
|
|
|
// ── JSON Schemas for Fastify Validation ──
|
|
|
|
const bulkMonitoredBodySchema = {
|
|
type: 'object' as const,
|
|
required: ['ids', 'monitored'],
|
|
properties: {
|
|
ids: { type: 'array' as const, items: { type: 'number' as const }, minItems: 1 },
|
|
monitored: { type: 'boolean' as const },
|
|
},
|
|
additionalProperties: false,
|
|
};
|
|
|
|
const toggleMonitoredBodySchema = {
|
|
type: 'object' as const,
|
|
required: ['monitored'],
|
|
properties: {
|
|
monitored: { type: 'boolean' as const },
|
|
},
|
|
additionalProperties: false,
|
|
};
|
|
|
|
// ── Route Plugin ──
|
|
|
|
/**
|
|
* Content route plugin.
|
|
*
|
|
* Registers:
|
|
* GET /api/v1/content — paginated content listing with optional filters
|
|
* PATCH /api/v1/content/bulk/monitored — bulk toggle monitored state
|
|
* PATCH /api/v1/content/:id/monitored — toggle single item monitored state
|
|
* GET /api/v1/channel/:id/content — content items for a specific channel
|
|
*/
|
|
export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
|
|
// ── GET /api/v1/content ──
|
|
|
|
fastify.get<{
|
|
Querystring: {
|
|
page?: string;
|
|
pageSize?: string;
|
|
status?: string;
|
|
contentType?: string;
|
|
channelId?: string;
|
|
search?: string;
|
|
};
|
|
}>('/api/v1/content', async (request, _reply) => {
|
|
const page = Math.max(1, parseInt(request.query.page ?? '1', 10) || 1);
|
|
const pageSize = Math.min(
|
|
100,
|
|
Math.max(1, parseInt(request.query.pageSize ?? '20', 10) || 20)
|
|
);
|
|
|
|
const filters: {
|
|
status?: ContentStatus;
|
|
contentType?: ContentType;
|
|
channelId?: number;
|
|
search?: string;
|
|
} = {};
|
|
|
|
if (request.query.status) {
|
|
filters.status = request.query.status as ContentStatus;
|
|
}
|
|
if (request.query.contentType) {
|
|
filters.contentType = request.query.contentType as ContentType;
|
|
}
|
|
if (request.query.channelId) {
|
|
const channelId = parseInt(request.query.channelId, 10);
|
|
if (!isNaN(channelId)) filters.channelId = channelId;
|
|
}
|
|
if (request.query.search) {
|
|
filters.search = request.query.search;
|
|
}
|
|
|
|
try {
|
|
const result = await getAllContentItems(fastify.db, filters, page, pageSize);
|
|
|
|
const response: PaginatedResponse<ContentItem> = {
|
|
success: true,
|
|
data: result.items,
|
|
pagination: {
|
|
page,
|
|
pageSize,
|
|
totalItems: result.total,
|
|
totalPages: Math.ceil(result.total / pageSize),
|
|
},
|
|
};
|
|
|
|
return response;
|
|
} catch (err) {
|
|
request.log.error(
|
|
{ err, filters, page, pageSize },
|
|
'[content] Failed to fetch paginated content items'
|
|
);
|
|
return _reply.status(500).send({
|
|
statusCode: 500,
|
|
error: 'Internal Server Error',
|
|
message: 'Failed to retrieve content items',
|
|
});
|
|
}
|
|
});
|
|
|
|
// ── PATCH /api/v1/content/bulk/monitored ──
|
|
// NOTE: Must be registered BEFORE /api/v1/content/:id/* routes
|
|
// to prevent Fastify from matching "bulk" as an :id param.
|
|
|
|
fastify.patch<{
|
|
Body: { ids: number[]; monitored: boolean };
|
|
}>(
|
|
'/api/v1/content/bulk/monitored',
|
|
{ schema: { body: bulkMonitoredBodySchema } },
|
|
async (request, reply) => {
|
|
try {
|
|
const count = await bulkSetMonitored(
|
|
fastify.db,
|
|
request.body.ids,
|
|
request.body.monitored
|
|
);
|
|
|
|
const response: ApiResponse<{ updated: number }> = {
|
|
success: true,
|
|
data: { updated: count },
|
|
};
|
|
|
|
return response;
|
|
} catch (err) {
|
|
request.log.error(
|
|
{ err, ids: request.body.ids },
|
|
'[content] Failed to bulk update monitored state'
|
|
);
|
|
return reply.status(500).send({
|
|
statusCode: 500,
|
|
error: 'Internal Server Error',
|
|
message: 'Failed to bulk update monitored state',
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// ── PATCH /api/v1/content/:id/monitored ──
|
|
|
|
fastify.patch<{
|
|
Params: { id: string };
|
|
Body: { monitored: boolean };
|
|
}>(
|
|
'/api/v1/content/:id/monitored',
|
|
{ schema: { body: toggleMonitoredBodySchema } },
|
|
async (request, reply) => {
|
|
const id = parseIdParam(request.params.id, reply, 'Content item ID');
|
|
if (id === null) return;
|
|
|
|
try {
|
|
const result = await setMonitored(
|
|
fastify.db,
|
|
id,
|
|
request.body.monitored
|
|
);
|
|
|
|
if (!result) {
|
|
return reply.status(404).send({
|
|
statusCode: 404,
|
|
error: 'Not Found',
|
|
message: 'Content item not found',
|
|
});
|
|
}
|
|
|
|
const response: ApiResponse<ContentItem> = {
|
|
success: true,
|
|
data: result,
|
|
};
|
|
|
|
return response;
|
|
} catch (err) {
|
|
request.log.error(
|
|
{ err, id },
|
|
'[content] Failed to update monitored state'
|
|
);
|
|
return reply.status(500).send({
|
|
statusCode: 500,
|
|
error: 'Internal Server Error',
|
|
message: 'Failed to update monitored state',
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// ── GET /api/v1/channel/:id/content ──
|
|
|
|
fastify.get<{
|
|
Params: { id: string };
|
|
Querystring: {
|
|
page?: string;
|
|
pageSize?: string;
|
|
search?: string;
|
|
status?: string;
|
|
contentType?: string;
|
|
sortBy?: string;
|
|
sortDirection?: string;
|
|
};
|
|
}>('/api/v1/channel/:id/content', async (request, reply) => {
|
|
const channelId = parseIdParam(request.params.id, reply, 'Channel ID');
|
|
if (channelId === null) return;
|
|
|
|
const page = Math.max(1, parseInt(request.query.page ?? '1', 10) || 1);
|
|
const pageSize = Math.min(
|
|
200,
|
|
Math.max(1, parseInt(request.query.pageSize ?? '50', 10) || 50)
|
|
);
|
|
|
|
// If no pagination params provided, return all items (backwards-compatible)
|
|
const hasPaginationParams = request.query.page || request.query.pageSize || request.query.search || request.query.status || request.query.contentType || request.query.sortBy;
|
|
|
|
try {
|
|
if (!hasPaginationParams) {
|
|
// Legacy mode: return all items as flat array (backwards-compatible)
|
|
const items = await getContentByChannelId(fastify.db, channelId);
|
|
const response: ApiResponse<ContentItem[]> = {
|
|
success: true,
|
|
data: items,
|
|
};
|
|
return response;
|
|
}
|
|
|
|
// Paginated mode with filters
|
|
const result = await getChannelContentPaginated(
|
|
fastify.db,
|
|
channelId,
|
|
{
|
|
search: request.query.search || undefined,
|
|
status: (request.query.status as ContentStatus) || undefined,
|
|
contentType: (request.query.contentType as ContentType) || undefined,
|
|
sortBy: request.query.sortBy as 'title' | 'publishedAt' | 'status' | 'duration' | 'fileSize' | 'downloadedAt' | undefined,
|
|
sortDirection: (request.query.sortDirection as 'asc' | 'desc') || undefined,
|
|
},
|
|
page,
|
|
pageSize
|
|
);
|
|
|
|
const response: PaginatedResponse<ContentItem> = {
|
|
success: true,
|
|
data: result.items,
|
|
pagination: {
|
|
page,
|
|
pageSize,
|
|
totalItems: result.total,
|
|
totalPages: Math.ceil(result.total / pageSize),
|
|
},
|
|
};
|
|
|
|
return response;
|
|
} catch (err) {
|
|
request.log.error(
|
|
{ err, channelId },
|
|
'[content] Failed to fetch content for channel'
|
|
);
|
|
return reply.status(500).send({
|
|
statusCode: 500,
|
|
error: 'Internal Server Error',
|
|
message: `Failed to retrieve content for channel ${channelId}`,
|
|
});
|
|
}
|
|
});
|
|
}
|