diff --git a/src/__tests__/media-server-api.test.ts b/src/__tests__/media-server-api.test.ts new file mode 100644 index 0000000..f5e6d08 --- /dev/null +++ b/src/__tests__/media-server-api.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { type FastifyInstance } from 'fastify'; +import { initDatabaseAsync, closeDatabase } from '../db/index'; +import { runMigrations } from '../db/migrate'; +import { buildServer } from '../server/index'; +import { systemConfig } from '../db/schema/index'; +import { eq } from 'drizzle-orm'; +import { type LibSQLDatabase } from 'drizzle-orm/libsql'; +import type * as schema from '../db/schema/index'; + +/** + * Integration tests for media-server CRUD + action API endpoints. + * Uses Fastify inject — no real HTTP ports. + */ + +describe('Media Server API', () => { + let server: FastifyInstance; + let db: LibSQLDatabase; + let apiKey: string; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-media-server-api-')); + const dbPath = join(tmpDir, 'test.db'); + db = await initDatabaseAsync(dbPath); + await runMigrations(dbPath); + server = await buildServer({ db }); + await server.ready(); + + // Read API key from database (generated by auth plugin) + const rows = await db + .select() + .from(systemConfig) + .where(eq(systemConfig.key, 'api_key')) + .limit(1); + apiKey = rows[0]?.value ?? ''; + expect(apiKey).toBeTruthy(); + }); + + afterAll(async () => { + await server.close(); + closeDatabase(); + try { + if (tmpDir && existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + } + } catch { + // best-effort cleanup + } + }); + + // ── Helpers ── + + function authed(opts: Record) { + return { + ...opts, + headers: { 'x-api-key': apiKey, ...(opts.headers as Record | undefined) }, + }; + } + + const plexBody = { + name: 'My Plex', + type: 'plex' as const, + url: 'http://plex.local:32400', + token: 'abc123secret', + librarySection: '1', + enabled: true, + }; + + const jellyfinBody = { + name: 'My Jellyfin', + type: 'jellyfin' as const, + url: 'http://jellyfin.local:8096', + token: 'jf-token-secret', + enabled: true, + }; + + // ── CRUD ── + + describe('CRUD', () => { + it('POST /api/v1/media-servers creates a server and redacts token', async () => { + const res = await server.inject( + authed({ + method: 'POST', + url: '/api/v1/media-servers', + payload: plexBody, + }) + ); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.name).toBe('My Plex'); + expect(body.type).toBe('plex'); + expect(body.url).toBe('http://plex.local:32400'); + // Token should be redacted + expect(body.token).not.toBe('abc123secret'); + expect(body.token).toContain('****'); + expect(body.id).toBeTypeOf('number'); + }); + + it('GET /api/v1/media-servers lists all servers', async () => { + // Create a second server + await server.inject( + authed({ + method: 'POST', + url: '/api/v1/media-servers', + payload: jellyfinBody, + }) + ); + + const res = await server.inject( + authed({ method: 'GET', url: '/api/v1/media-servers' }) + ); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.length).toBeGreaterThanOrEqual(2); + // All tokens should be redacted + for (const s of body) { + expect(s.token).toContain('****'); + } + }); + + it('GET /api/v1/media-servers/:id returns a single server', async () => { + const createRes = await server.inject( + authed({ + method: 'POST', + url: '/api/v1/media-servers', + payload: { ...plexBody, name: 'Get-By-Id Test' }, + }) + ); + const created = createRes.json(); + + const res = await server.inject( + authed({ method: 'GET', url: `/api/v1/media-servers/${created.id}` }) + ); + + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe('Get-By-Id Test'); + }); + + it('GET /api/v1/media-servers/:id returns 404 for missing', async () => { + const res = await server.inject( + authed({ method: 'GET', url: '/api/v1/media-servers/99999' }) + ); + + expect(res.statusCode).toBe(404); + }); + + it('PUT /api/v1/media-servers/:id updates fields', async () => { + const createRes = await server.inject( + authed({ + method: 'POST', + url: '/api/v1/media-servers', + payload: plexBody, + }) + ); + const id = createRes.json().id; + + const res = await server.inject( + authed({ + method: 'PUT', + url: `/api/v1/media-servers/${id}`, + payload: { name: 'Updated Plex', enabled: false }, + }) + ); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.name).toBe('Updated Plex'); + expect(body.enabled).toBe(false); + }); + + it('PUT /api/v1/media-servers/:id returns 404 for missing', async () => { + const res = await server.inject( + authed({ + method: 'PUT', + url: '/api/v1/media-servers/99999', + payload: { name: 'Nope' }, + }) + ); + + expect(res.statusCode).toBe(404); + }); + + it('DELETE /api/v1/media-servers/:id deletes and returns 204', async () => { + const createRes = await server.inject( + authed({ + method: 'POST', + url: '/api/v1/media-servers', + payload: jellyfinBody, + }) + ); + const id = createRes.json().id; + + const res = await server.inject( + authed({ method: 'DELETE', url: `/api/v1/media-servers/${id}` }) + ); + + expect(res.statusCode).toBe(204); + + // Confirm gone + const getRes = await server.inject( + authed({ method: 'GET', url: `/api/v1/media-servers/${id}` }) + ); + expect(getRes.statusCode).toBe(404); + }); + + it('DELETE /api/v1/media-servers/:id returns 404 for missing', async () => { + const res = await server.inject( + authed({ method: 'DELETE', url: '/api/v1/media-servers/99999' }) + ); + + expect(res.statusCode).toBe(404); + }); + }); + + // ── Validation ── + + describe('validation', () => { + it('rejects POST with missing required fields', async () => { + const res = await server.inject( + authed({ + method: 'POST', + url: '/api/v1/media-servers', + payload: { name: 'Missing fields' }, + }) + ); + + expect(res.statusCode).toBe(400); + }); + + it('rejects POST with invalid type', async () => { + const res = await server.inject( + authed({ + method: 'POST', + url: '/api/v1/media-servers', + payload: { ...plexBody, type: 'emby' }, + }) + ); + + expect(res.statusCode).toBe(400); + }); + + it('rejects non-numeric ID param', async () => { + const res = await server.inject( + authed({ method: 'GET', url: '/api/v1/media-servers/abc' }) + ); + + expect(res.statusCode).toBe(400); + }); + }); + + // ── Action endpoints ── + + describe('actions', () => { + it('POST /api/v1/media-servers/:id/test returns 404 for missing server', async () => { + const res = await server.inject( + authed({ method: 'POST', url: '/api/v1/media-servers/99999/test' }) + ); + + expect(res.statusCode).toBe(404); + }); + + it('GET /api/v1/media-servers/:id/sections returns 404 for missing server', async () => { + const res = await server.inject( + authed({ method: 'GET', url: '/api/v1/media-servers/99999/sections' }) + ); + + expect(res.statusCode).toBe(404); + }); + + it('POST /api/v1/media-servers/:id/test calls testConnection on a real server record', async () => { + // Create a server (the test will fail to actually connect, but verifies the route works) + const createRes = await server.inject( + authed({ + method: 'POST', + url: '/api/v1/media-servers', + payload: { ...plexBody, name: 'Test-Connection Target', url: 'http://127.0.0.1:1' }, + }) + ); + const id = createRes.json().id; + + const res = await server.inject( + authed({ method: 'POST', url: `/api/v1/media-servers/${id}/test` }) + ); + + expect(res.statusCode).toBe(200); + const body = res.json(); + // Should return a structured result (will fail since no server is running) + expect(body).toHaveProperty('success'); + expect(body).toHaveProperty('message'); + expect(body.success).toBe(false); + }); + + it('GET /api/v1/media-servers/:id/sections returns sections array', async () => { + // Create a jellyfin server (listLibrarySections returns [] for jellyfin) + const createRes = await server.inject( + authed({ + method: 'POST', + url: '/api/v1/media-servers', + payload: { ...jellyfinBody, name: 'Sections-Test' }, + }) + ); + const id = createRes.json().id; + + const res = await server.inject( + authed({ method: 'GET', url: `/api/v1/media-servers/${id}/sections` }) + ); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([]); + }); + }); + + // ── Auth ── + + describe('auth', () => { + it('rejects requests without API key', async () => { + const res = await server.inject({ + method: 'GET', + url: '/api/v1/media-servers', + }); + + // Should be 401 or 403 depending on auth plugin behavior + expect(res.statusCode).toBeGreaterThanOrEqual(400); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 2b520f8..085ce76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,8 @@ import { DownloadEventBus } from './services/event-bus'; import { QueueService } from './services/queue'; import { NotificationService } from './services/notification'; import { HealthService } from './services/health'; +import { MediaServerService } from './services/media-server'; +import { getEnabledMediaServers } from './db/repositories/media-server-repository'; import { PlatformRegistry } from './sources/platform-source'; import { YouTubeSource } from './sources/youtube'; import { SoundCloudSource } from './sources/soundcloud'; @@ -165,6 +167,39 @@ async function main(): Promise { ); (server as { healthService: HealthService | null }).healthService = healthService; + // 5c. Wire automatic media-server scans on download completion + const mediaServerService = new MediaServerService(); + eventBus.onDownload('download:complete', (payload) => { + getEnabledMediaServers(db) + .then(async (servers) => { + if (servers.length === 0) return; + console.log( + `[media-server] download complete contentItemId=${payload.contentItemId} — triggering ${servers.length} server scan(s)` + ); + const results = await Promise.allSettled( + servers.map((s) => mediaServerService.triggerScan(s)) + ); + for (let i = 0; i < results.length; i++) { + const r = results[i]; + const s = servers[i]; + if (r.status === 'fulfilled') { + if (r.value.success) { + console.log(`[media-server] scan ok server="${s.name}" msg="${r.value.message}"`); + } else { + console.log(`[media-server] scan failed server="${s.name}" msg="${r.value.message}"`); + } + } else { + console.log(`[media-server] scan error server="${s.name}" err="${r.reason}"`); + } + } + }) + .catch((err) => { + console.log( + `[media-server] failed to query enabled servers: ${err instanceof Error ? err.message : String(err)}` + ); + }); + }); + // 6. Graceful shutdown handler const shutdown = async (signal: string) => { console.log(`[${APP_NAME}] ${signal} received — shutting down gracefully...`); diff --git a/src/server/index.ts b/src/server/index.ts index 4824bb7..07f3ef9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -23,6 +23,7 @@ import { scanRoutes } from './routes/scan'; import { collectRoutes } from './routes/collect'; import { playlistRoutes } from './routes/playlist'; import { adhocDownloadRoutes } from './routes/adhoc-download'; +import { mediaServerRoutes } from './routes/media-server'; import { websocketRoutes } from './routes/websocket'; import type { SchedulerService } from '../services/scheduler'; import type { DownloadService } from '../services/download'; @@ -111,6 +112,7 @@ export async function buildServer(opts: BuildServerOptions): Promise(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 { + 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); + } + ); +}