tubearr/src/server/routes/format-profile.ts
John Lightner 4606dce553 feat: Tubearr — full project state through M006/S01
Migrated git root from W:/programming/Projects/ to W:/programming/Projects/Tubearr/.
Previous history preserved in Tubearr-full-backup.bundle at parent directory.

Completed milestones: M001 through M005
Active: M006/S02 (Add Channel UX)
2026-03-24 20:20:10 -05:00

207 lines
6.1 KiB
TypeScript

import { type FastifyInstance } from 'fastify';
import {
createFormatProfile,
getAllFormatProfiles,
getFormatProfileById,
updateFormatProfile,
deleteFormatProfile,
} from '../../db/repositories/format-profile-repository';
// ── JSON Schemas for Fastify Validation ──
const createFormatProfileBodySchema = {
type: 'object' as const,
required: ['name'],
properties: {
name: { type: 'string' as const, minLength: 1 },
videoResolution: { type: 'string' as const, nullable: true },
audioCodec: { type: 'string' as const, nullable: true },
audioBitrate: { type: 'string' as const, nullable: true },
containerFormat: { type: 'string' as const, nullable: true },
isDefault: { type: 'boolean' as const },
subtitleLanguages: { type: 'string' as const, nullable: true },
embedSubtitles: { type: 'boolean' as const },
},
additionalProperties: false,
};
const updateFormatProfileBodySchema = {
type: 'object' as const,
properties: {
name: { type: 'string' as const, minLength: 1 },
videoResolution: { type: 'string' as const, nullable: true },
audioCodec: { type: 'string' as const, nullable: true },
audioBitrate: { type: 'string' as const, nullable: true },
containerFormat: { type: 'string' as const, nullable: true },
isDefault: { type: 'boolean' as const },
subtitleLanguages: { type: 'string' as const, nullable: true },
embedSubtitles: { type: 'boolean' as const },
},
additionalProperties: false,
};
// ── Route Plugin ──
/**
* Format profile CRUD route plugin.
*
* Registers:
* POST /api/v1/format-profile — create a new format profile
* GET /api/v1/format-profile — list all format profiles
* GET /api/v1/format-profile/:id — get a single format profile
* PUT /api/v1/format-profile/:id — update format profile fields
* DELETE /api/v1/format-profile/:id — delete a format profile
*/
export async function formatProfileRoutes(fastify: FastifyInstance): Promise<void> {
// ── POST /api/v1/format-profile ──
fastify.post<{
Body: {
name: string;
videoResolution?: string | null;
audioCodec?: string | null;
audioBitrate?: string | null;
containerFormat?: string | null;
isDefault?: boolean;
subtitleLanguages?: string | null;
embedSubtitles?: boolean;
};
}>(
'/api/v1/format-profile',
{
schema: { body: createFormatProfileBodySchema },
},
async (request, reply) => {
const profile = await createFormatProfile(fastify.db, request.body);
return reply.status(201).send(profile);
}
);
// ── GET /api/v1/format-profile ──
fastify.get('/api/v1/format-profile', async (_request, _reply) => {
return getAllFormatProfiles(fastify.db);
});
// ── GET /api/v1/format-profile/:id ──
fastify.get<{ Params: { id: string } }>(
'/api/v1/format-profile/:id',
async (request, reply) => {
const id = parseInt(request.params.id, 10);
if (isNaN(id)) {
return reply.status(400).send({
statusCode: 400,
error: 'Bad Request',
message: 'Format profile ID must be a number',
});
}
const profile = await getFormatProfileById(fastify.db, id);
if (!profile) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Format profile with ID ${id} not found`,
});
}
return profile;
}
);
// ── PUT /api/v1/format-profile/:id ──
fastify.put<{
Params: { id: string };
Body: {
name?: string;
videoResolution?: string | null;
audioCodec?: string | null;
audioBitrate?: string | null;
containerFormat?: string | null;
isDefault?: boolean;
subtitleLanguages?: string | null;
embedSubtitles?: boolean;
};
}>(
'/api/v1/format-profile/:id',
{
schema: { body: updateFormatProfileBodySchema },
},
async (request, reply) => {
const id = parseInt(request.params.id, 10);
if (isNaN(id)) {
return reply.status(400).send({
statusCode: 400,
error: 'Bad Request',
message: 'Format profile ID must be a number',
});
}
// Guard: prevent unsetting isDefault on the default profile
const existing = await getFormatProfileById(fastify.db, id);
if (!existing) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Format profile with ID ${id} not found`,
});
}
if (existing.isDefault && request.body.isDefault === false) {
return reply.status(400).send({
statusCode: 400,
error: 'Bad Request',
message: 'Cannot unset isDefault on the default format profile',
});
}
const updated = await updateFormatProfile(fastify.db, id, request.body);
if (!updated) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Format profile with ID ${id} not found`,
});
}
return updated;
}
);
// ── DELETE /api/v1/format-profile/:id ──
fastify.delete<{ Params: { id: string } }>(
'/api/v1/format-profile/:id',
async (request, reply) => {
const id = parseInt(request.params.id, 10);
if (isNaN(id)) {
return reply.status(400).send({
statusCode: 400,
error: 'Bad Request',
message: 'Format profile ID must be a number',
});
}
// Guard: prevent deleting the default profile
const profile = await getFormatProfileById(fastify.db, id);
if (!profile) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Format profile with ID ${id} not found`,
});
}
if (profile.isDefault) {
return reply.status(403).send({
statusCode: 403,
error: 'Forbidden',
message: 'Cannot delete the default format profile',
});
}
await deleteFormatProfile(fastify.db, id);
return reply.status(204).send();
}
);
}