tubearr/src/server/routes/content.ts
jlightner b1e90ea8d6 refactor: consolidate format utils, extract route helpers, remove dead code
- 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
2026-04-03 22:55:43 +00:00

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}`,
});
}
});
}