Files
netfelix-audio-fix/server/api/scan.ts
Felix Förtsch 76d3b1acfb
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m50s
remove path mappings, add subtitle summary endpoint, cache setup page, bump version
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:02:26 +01:00

256 lines
11 KiB
TypeScript

import { Hono } from 'hono';
import { stream } from 'hono/streaming';
import { getDb, getConfig, setConfig, getAllConfig } from '../db/index';
import { getAllItems, getDevItems, extractOriginalLanguage, mapStream, normalizeLanguage } from '../services/jellyfin';
import { getOriginalLanguage as radarrLang } from '../services/radarr';
import { getOriginalLanguage as sonarrLang } from '../services/sonarr';
import { analyzeItem } from '../services/analyzer';
import type { MediaItem, MediaStream } from '../types';
import { log, warn, error as logError } from '../lib/log';
const app = new Hono();
// ─── State ────────────────────────────────────────────────────────────────────
let scanAbort: AbortController | null = null;
const scanListeners = new Set<(data: string) => void>();
function emitSse(type: string, data: unknown): void {
const line = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
for (const listener of scanListeners) listener(line);
}
function currentScanLimit(): number | null {
const v = getConfig('scan_limit');
return v ? Number(v) : null;
}
// ─── Status ───────────────────────────────────────────────────────────────────
app.get('/', (c) => {
const db = getDb();
const running = getConfig('scan_running') === '1';
const total = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n;
const scanned = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }).n;
const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number }).n;
const recentItems = db.prepare(
'SELECT name, type, scan_status, file_path FROM media_items ORDER BY last_scanned_at DESC LIMIT 50'
).all() as { name: string; type: string; scan_status: string; file_path: string }[];
return c.json({ running, progress: { scanned, total, errors }, recentItems, scanLimit: currentScanLimit() });
});
// ─── Start ────────────────────────────────────────────────────────────────────
app.post('/start', async (c) => {
if (getConfig('scan_running') === '1') {
return c.json({ ok: false, error: 'Scan already running' }, 409);
}
const body = await c.req.json<{ limit?: number }>().catch(() => ({}));
const formLimit = body.limit ?? null;
const envLimit = process.env.SCAN_LIMIT ? Number(process.env.SCAN_LIMIT) : null;
const limit = formLimit ?? envLimit ?? null;
setConfig('scan_limit', limit != null ? String(limit) : '');
setConfig('scan_running', '1');
runScan(limit).catch((err) => {
logError('Scan failed:', err);
setConfig('scan_running', '0');
emitSse('error', { message: String(err) });
});
return c.json({ ok: true });
});
// ─── Stop ─────────────────────────────────────────────────────────────────────
app.post('/stop', (c) => {
scanAbort?.abort();
setConfig('scan_running', '0');
return c.json({ ok: true });
});
// ─── SSE ──────────────────────────────────────────────────────────────────────
app.get('/events', (c) => {
return stream(c, async (s) => {
c.header('Content-Type', 'text/event-stream');
c.header('Cache-Control', 'no-cache');
c.header('Connection', 'keep-alive');
const queue: string[] = [];
let resolve: (() => void) | null = null;
const listener = (data: string) => {
queue.push(data);
resolve?.();
};
scanListeners.add(listener);
s.onAbort(() => { scanListeners.delete(listener); });
try {
while (!s.closed) {
if (queue.length > 0) {
await s.write(queue.shift()!);
} else {
await new Promise<void>((res) => {
resolve = res;
setTimeout(res, 25_000);
});
resolve = null;
if (queue.length === 0) await s.write(': keepalive\n\n');
}
}
} finally {
scanListeners.delete(listener);
}
});
});
// ─── Core scan logic ──────────────────────────────────────────────────────────
async function runScan(limit: number | null = null): Promise<void> {
log(`Scan started${limit ? ` (limit: ${limit})` : ''}`);
scanAbort = new AbortController();
const { signal } = scanAbort;
const isDev = process.env.NODE_ENV === 'development';
const db = getDb();
if (isDev) {
db.prepare('DELETE FROM stream_decisions').run();
db.prepare('DELETE FROM review_plans').run();
db.prepare('DELETE FROM media_streams').run();
db.prepare('DELETE FROM media_items').run();
}
const cfg = getAllConfig();
const jellyfinCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
const subtitleLanguages: string[] = JSON.parse(cfg.subtitle_languages ?? '["eng","deu","spa"]');
const audioLanguages: string[] = JSON.parse(cfg.audio_languages ?? '[]');
const radarrEnabled = cfg.radarr_enabled === '1';
const sonarrEnabled = cfg.sonarr_enabled === '1';
let processed = 0;
let errors = 0;
let total = 0;
const upsertItem = db.prepare(`
INSERT INTO media_items (
jellyfin_id, type, name, series_name, series_jellyfin_id,
season_number, episode_number, year, file_path, file_size, container,
original_language, orig_lang_source, needs_review,
imdb_id, tmdb_id, tvdb_id,
scan_status, last_scanned_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scanned', datetime('now'))
ON CONFLICT(jellyfin_id) DO UPDATE SET
type = excluded.type, name = excluded.name, series_name = excluded.series_name,
series_jellyfin_id = excluded.series_jellyfin_id, season_number = excluded.season_number,
episode_number = excluded.episode_number, year = excluded.year, file_path = excluded.file_path,
file_size = excluded.file_size, container = excluded.container,
original_language = excluded.original_language, orig_lang_source = excluded.orig_lang_source,
needs_review = excluded.needs_review, imdb_id = excluded.imdb_id,
tmdb_id = excluded.tmdb_id, tvdb_id = excluded.tvdb_id,
scan_status = 'scanned', last_scanned_at = datetime('now')
`);
const deleteStreams = db.prepare('DELETE FROM media_streams WHERE item_id = ?');
const insertStream = db.prepare(`
INSERT INTO media_streams (
item_id, stream_index, type, codec, language, language_display,
title, is_default, is_forced, is_hearing_impaired,
channels, channel_layout, bit_rate, sample_rate
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const upsertPlan = db.prepare(`
INSERT INTO review_plans (item_id, status, is_noop, notes)
VALUES (?, 'pending', ?, ?)
ON CONFLICT(item_id) DO UPDATE SET is_noop = excluded.is_noop, notes = excluded.notes
`);
const upsertDecision = db.prepare(`
INSERT INTO stream_decisions (plan_id, stream_id, action, target_index)
VALUES (?, ?, ?, ?)
ON CONFLICT(plan_id, stream_id) DO UPDATE SET action = excluded.action, target_index = excluded.target_index
`);
const getItemByJellyfinId = db.prepare('SELECT id FROM media_items WHERE jellyfin_id = ?');
const getPlanByItemId = db.prepare('SELECT id FROM review_plans WHERE item_id = ?');
const getStreamsByItemId = db.prepare('SELECT * FROM media_streams WHERE item_id = ?');
const itemSource = isDev
? getDevItems(jellyfinCfg)
: getAllItems(jellyfinCfg, (_fetched, jellyfinTotal) => {
total = limit != null ? Math.min(limit, jellyfinTotal) : jellyfinTotal;
});
for await (const jellyfinItem of itemSource) {
if (signal.aborted) break;
if (!isDev && limit != null && processed >= limit) break;
if (!jellyfinItem.Name || !jellyfinItem.Path) {
warn(`Skipping item without name/path: id=${jellyfinItem.Id}`);
continue;
}
processed++;
emitSse('progress', { scanned: processed, total, current_item: jellyfinItem.Name, errors, running: true });
try {
const providerIds = jellyfinItem.ProviderIds ?? {};
const imdbId = providerIds['Imdb'] ?? null;
const tmdbId = providerIds['Tmdb'] ?? null;
const tvdbId = providerIds['Tvdb'] ?? null;
let origLang: string | null = extractOriginalLanguage(jellyfinItem);
let origLangSource = 'jellyfin';
let needsReview = origLang ? 0 : 1;
if (jellyfinItem.Type === 'Movie' && radarrEnabled && (tmdbId || imdbId)) {
const lang = await radarrLang({ url: cfg.radarr_url, apiKey: cfg.radarr_api_key }, { tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined });
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'radarr'; }
}
if (jellyfinItem.Type === 'Episode' && sonarrEnabled && tvdbId) {
const lang = await sonarrLang({ url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key }, tvdbId);
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'sonarr'; }
}
upsertItem.run(
jellyfinItem.Id, jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie',
jellyfinItem.Name, jellyfinItem.SeriesName ?? null, jellyfinItem.SeriesId ?? null,
jellyfinItem.ParentIndexNumber ?? null, jellyfinItem.IndexNumber ?? null,
jellyfinItem.ProductionYear ?? null, jellyfinItem.Path, jellyfinItem.Size ?? null,
jellyfinItem.Container ?? null, origLang, origLangSource, needsReview,
imdbId, tmdbId, tvdbId
);
const itemRow = getItemByJellyfinId.get(jellyfinItem.Id) as { id: number };
const itemId = itemRow.id;
deleteStreams.run(itemId);
for (const jStream of jellyfinItem.MediaStreams ?? []) {
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
const s = mapStream(jStream);
insertStream.run(itemId, s.stream_index, s.type, s.codec, s.language, s.language_display, s.title, s.is_default, s.is_forced, s.is_hearing_impaired, s.channels, s.channel_layout, s.bit_rate, s.sample_rate);
}
const streams = getStreamsByItemId.all(itemId) as MediaStream[];
const analysis = analyzeItem({ original_language: origLang, needs_review: needsReview }, streams, { subtitleLanguages, audioLanguages });
upsertPlan.run(itemId, analysis.is_noop ? 1 : 0, analysis.notes);
const planRow = getPlanByItemId.get(itemId) as { id: number };
for (const dec of analysis.decisions) upsertDecision.run(planRow.id, dec.stream_id, dec.action, dec.target_index);
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'scanned', file: jellyfinItem.Path });
} catch (err) {
errors++;
logError(`Error scanning ${jellyfinItem.Name}:`, err);
try { db.prepare("UPDATE media_items SET scan_status = 'error', scan_error = ? WHERE jellyfin_id = ?").run(String(err), jellyfinItem.Id); } catch { /* ignore */ }
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'error', file: jellyfinItem.Path });
}
}
setConfig('scan_running', '0');
log(`Scan complete: ${processed} scanned, ${errors} errors`);
emitSse('complete', { scanned: processed, total, errors });
}
export default app;