/** * Image proxy routes * * Provides a proxy endpoint for fetching images from Plex/Jellyfin servers. * This solves CORS issues and allows resizing/caching of images. */ import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; import { proxyImage, type FallbackType } from '../services/imageProxy.js'; const proxyQuerySchema = z.object({ server: z.uuid({ error: 'Invalid server ID' }), url: z.string().min(1, 'Image URL is required'), width: z.coerce.number().int().min(10).max(2000).optional().default(300), height: z.coerce.number().int().min(10).max(2000).optional().default(450), fallback: z.enum(['poster', 'avatar', 'art']).optional().default('poster'), }); export const imageRoutes: FastifyPluginAsync = async (app) => { /** * GET /images/proxy - Proxy an image from a media server * * Note: No authentication required - images are public once you have * a valid server ID. This allows tags to work without auth headers. * Server ID is validated in proxyImage service. * * Query params: * - server: UUID of the server to fetch from * - url: The image path (e.g., /library/metadata/123/thumb/456) * - width: Resize width (default 300) * - height: Resize height (default 450) * - fallback: Placeholder type if image fails (poster, avatar, art) */ app.get( '/proxy', async (request, reply) => { const parseResult = proxyQuerySchema.safeParse(request.query); if (!parseResult.success) { return reply.status(400).send({ error: 'Invalid query parameters', details: z.treeifyError(parseResult.error), }); } const { server, url, width, height, fallback } = parseResult.data; const result = await proxyImage({ serverId: server, imagePath: url, width, height, fallback: fallback as FallbackType, }); // Set cache headers if (result.cached) { reply.header('X-Cache', 'HIT'); } else { reply.header('X-Cache', 'MISS'); } // Cache for 1 hour in browser, allow CDN caching reply.header('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400'); reply.header('Content-Type', result.contentType); return reply.send(result.data); } ); /** * GET /images/avatar - Get a user avatar (with gravatar fallback) * * Note: No authentication required for same reason as /proxy * * Query params: * - server: UUID of the server (optional if using gravatar) * - url: The avatar path from server (optional) * - email: Email for gravatar fallback (optional) * - size: Avatar size (default 100) */ app.get( '/avatar', async (request, reply) => { const query = request.query as Record; const server = query.server; const url = query.url; const size = parseInt(query.size ?? '100', 10); // If we have server URL, try to fetch from media server if (server && url) { const result = await proxyImage({ serverId: server, imagePath: url, width: size, height: size, fallback: 'avatar', }); reply.header('Cache-Control', 'public, max-age=3600'); reply.header('Content-Type', result.contentType); return reply.send(result.data); } // Return fallback avatar const result = await proxyImage({ serverId: 'fallback', imagePath: 'fallback', width: size, height: size, fallback: 'avatar', }); reply.header('Cache-Control', 'public, max-age=86400'); reply.header('Content-Type', result.contentType); return reply.send(result.data); } ); };