Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
121 lines
3.7 KiB
TypeScript
121 lines
3.7 KiB
TypeScript
/**
|
|
* 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 <img> 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<string, string | undefined>;
|
|
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);
|
|
}
|
|
);
|
|
};
|