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:
parent
6aa7e21b90
commit
73c232a845
2 changed files with 604 additions and 0 deletions
337
src/__tests__/media-server.test.ts
Normal file
337
src/__tests__/media-server.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
267
src/services/media-server.ts
Normal file
267
src/services/media-server.ts
Normal 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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue