restructure to react spa + hono api, fix missing server/ and lib/
rewrite from monolithic hono jsx to react 19 spa with tanstack router + hono json api backend. add scan, review, execute, nodes, and setup pages. multi-stage dockerfile (node for vite build, bun for runtime). previously, server/ and src/shared/lib/ were silently excluded by global gitignore patterns (/server/ from emacs, lib/ from python). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
244
server/services/jellyfin.ts
Normal file
244
server/services/jellyfin.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from '../types';
|
||||
|
||||
export interface JellyfinConfig {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
/** Optional: when omitted the server-level /Items endpoint is used (requires admin API key). */
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/** Build the base items URL: user-scoped when userId is set, server-level otherwise. */
|
||||
function itemsBaseUrl(cfg: JellyfinConfig): string {
|
||||
return cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items` : `${cfg.url}/Items`;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
function headers(apiKey: string): Record<string, string> {
|
||||
return {
|
||||
'X-Emby-Token': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
export async function testConnection(cfg: JellyfinConfig): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await fetch(`${cfg.url}/Users`, {
|
||||
headers: headers(cfg.apiKey),
|
||||
});
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUsers(cfg: Pick<JellyfinConfig, 'url' | 'apiKey'>): Promise<JellyfinUser[]> {
|
||||
const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`);
|
||||
return res.json() as Promise<JellyfinUser[]>;
|
||||
}
|
||||
|
||||
const ITEM_FIELDS = [
|
||||
'MediaStreams',
|
||||
'Path',
|
||||
'ProviderIds',
|
||||
'OriginalTitle',
|
||||
'ProductionYear',
|
||||
'Size',
|
||||
'Container',
|
||||
].join(',');
|
||||
|
||||
export async function* getAllItems(
|
||||
cfg: JellyfinConfig,
|
||||
onProgress?: (count: number, total: number) => void
|
||||
): AsyncGenerator<JellyfinItem> {
|
||||
let startIndex = 0;
|
||||
let total = 0;
|
||||
|
||||
do {
|
||||
const url = new URL(itemsBaseUrl(cfg));
|
||||
url.searchParams.set('Recursive', 'true');
|
||||
url.searchParams.set('IncludeItemTypes', 'Movie,Episode');
|
||||
url.searchParams.set('Fields', ITEM_FIELDS);
|
||||
url.searchParams.set('Limit', String(PAGE_SIZE));
|
||||
url.searchParams.set('StartIndex', String(startIndex));
|
||||
|
||||
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) throw new Error(`Jellyfin items failed: ${res.status}`);
|
||||
|
||||
const body = (await res.json()) as { Items: JellyfinItem[]; TotalRecordCount: number };
|
||||
total = body.TotalRecordCount;
|
||||
|
||||
for (const item of body.Items) {
|
||||
yield item;
|
||||
}
|
||||
|
||||
startIndex += body.Items.length;
|
||||
onProgress?.(startIndex, total);
|
||||
} while (startIndex < total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev mode: yields 50 random movies + all episodes from 10 random series.
|
||||
* Used instead of getAllItems() when NODE_ENV=development.
|
||||
*/
|
||||
export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator<JellyfinItem> {
|
||||
// 50 random movies
|
||||
const movieUrl = new URL(itemsBaseUrl(cfg));
|
||||
movieUrl.searchParams.set('Recursive', 'true');
|
||||
movieUrl.searchParams.set('IncludeItemTypes', 'Movie');
|
||||
movieUrl.searchParams.set('SortBy', 'Random');
|
||||
movieUrl.searchParams.set('Limit', '50');
|
||||
movieUrl.searchParams.set('Fields', ITEM_FIELDS);
|
||||
|
||||
const movieRes = await fetch(movieUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!movieRes.ok) throw new Error(`Jellyfin movies failed: HTTP ${movieRes.status} — check JELLYFIN_URL and JELLYFIN_API_KEY`);
|
||||
const movieBody = (await movieRes.json()) as { Items: JellyfinItem[] };
|
||||
for (const item of movieBody.Items) yield item;
|
||||
|
||||
// 10 random series → yield all their episodes
|
||||
const seriesUrl = new URL(itemsBaseUrl(cfg));
|
||||
seriesUrl.searchParams.set('Recursive', 'true');
|
||||
seriesUrl.searchParams.set('IncludeItemTypes', 'Series');
|
||||
seriesUrl.searchParams.set('SortBy', 'Random');
|
||||
seriesUrl.searchParams.set('Limit', '10');
|
||||
|
||||
const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`);
|
||||
const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> };
|
||||
for (const series of seriesBody.Items) {
|
||||
const epUrl = new URL(itemsBaseUrl(cfg));
|
||||
epUrl.searchParams.set('ParentId', series.Id);
|
||||
epUrl.searchParams.set('Recursive', 'true');
|
||||
epUrl.searchParams.set('IncludeItemTypes', 'Episode');
|
||||
epUrl.searchParams.set('Fields', ITEM_FIELDS);
|
||||
|
||||
const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (epRes.ok) {
|
||||
const epBody = (await epRes.json()) as { Items: JellyfinItem[] };
|
||||
for (const ep of epBody.Items) yield ep;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch a single Jellyfin item by its ID (for per-file rescan). */
|
||||
export async function getItem(cfg: JellyfinConfig, jellyfinId: string): Promise<JellyfinItem | null> {
|
||||
const base = cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items/${jellyfinId}` : `${cfg.url}/Items/${jellyfinId}`;
|
||||
const url = new URL(base);
|
||||
url.searchParams.set('Fields', ITEM_FIELDS);
|
||||
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<JellyfinItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a Jellyfin metadata refresh for a single item and wait until it completes.
|
||||
* Polls DateLastRefreshed until it changes (or timeout is reached).
|
||||
*/
|
||||
export async function refreshItem(cfg: JellyfinConfig, jellyfinId: string, timeoutMs = 15000): Promise<void> {
|
||||
const itemUrl = `${cfg.url}/Items/${jellyfinId}`;
|
||||
|
||||
// 1. Snapshot current DateLastRefreshed
|
||||
const beforeRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
|
||||
if (!beforeRes.ok) throw new Error(`Jellyfin item fetch failed: HTTP ${beforeRes.status}`);
|
||||
const before = (await beforeRes.json()) as { DateLastRefreshed?: string };
|
||||
const beforeDate = before.DateLastRefreshed;
|
||||
|
||||
// 2. Trigger refresh (returns 204 immediately; refresh runs async)
|
||||
const refreshUrl = new URL(`${itemUrl}/Refresh`);
|
||||
refreshUrl.searchParams.set('MetadataRefreshMode', 'FullRefresh');
|
||||
refreshUrl.searchParams.set('ImageRefreshMode', 'None');
|
||||
refreshUrl.searchParams.set('ReplaceAllMetadata', 'false');
|
||||
refreshUrl.searchParams.set('ReplaceAllImages', 'false');
|
||||
const refreshRes = await fetch(refreshUrl.toString(), { method: 'POST', headers: headers(cfg.apiKey) });
|
||||
if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`);
|
||||
|
||||
// 3. Poll until DateLastRefreshed changes
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const checkRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
|
||||
if (!checkRes.ok) continue;
|
||||
const check = (await checkRes.json()) as { DateLastRefreshed?: string };
|
||||
if (check.DateLastRefreshed && check.DateLastRefreshed !== beforeDate) return;
|
||||
}
|
||||
// Timeout reached — proceed anyway (refresh may still complete in background)
|
||||
}
|
||||
|
||||
/** Map a Jellyfin item to our normalized language code (ISO 639-2). */
|
||||
export function extractOriginalLanguage(item: JellyfinItem): string | null {
|
||||
// Jellyfin doesn't have a direct "original_language" field like TMDb.
|
||||
// The best proxy is the language of the first audio stream.
|
||||
if (!item.MediaStreams) return null;
|
||||
const firstAudio = item.MediaStreams.find((s) => s.Type === 'Audio');
|
||||
return firstAudio?.Language ? normalizeLanguage(firstAudio.Language) : null;
|
||||
}
|
||||
|
||||
/** Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id). */
|
||||
export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, 'id' | 'item_id'> {
|
||||
return {
|
||||
stream_index: s.Index,
|
||||
type: s.Type as MediaStream['type'],
|
||||
codec: s.Codec ?? null,
|
||||
language: s.Language ? normalizeLanguage(s.Language) : null,
|
||||
language_display: s.DisplayLanguage ?? null,
|
||||
title: s.Title ?? null,
|
||||
is_default: s.IsDefault ? 1 : 0,
|
||||
is_forced: s.IsForced ? 1 : 0,
|
||||
is_hearing_impaired: s.IsHearingImpaired ? 1 : 0,
|
||||
channels: s.Channels ?? null,
|
||||
channel_layout: s.ChannelLayout ?? null,
|
||||
bit_rate: s.BitRate ?? null,
|
||||
sample_rate: s.SampleRate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ISO 639-2/T → ISO 639-2/B normalization + common aliases
|
||||
const LANG_ALIASES: Record<string, string> = {
|
||||
// German: both /T (deu) and /B (ger) → deu
|
||||
ger: 'deu',
|
||||
// Chinese
|
||||
chi: 'zho',
|
||||
// French
|
||||
fre: 'fra',
|
||||
// Dutch
|
||||
dut: 'nld',
|
||||
// Modern Greek
|
||||
gre: 'ell',
|
||||
// Hebrew
|
||||
heb: 'heb',
|
||||
// Farsi
|
||||
per: 'fas',
|
||||
// Romanian
|
||||
rum: 'ron',
|
||||
// Malay
|
||||
may: 'msa',
|
||||
// Tibetan
|
||||
tib: 'bod',
|
||||
// Burmese
|
||||
bur: 'mya',
|
||||
// Czech
|
||||
cze: 'ces',
|
||||
// Slovak
|
||||
slo: 'slk',
|
||||
// Georgian
|
||||
geo: 'kat',
|
||||
// Icelandic
|
||||
ice: 'isl',
|
||||
// Armenian
|
||||
arm: 'hye',
|
||||
// Basque
|
||||
baq: 'eus',
|
||||
// Albanian
|
||||
alb: 'sqi',
|
||||
// Macedonian
|
||||
mac: 'mkd',
|
||||
// Welsh
|
||||
wel: 'cym',
|
||||
};
|
||||
|
||||
export function normalizeLanguage(lang: string): string {
|
||||
const lower = lang.toLowerCase().trim();
|
||||
return LANG_ALIASES[lower] ?? lower;
|
||||
}
|
||||
Reference in New Issue
Block a user