tubearr/src/server/routes/media-server.ts
jlightner 9ef0323480 test: Built media server CRUD routes, connection test/sections endpoint…
- "src/server/routes/media-server.ts"
- "src/__tests__/media-server-api.test.ts"
- "src/index.ts"
- "src/server/index.ts"

GSD-Task: S04/T03
2026-04-04 05:57:39 +00:00

224 lines
6.8 KiB
TypeScript

import { type FastifyInstance } from 'fastify';
import { parseIdParam } from './helpers';
import {
createMediaServer,
getAllMediaServers,
getMediaServerById,
updateMediaServer,
deleteMediaServer,
} from '../../db/repositories/media-server-repository';
import { MediaServerService } from '../../services/media-server';
// ── JSON Schemas for Fastify Validation ──
const createMediaServerBodySchema = {
type: 'object' as const,
required: ['name', 'type', 'url', 'token'],
properties: {
name: { type: 'string' as const, minLength: 1 },
type: { type: 'string' as const, enum: ['plex', 'jellyfin'] },
url: { type: 'string' as const, minLength: 1 },
token: { type: 'string' as const, minLength: 1 },
librarySection: { type: 'string' as const, nullable: true },
enabled: { type: 'boolean' as const },
},
additionalProperties: false,
};
const updateMediaServerBodySchema = {
type: 'object' as const,
properties: {
name: { type: 'string' as const, minLength: 1 },
type: { type: 'string' as const, enum: ['plex', 'jellyfin'] },
url: { type: 'string' as const, minLength: 1 },
token: { type: 'string' as const, minLength: 1 },
librarySection: { type: 'string' as const, nullable: true },
enabled: { type: 'boolean' as const },
},
additionalProperties: false,
};
// ── Helpers ──
/** Redact the token for API responses — show first 4 chars + '…'. */
function redactToken(token: string): string {
if (token.length <= 4) return '****';
return token.slice(0, 4) + '****';
}
/** Return a copy of the media server with the token redacted. */
function redactServer<T extends { token: string }>(server: T): T {
return { ...server, token: redactToken(server.token) };
}
// ── Route Plugin ──
/**
* Media server CRUD + action route plugin.
*
* Registers:
* POST /api/v1/media-servers — create a media server
* GET /api/v1/media-servers — list all media servers (redacted tokens)
* GET /api/v1/media-servers/:id — get a single server (redacted token)
* PUT /api/v1/media-servers/:id — update server fields
* DELETE /api/v1/media-servers/:id — delete a server
* POST /api/v1/media-servers/:id/test — test connection
* GET /api/v1/media-servers/:id/sections — list library sections
*/
export async function mediaServerRoutes(fastify: FastifyInstance): Promise<void> {
const service = new MediaServerService();
// ── POST /api/v1/media-servers ──
fastify.post<{
Body: {
name: string;
type: 'plex' | 'jellyfin';
url: string;
token: string;
librarySection?: string | null;
enabled?: boolean;
};
}>(
'/api/v1/media-servers',
{ schema: { body: createMediaServerBodySchema } },
async (request, reply) => {
const server = await createMediaServer(fastify.db, request.body);
request.log.info(
{ serverId: server.id, name: server.name, type: server.type },
`[media-server] created server="${server.name}" type=${server.type}`
);
return reply.status(201).send(redactServer(server));
}
);
// ── GET /api/v1/media-servers ──
fastify.get('/api/v1/media-servers', async () => {
const servers = await getAllMediaServers(fastify.db);
return servers.map(redactServer);
});
// ── GET /api/v1/media-servers/:id ──
fastify.get<{ Params: { id: string } }>(
'/api/v1/media-servers/:id',
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Media server ID');
if (id === null) return;
const server = await getMediaServerById(fastify.db, id);
if (!server) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Media server with ID ${id} not found`,
});
}
return redactServer(server);
}
);
// ── PUT /api/v1/media-servers/:id ──
fastify.put<{
Params: { id: string };
Body: {
name?: string;
type?: 'plex' | 'jellyfin';
url?: string;
token?: string;
librarySection?: string | null;
enabled?: boolean;
};
}>(
'/api/v1/media-servers/:id',
{ schema: { body: updateMediaServerBodySchema } },
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Media server ID');
if (id === null) return;
const updated = await updateMediaServer(fastify.db, id, request.body);
if (!updated) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Media server with ID ${id} not found`,
});
}
request.log.info(
{ serverId: id, fields: Object.keys(request.body) },
`[media-server] updated server=${id}`
);
return redactServer(updated);
}
);
// ── DELETE /api/v1/media-servers/:id ──
fastify.delete<{ Params: { id: string } }>(
'/api/v1/media-servers/:id',
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Media server ID');
if (id === null) return;
const deleted = await deleteMediaServer(fastify.db, id);
if (!deleted) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Media server with ID ${id} not found`,
});
}
request.log.info({ serverId: id }, `[media-server] deleted server=${id}`);
return reply.status(204).send();
}
);
// ── POST /api/v1/media-servers/:id/test ──
fastify.post<{ Params: { id: string } }>(
'/api/v1/media-servers/:id/test',
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Media server ID');
if (id === null) return;
const server = await getMediaServerById(fastify.db, id);
if (!server) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Media server with ID ${id} not found`,
});
}
const result = await service.testConnection(server);
return reply.send(result);
}
);
// ── GET /api/v1/media-servers/:id/sections ──
fastify.get<{ Params: { id: string } }>(
'/api/v1/media-servers/:id/sections',
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Media server ID');
if (id === null) return;
const server = await getMediaServerById(fastify.db, id);
if (!server) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Media server with ID ${id} not found`,
});
}
const sections = await service.listLibrarySections(server);
return reply.send(sections);
}
);
}