From 73c232a8458e636e14a9bd87820e0c181070b40d Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 05:53:30 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Built=20stateless=20MediaServerService?= =?UTF-8?q?=20with=20scan=20triggering,=20connecti=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/services/media-server.ts" - "src/__tests__/media-server.test.ts" GSD-Task: S04/T02 --- src/__tests__/media-server.test.ts | 337 +++++++++++++++++++++++++++++ src/services/media-server.ts | 267 +++++++++++++++++++++++ 2 files changed, 604 insertions(+) create mode 100644 src/__tests__/media-server.test.ts create mode 100644 src/services/media-server.ts diff --git a/src/__tests__/media-server.test.ts b/src/__tests__/media-server.test.ts new file mode 100644 index 0000000..eb88bd0 --- /dev/null +++ b/src/__tests__/media-server.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { MediaServerService } from '../services/media-server'; +import type { MediaServer } from '../types/index'; + +// ── Fixtures ── + +function makePlexServer(overrides: Partial = {}): MediaServer { + return { + id: 1, + name: 'My Plex', + type: 'plex', + url: 'http://plex.local:32400', + token: 'abc123', + librarySection: '1', + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeJellyfinServer(overrides: Partial = {}): MediaServer { + return { + id: 2, + name: 'My Jellyfin', + type: 'jellyfin', + url: 'http://jellyfin.local:8096', + token: 'jf-token-456', + librarySection: null, + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +// ── Tests ── + +describe('MediaServerService', () => { + let service: MediaServerService; + let mockFetch: ReturnType; + + beforeEach(() => { + service = new MediaServerService(); + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ── Plex scan ── + + describe('plexScan', () => { + it('sends GET to /library/sections/{id}/refresh with token', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200 }); + + const result = await service.triggerScan(makePlexServer()); + + expect(result.success).toBe(true); + expect(result.message).toContain('section 1'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toBe( + 'http://plex.local:32400/library/sections/1/refresh?X-Plex-Token=abc123' + ); + expect(opts.method).toBe('GET'); + }); + + it('returns failure when librarySection is missing', async () => { + const result = await service.triggerScan( + makePlexServer({ librarySection: null }) + ); + + expect(result.success).toBe(false); + expect(result.message).toContain('requires a library section'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns failure on HTTP error', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' }); + + const result = await service.triggerScan(makePlexServer()); + + expect(result.success).toBe(false); + expect(result.message).toContain('401'); + }); + + it('returns failure on network error', async () => { + mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await service.triggerScan(makePlexServer()); + + expect(result.success).toBe(false); + expect(result.message).toContain('ECONNREFUSED'); + }); + + it('strips trailing slashes from URL', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200 }); + + await service.triggerScan(makePlexServer({ url: 'http://plex.local:32400///' })); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toMatch(/^http:\/\/plex\.local:32400\/library\/sections\//); + }); + + it('encodes special characters in section ID and token', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200 }); + + await service.triggerScan( + makePlexServer({ librarySection: 'a/b', token: 'tok&en=1' }) + ); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('a%2Fb'); + expect(url).toContain('tok%26en%3D1'); + }); + }); + + // ── Jellyfin scan ── + + describe('jellyfinScan', () => { + it('sends POST to /Library/Refresh with X-Emby-Token header', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + + const result = await service.triggerScan(makeJellyfinServer()); + + expect(result.success).toBe(true); + expect(result.message).toContain('Jellyfin'); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toBe('http://jellyfin.local:8096/Library/Refresh'); + expect(opts.method).toBe('POST'); + expect(opts.headers['X-Emby-Token']).toBe('jf-token-456'); + }); + + it('returns failure on HTTP error', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 403, statusText: 'Forbidden' }); + + const result = await service.triggerScan(makeJellyfinServer()); + + expect(result.success).toBe(false); + expect(result.message).toContain('403'); + }); + + it('returns failure on network error', async () => { + mockFetch.mockRejectedValue(new Error('timeout')); + + const result = await service.triggerScan(makeJellyfinServer()); + + expect(result.success).toBe(false); + expect(result.message).toContain('timeout'); + }); + }); + + // ── Plex testConnection ── + + describe('testConnection (Plex)', () => { + it('validates via /identity and returns server name', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + MediaContainer: { friendlyName: 'Living Room Plex' }, + }), + }); + + const result = await service.testConnection(makePlexServer()); + + expect(result.success).toBe(true); + expect(result.serverName).toBe('Living Room Plex'); + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('/identity'); + expect(url).toContain('X-Plex-Token=abc123'); + }); + + it('returns failure on 401', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' }); + + const result = await service.testConnection(makePlexServer()); + + expect(result.success).toBe(false); + expect(result.message).toContain('401'); + }); + + it('returns failure on network error', async () => { + mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await service.testConnection(makePlexServer()); + + expect(result.success).toBe(false); + expect(result.message).toContain('ECONNREFUSED'); + }); + + it('succeeds without friendlyName', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ MediaContainer: {} }), + }); + + const result = await service.testConnection(makePlexServer()); + + expect(result.success).toBe(true); + expect(result.serverName).toBeUndefined(); + }); + }); + + // ── Jellyfin testConnection ── + + describe('testConnection (Jellyfin)', () => { + it('validates via /System/Info and returns server name', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ServerName: 'Bedroom Jellyfin' }), + }); + + const result = await service.testConnection(makeJellyfinServer()); + + expect(result.success).toBe(true); + expect(result.serverName).toBe('Bedroom Jellyfin'); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toContain('/System/Info'); + expect(opts.headers['X-Emby-Token']).toBe('jf-token-456'); + }); + + it('returns failure on 401', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' }); + + const result = await service.testConnection(makeJellyfinServer()); + + expect(result.success).toBe(false); + expect(result.message).toContain('401'); + }); + + it('returns failure on network error', async () => { + mockFetch.mockRejectedValue(new Error('ETIMEDOUT')); + + const result = await service.testConnection(makeJellyfinServer()); + + expect(result.success).toBe(false); + expect(result.message).toContain('ETIMEDOUT'); + }); + }); + + // ── listLibrarySections ── + + describe('listLibrarySections', () => { + it('returns Plex sections from /library/sections', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + MediaContainer: { + Directory: [ + { key: '1', title: 'Movies', type: 'movie' }, + { key: '2', title: 'TV Shows', type: 'show' }, + { key: '3', title: 'Music', type: 'artist' }, + ], + }, + }), + }); + + const sections = await service.listLibrarySections(makePlexServer()); + + expect(sections).toHaveLength(3); + expect(sections[0]).toEqual({ key: '1', title: 'Movies', type: 'movie' }); + expect(sections[1]).toEqual({ key: '2', title: 'TV Shows', type: 'show' }); + }); + + it('returns empty array for Jellyfin', async () => { + const sections = await service.listLibrarySections(makeJellyfinServer()); + + expect(sections).toEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns empty array on HTTP error', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal' }); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const sections = await service.listLibrarySections(makePlexServer()); + + expect(sections).toEqual([]); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[media-server]') + ); + consoleSpy.mockRestore(); + }); + + it('returns empty array on network error', async () => { + mockFetch.mockRejectedValue(new Error('DNS lookup failed')); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const sections = await service.listLibrarySections(makePlexServer()); + + expect(sections).toEqual([]); + consoleSpy.mockRestore(); + }); + + it('returns empty array when Directory is missing', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ MediaContainer: {} }), + }); + + const sections = await service.listLibrarySections(makePlexServer()); + + expect(sections).toEqual([]); + }); + }); + + // ── triggerScan dispatch ── + + describe('triggerScan dispatch', () => { + it('routes to plexScan for plex type', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200 }); + + await service.triggerScan(makePlexServer()); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('/library/sections/'); + }); + + it('routes to jellyfinScan for jellyfin type', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + + await service.triggerScan(makeJellyfinServer()); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('/Library/Refresh'); + }); + }); +}); diff --git a/src/services/media-server.ts b/src/services/media-server.ts new file mode 100644 index 0000000..311f9d0 --- /dev/null +++ b/src/services/media-server.ts @@ -0,0 +1,267 @@ +import type { MediaServer, MediaServerType } from '../types/index'; + +// ── Types ── + +/** A library section returned by Plex's /library/sections endpoint. */ +export interface PlexLibrarySection { + key: string; + title: string; + type: string; +} + +/** Result from a connection test. */ +export interface ConnectionTestResult { + success: boolean; + message: string; + serverName?: string; +} + +/** Result from a library scan trigger. */ +export interface ScanResult { + success: boolean; + message: string; +} + +// ── Constants ── + +const REQUEST_TIMEOUT_MS = 15_000; + +// ── Helpers ── + +/** Strip trailing slashes from a URL. */ +function normalizeUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +/** Build an AbortSignal that fires after the given ms. */ +function timeoutSignal(ms: number = REQUEST_TIMEOUT_MS): AbortSignal { + return AbortSignal.timeout(ms); +} + +// ── MediaServerService ── + +/** + * Handles communication with Plex and Jellyfin media servers. + * Stateless — each method takes a MediaServer config object. + * All network errors are caught and returned as structured results, never thrown. + */ +export class MediaServerService { + /** + * Trigger a library scan on the given media server. + * For Plex: refreshes a specific library section (requires librarySection). + * For Jellyfin: triggers a full library refresh. + */ + async triggerScan(server: MediaServer): Promise { + if (server.type === 'plex') { + return this.plexScan(server); + } + return this.jellyfinScan(server); + } + + /** + * Test whether the server is reachable and the token is valid. + */ + async testConnection(server: MediaServer): Promise { + if (server.type === 'plex') { + return this.testPlexConnection(server); + } + return this.testJellyfinConnection(server); + } + + /** + * List available library sections. Currently only supported for Plex. + * Returns an empty array for Jellyfin (no per-library scan targeting). + */ + async listLibrarySections(server: MediaServer): Promise { + if (server.type === 'plex') { + return this.plexListSections(server); + } + // Jellyfin refreshes all libraries at once; no section listing needed. + return []; + } + + // ── Plex ── + + private async plexScan(server: MediaServer): Promise { + if (!server.librarySection) { + return { + success: false, + message: 'Plex server requires a library section to scan', + }; + } + + const base = normalizeUrl(server.url); + const url = `${base}/library/sections/${encodeURIComponent(server.librarySection)}/refresh?X-Plex-Token=${encodeURIComponent(server.token)}`; + + try { + const res = await fetch(url, { + method: 'GET', + signal: timeoutSignal(), + }); + + if (res.ok) { + return { + success: true, + message: `Plex scan triggered for section ${server.librarySection}`, + }; + } + + return { + success: false, + message: `Plex scan failed: HTTP ${res.status} ${res.statusText}`, + }; + } catch (err) { + return { + success: false, + message: `Plex scan error: ${err instanceof Error ? err.message : String(err)}`, + }; + } + } + + private async testPlexConnection(server: MediaServer): Promise { + const base = normalizeUrl(server.url); + const url = `${base}/identity?X-Plex-Token=${encodeURIComponent(server.token)}`; + + try { + const res = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: timeoutSignal(), + }); + + if (!res.ok) { + return { + success: false, + message: `Connection failed: HTTP ${res.status} ${res.statusText}`, + }; + } + + // Plex /identity returns { MediaContainer: { machineIdentifier, version } } + const data = (await res.json()) as { + MediaContainer?: { friendlyName?: string }; + }; + const friendlyName = data?.MediaContainer?.friendlyName; + + return { + success: true, + message: 'Connection successful', + serverName: friendlyName ?? undefined, + }; + } catch (err) { + return { + success: false, + message: `Connection error: ${err instanceof Error ? err.message : String(err)}`, + }; + } + } + + private async plexListSections(server: MediaServer): Promise { + const base = normalizeUrl(server.url); + const url = `${base}/library/sections?X-Plex-Token=${encodeURIComponent(server.token)}`; + + try { + const res = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: timeoutSignal(), + }); + + if (!res.ok) { + console.log( + `[media-server] failed to list Plex sections: HTTP ${res.status} server="${server.name}"` + ); + return []; + } + + const data = (await res.json()) as { + MediaContainer?: { + Directory?: Array<{ key: string; title: string; type: string }>; + }; + }; + + const directories = data?.MediaContainer?.Directory; + if (!Array.isArray(directories)) return []; + + return directories.map((d) => ({ + key: d.key, + title: d.title, + type: d.type, + })); + } catch (err) { + console.log( + `[media-server] error listing Plex sections: ${err instanceof Error ? err.message : String(err)} server="${server.name}"` + ); + return []; + } + } + + // ── Jellyfin ── + + private async jellyfinScan(server: MediaServer): Promise { + const base = normalizeUrl(server.url); + const url = `${base}/Library/Refresh`; + + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'X-Emby-Token': server.token, + }, + signal: timeoutSignal(), + }); + + if (res.ok || res.status === 204) { + return { + success: true, + message: 'Jellyfin library refresh triggered', + }; + } + + return { + success: false, + message: `Jellyfin scan failed: HTTP ${res.status} ${res.statusText}`, + }; + } catch (err) { + return { + success: false, + message: `Jellyfin scan error: ${err instanceof Error ? err.message : String(err)}`, + }; + } + } + + private async testJellyfinConnection(server: MediaServer): Promise { + const base = normalizeUrl(server.url); + const url = `${base}/System/Info`; + + try { + const res = await fetch(url, { + method: 'GET', + headers: { + 'X-Emby-Token': server.token, + Accept: 'application/json', + }, + signal: timeoutSignal(), + }); + + if (!res.ok) { + return { + success: false, + message: `Connection failed: HTTP ${res.status} ${res.statusText}`, + }; + } + + const data = (await res.json()) as { ServerName?: string }; + + return { + success: true, + message: 'Connection successful', + serverName: data?.ServerName ?? undefined, + }; + } catch (err) { + return { + success: false, + message: `Connection error: ${err instanceof Error ? err.message : String(err)}`, + }; + } + } +}