test: Built stateless MediaServerService with scan triggering, connecti…

- "src/services/media-server.ts"
- "src/__tests__/media-server.test.ts"

GSD-Task: S04/T02
This commit is contained in:
jlightner 2026-04-04 05:53:30 +00:00
parent 6aa7e21b90
commit 73c232a845
2 changed files with 604 additions and 0 deletions

View file

@ -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> = {}): 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> = {}): 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<typeof vi.fn>;
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');
});
});
});

View file

@ -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<ScanResult> {
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<ConnectionTestResult> {
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<PlexLibrarySection[]> {
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<ScanResult> {
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<ConnectionTestResult> {
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<PlexLibrarySection[]> {
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<ScanResult> {
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<ConnectionTestResult> {
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)}`,
};
}
}
}