Compare commits
12 Commits
3f14b19195
...
7cefd9bf04
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cefd9bf04 | |||
| 3881f3a4c2 | |||
| 8bdfa79215 | |||
| fd72a6d212 | |||
| 9cffdaac47 | |||
| 9a19350f7e | |||
| 97e60dbfc5 | |||
| ecb0732185 | |||
| b1cf0fca38 | |||
| c2e5b70b02 | |||
| c017ca09d4 | |||
| 6507924e45 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ bun.lockb
|
||||
.env.production
|
||||
dist/
|
||||
src/routeTree.gen.ts
|
||||
.worktrees/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "netfelix-audio-fix",
|
||||
"version": "2026.03.05.8",
|
||||
"version": "2026.03.27",
|
||||
"scripts": {
|
||||
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
|
||||
"dev:client": "vite",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Job, Node, MediaItem, MediaStream } from '../types';
|
||||
import { predictExtractedFiles } from '../services/ffmpeg';
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
import { log, error as logError } from '../lib/log';
|
||||
import { getSchedulerState, updateSchedulerState } from '../services/scheduler';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -305,4 +306,28 @@ async function runJob(job: Job): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scheduler ────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /scheduler — current scheduler state
|
||||
app.get('/scheduler', (c) => {
|
||||
return c.json(getSchedulerState());
|
||||
});
|
||||
|
||||
// PATCH /scheduler — update scheduler settings
|
||||
app.patch('/scheduler', async (c) => {
|
||||
const body = await c.req.json();
|
||||
updateSchedulerState(body);
|
||||
return c.json(getSchedulerState());
|
||||
});
|
||||
|
||||
// ─── FFmpeg progress parsing ───────────────────────────────────────────────────
|
||||
|
||||
/** Parse FFmpeg stderr line for progress. Returns seconds processed or null. */
|
||||
export function parseFFmpegProgress(line: string): number | null {
|
||||
const match = line.match(/time=(\d+):(\d+):(\d+)\.(\d+)/);
|
||||
if (!match) return null;
|
||||
const [, h, m, s] = match.map(Number);
|
||||
return h * 3600 + m * 60 + s;
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -68,13 +68,14 @@ function reanalyze(db: ReturnType<typeof getDb>, itemId: number): void {
|
||||
|
||||
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
|
||||
const subtitleLanguages = getSubtitleLanguages();
|
||||
const analysis = analyzeItem({ original_language: item.original_language, needs_review: item.needs_review }, streams, { subtitleLanguages });
|
||||
const audioLanguages: string[] = JSON.parse(getConfig('audio_languages') ?? '[]');
|
||||
const analysis = analyzeItem({ original_language: item.original_language, needs_review: item.needs_review, container: item.container }, streams, { subtitleLanguages, audioLanguages });
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO review_plans (item_id, status, is_noop, notes)
|
||||
VALUES (?, 'pending', ?, ?)
|
||||
ON CONFLICT(item_id) DO UPDATE SET status = 'pending', is_noop = excluded.is_noop, notes = excluded.notes
|
||||
`).run(itemId, analysis.is_noop ? 1 : 0, analysis.notes);
|
||||
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
|
||||
VALUES (?, 'pending', ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(item_id) DO UPDATE SET status = 'pending', is_noop = excluded.is_noop, confidence = excluded.confidence, apple_compat = excluded.apple_compat, job_type = excluded.job_type, notes = excluded.notes
|
||||
`).run(itemId, analysis.is_noop ? 1 : 0, analysis.confidence, analysis.apple_compat, analysis.job_type, analysis.notes.length > 0 ? analysis.notes.join('\n') : null);
|
||||
|
||||
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number };
|
||||
const existingTitles = new Map<number, string | null>(
|
||||
@@ -83,8 +84,8 @@ function reanalyze(db: ReturnType<typeof getDb>, itemId: number): void {
|
||||
);
|
||||
db.prepare('DELETE FROM stream_decisions WHERE plan_id = ?').run(plan.id);
|
||||
for (const dec of analysis.decisions) {
|
||||
db.prepare('INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, custom_title) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(plan.id, dec.stream_id, dec.action, dec.target_index, existingTitles.get(dec.stream_id) ?? null);
|
||||
db.prepare('INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, custom_title, transcode_codec) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(plan.id, dec.stream_id, dec.action, dec.target_index, existingTitles.get(dec.stream_id) ?? null, dec.transcode_codec);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,4 +379,126 @@ app.post('/:id/rescan', async (c) => {
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// ─── Pipeline: approve up to here ────────────────────────────────────────────
|
||||
|
||||
app.post('/approve-up-to/:id', (c) => {
|
||||
const targetId = Number(c.req.param('id'));
|
||||
const db = getDb();
|
||||
|
||||
const target = db.prepare('SELECT id FROM review_plans WHERE id = ?').get(targetId) as { id: number } | undefined;
|
||||
if (!target) return c.json({ error: 'Plan not found' }, 404);
|
||||
|
||||
// Get all pending plans sorted by confidence (high first), then name
|
||||
const pendingPlans = db.prepare(`
|
||||
SELECT rp.id
|
||||
FROM review_plans rp
|
||||
JOIN media_items mi ON mi.id = rp.item_id
|
||||
WHERE rp.status = 'pending' AND rp.is_noop = 0
|
||||
ORDER BY
|
||||
CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END,
|
||||
COALESCE(mi.series_name, mi.name),
|
||||
mi.season_number,
|
||||
mi.episode_number,
|
||||
mi.name
|
||||
`).all() as { id: number }[];
|
||||
|
||||
// Find the target and approve everything up to and including it
|
||||
const toApprove: number[] = [];
|
||||
for (const plan of pendingPlans) {
|
||||
toApprove.push(plan.id);
|
||||
if (plan.id === targetId) break;
|
||||
}
|
||||
|
||||
// Batch approve and create jobs
|
||||
for (const planId of toApprove) {
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(planId);
|
||||
const planRow = db.prepare('SELECT item_id, job_type FROM review_plans WHERE id = ?').get(planId) as { item_id: number; job_type: string };
|
||||
const detail = loadItemDetail(db, planRow.item_id);
|
||||
if (detail.item && detail.command) {
|
||||
db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, ?, 'pending')")
|
||||
.run(planRow.item_id, detail.command, planRow.job_type);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ approved: toApprove.length });
|
||||
});
|
||||
|
||||
// ─── Pipeline: series language ───────────────────────────────────────────────
|
||||
|
||||
app.patch('/series/:seriesKey/language', async (c) => {
|
||||
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
|
||||
const { language } = await c.req.json<{ language: string }>();
|
||||
const db = getDb();
|
||||
|
||||
const items = db.prepare(
|
||||
'SELECT id FROM media_items WHERE series_jellyfin_id = ? OR (series_jellyfin_id IS NULL AND series_name = ?)'
|
||||
).all(seriesKey, seriesKey) as { id: number }[];
|
||||
|
||||
const normalizedLang = language ? normalizeLanguage(language) : null;
|
||||
for (const item of items) {
|
||||
db.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?")
|
||||
.run(normalizedLang, item.id);
|
||||
}
|
||||
|
||||
// Re-analyze all episodes
|
||||
for (const item of items) {
|
||||
reanalyze(db, item.id);
|
||||
}
|
||||
|
||||
return c.json({ updated: items.length });
|
||||
});
|
||||
|
||||
// ─── Pipeline: summary ───────────────────────────────────────────────────────
|
||||
|
||||
app.get('/pipeline', (c) => {
|
||||
const db = getDb();
|
||||
|
||||
const review = db.prepare(`
|
||||
SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id,
|
||||
mi.season_number, mi.episode_number, mi.type, mi.container,
|
||||
mi.original_language, mi.orig_lang_source, mi.file_path
|
||||
FROM review_plans rp
|
||||
JOIN media_items mi ON mi.id = rp.item_id
|
||||
WHERE rp.status = 'pending' AND rp.is_noop = 0
|
||||
ORDER BY
|
||||
CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END,
|
||||
COALESCE(mi.series_name, mi.name),
|
||||
mi.season_number, mi.episode_number
|
||||
`).all();
|
||||
|
||||
const queued = db.prepare(`
|
||||
SELECT j.*, mi.name, mi.series_name, mi.type,
|
||||
rp.job_type, rp.apple_compat
|
||||
FROM jobs j
|
||||
JOIN media_items mi ON mi.id = j.item_id
|
||||
JOIN review_plans rp ON rp.item_id = j.item_id
|
||||
WHERE j.status = 'pending'
|
||||
ORDER BY j.created_at
|
||||
`).all();
|
||||
|
||||
const processing = db.prepare(`
|
||||
SELECT j.*, mi.name, mi.series_name, mi.type,
|
||||
rp.job_type, rp.apple_compat
|
||||
FROM jobs j
|
||||
JOIN media_items mi ON mi.id = j.item_id
|
||||
JOIN review_plans rp ON rp.item_id = j.item_id
|
||||
WHERE j.status = 'running'
|
||||
`).all();
|
||||
|
||||
const done = db.prepare(`
|
||||
SELECT j.*, mi.name, mi.series_name, mi.type,
|
||||
rp.job_type, rp.apple_compat
|
||||
FROM jobs j
|
||||
JOIN media_items mi ON mi.id = j.item_id
|
||||
JOIN review_plans rp ON rp.item_id = j.item_id
|
||||
WHERE j.status IN ('done', 'error')
|
||||
ORDER BY j.completed_at DESC
|
||||
LIMIT 50
|
||||
`).all();
|
||||
|
||||
const noops = db.prepare('SELECT COUNT(*) as count FROM review_plans WHERE is_noop = 1').get() as { count: number };
|
||||
|
||||
return c.json({ review, queued, processing, done, noopCount: noops.count });
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -164,14 +164,20 @@ async function runScan(limit: number | null = null): Promise<void> {
|
||||
) 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
|
||||
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
|
||||
VALUES (?, 'pending', ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(item_id) DO UPDATE SET
|
||||
status = CASE WHEN review_plans.status IN ('done','error') THEN 'pending' ELSE review_plans.status END,
|
||||
is_noop = excluded.is_noop,
|
||||
confidence = excluded.confidence,
|
||||
apple_compat = excluded.apple_compat,
|
||||
job_type = excluded.job_type,
|
||||
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
|
||||
INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, transcode_codec)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(plan_id, stream_id) DO UPDATE SET action = excluded.action, target_index = excluded.target_index, transcode_codec = excluded.transcode_codec
|
||||
`);
|
||||
const getItemByJellyfinId = db.prepare('SELECT id FROM media_items WHERE jellyfin_id = ?');
|
||||
const getPlanByItemId = db.prepare('SELECT id FROM review_plans WHERE item_id = ?');
|
||||
@@ -213,6 +219,16 @@ async function runScan(limit: number | null = null): Promise<void> {
|
||||
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'sonarr'; }
|
||||
}
|
||||
|
||||
// Compute confidence from source agreement
|
||||
let confidence: 'high' | 'low' = 'low';
|
||||
if (!origLang) {
|
||||
confidence = 'low'; // unknown language
|
||||
} else if (needsReview) {
|
||||
confidence = 'low'; // sources disagree
|
||||
} else {
|
||||
confidence = 'high'; // language known, no conflicts
|
||||
}
|
||||
|
||||
upsertItem.run(
|
||||
jellyfinItem.Id, jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie',
|
||||
jellyfinItem.Name, jellyfinItem.SeriesName ?? null, jellyfinItem.SeriesId ?? null,
|
||||
@@ -233,10 +249,12 @@ async function runScan(limit: number | null = null): Promise<void> {
|
||||
}
|
||||
|
||||
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 analysis = analyzeItem({ original_language: origLang, needs_review: needsReview, container: jellyfinItem.Container ?? null }, streams, { subtitleLanguages, audioLanguages });
|
||||
// Override base confidence with scan-computed value
|
||||
const finalConfidence = confidence;
|
||||
upsertPlan.run(itemId, analysis.is_noop ? 1 : 0, finalConfidence, analysis.apple_compat, analysis.job_type, analysis.notes.length > 0 ? analysis.notes.join('\n') : null);
|
||||
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);
|
||||
for (const dec of analysis.decisions) upsertDecision.run(planRow.id, dec.stream_id, dec.action, dec.target_index, dec.transcode_codec);
|
||||
|
||||
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'scanned', file: jellyfinItem.Path });
|
||||
} catch (err) {
|
||||
|
||||
@@ -57,7 +57,13 @@ export function getDb(): Database {
|
||||
try { _db.exec("ALTER TABLE nodes ADD COLUMN movies_path TEXT NOT NULL DEFAULT ''"); } catch { /* already exists */ }
|
||||
try { _db.exec("ALTER TABLE nodes ADD COLUMN series_path TEXT NOT NULL DEFAULT ''"); } catch { /* already exists */ }
|
||||
try { _db.exec("ALTER TABLE jobs ADD COLUMN job_type TEXT NOT NULL DEFAULT 'audio'"); } catch { /* already exists */ }
|
||||
// Apple compat pipeline columns
|
||||
try { _db.exec("ALTER TABLE review_plans ADD COLUMN confidence TEXT NOT NULL DEFAULT 'low'"); } catch { /* already exists */ }
|
||||
try { _db.exec('ALTER TABLE review_plans ADD COLUMN apple_compat TEXT'); } catch { /* already exists */ }
|
||||
try { _db.exec("ALTER TABLE review_plans ADD COLUMN job_type TEXT NOT NULL DEFAULT 'copy'"); } catch { /* already exists */ }
|
||||
try { _db.exec('ALTER TABLE stream_decisions ADD COLUMN transcode_codec TEXT'); } catch { /* already exists */ }
|
||||
seedDefaults(_db);
|
||||
|
||||
return _db;
|
||||
}
|
||||
|
||||
|
||||
@@ -129,4 +129,8 @@ export const DEFAULT_CONFIG: Record<string, string> = {
|
||||
audio_languages: '[]',
|
||||
|
||||
scan_running: '0',
|
||||
job_sleep_seconds: '0',
|
||||
schedule_enabled: '0',
|
||||
schedule_start: '01:00',
|
||||
schedule_end: '07:00',
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MediaItem, MediaStream, PlanResult } from '../types';
|
||||
import { normalizeLanguage } from './jellyfin';
|
||||
import { transcodeTarget, computeAppleCompat } from './apple-compat';
|
||||
|
||||
export interface AnalyzerConfig {
|
||||
subtitleLanguages: string[];
|
||||
@@ -14,7 +15,7 @@ export interface AnalyzerConfig {
|
||||
* sidecar files). is_noop only considers audio changes.
|
||||
*/
|
||||
export function analyzeItem(
|
||||
item: Pick<MediaItem, 'original_language' | 'needs_review'>,
|
||||
item: Pick<MediaItem, 'original_language' | 'needs_review' | 'container'>,
|
||||
streams: MediaStream[],
|
||||
config: AnalyzerConfig
|
||||
): PlanResult {
|
||||
@@ -24,7 +25,7 @@ export function analyzeItem(
|
||||
// Compute action for each stream
|
||||
const decisions: PlanResult['decisions'] = streams.map((s) => {
|
||||
const action = decideAction(s, origLang, config.audioLanguages);
|
||||
return { stream_id: s.id, action, target_index: null };
|
||||
return { stream_id: s.id, action, target_index: null, transcode_codec: null };
|
||||
});
|
||||
|
||||
// Audio-only noop: only consider audio removals/reordering
|
||||
@@ -38,8 +39,33 @@ export function analyzeItem(
|
||||
// Check if audio ordering changes
|
||||
const audioOrderChanged = checkAudioOrderChanged(streams, decisions);
|
||||
|
||||
const isNoop = !anyAudioRemoved && !audioOrderChanged;
|
||||
// Step 3: Apple compatibility — compute transcode targets for kept audio
|
||||
for (const d of decisions) {
|
||||
if (d.action === 'keep') {
|
||||
const stream = streams.find(s => s.id === d.stream_id);
|
||||
if (stream && stream.type === 'Audio') {
|
||||
d.transcode_codec = transcodeTarget(
|
||||
stream.codec ?? '',
|
||||
stream.title,
|
||||
item.container,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const keptAudioCodecs = decisions
|
||||
.filter(d => d.action === 'keep')
|
||||
.map(d => streams.find(s => s.id === d.stream_id))
|
||||
.filter(s => s && s.type === 'Audio')
|
||||
.map(s => s!.codec ?? '');
|
||||
|
||||
const needsTranscode = decisions.some(d => d.transcode_codec != null);
|
||||
const apple_compat = computeAppleCompat(keptAudioCodecs, item.container);
|
||||
const job_type = needsTranscode ? 'transcode' as const : 'copy' as const;
|
||||
|
||||
const hasSubs = streams.some((s) => s.type === 'Subtitle');
|
||||
// Extended is_noop: no audio changes AND no subs to extract AND no transcode needed
|
||||
const is_noop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode;
|
||||
|
||||
// Generate notes for edge cases
|
||||
if (!origLang && item.needs_review) {
|
||||
@@ -47,10 +73,13 @@ export function analyzeItem(
|
||||
}
|
||||
|
||||
return {
|
||||
is_noop: isNoop,
|
||||
is_noop,
|
||||
has_subs: hasSubs,
|
||||
confidence: 'low',
|
||||
apple_compat,
|
||||
job_type,
|
||||
decisions,
|
||||
notes: notes.length > 0 ? notes.join('\n') : null,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
66
server/services/apple-compat.ts
Normal file
66
server/services/apple-compat.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// Codec sets and transcode target mapping for Apple device compatibility.
|
||||
// Apple natively decodes: AAC, AC3, EAC3, ALAC, FLAC, MP3, PCM, Opus
|
||||
// Everything else (DTS family, TrueHD family) needs transcoding.
|
||||
|
||||
const APPLE_COMPATIBLE_AUDIO = new Set([
|
||||
'aac', 'ac3', 'eac3', 'alac', 'flac', 'mp3',
|
||||
'pcm_s16le', 'pcm_s24le', 'pcm_s32le', 'pcm_f32le',
|
||||
'pcm_s16be', 'pcm_s24be', 'pcm_s32be', 'pcm_f64le',
|
||||
'opus',
|
||||
]);
|
||||
|
||||
// Codec strings Jellyfin may report for DTS variants
|
||||
const DTS_CODECS = new Set([
|
||||
'dts', 'dca',
|
||||
]);
|
||||
|
||||
const TRUEHD_CODECS = new Set([
|
||||
'truehd',
|
||||
]);
|
||||
|
||||
export function isAppleCompatible(codec: string): boolean {
|
||||
return APPLE_COMPATIBLE_AUDIO.has(codec.toLowerCase());
|
||||
}
|
||||
|
||||
/** Maps (codec, profile, container) → target codec for transcoding. */
|
||||
export function transcodeTarget(
|
||||
codec: string,
|
||||
profile: string | null,
|
||||
container: string | null,
|
||||
): string | null {
|
||||
const c = codec.toLowerCase();
|
||||
const isMkv = !container || container.toLowerCase() === 'mkv' || container.toLowerCase() === 'matroska';
|
||||
|
||||
if (isAppleCompatible(c)) return null; // no transcode needed
|
||||
|
||||
// DTS-HD MA and DTS:X are lossless → FLAC in MKV, EAC3 in MP4
|
||||
if (DTS_CODECS.has(c)) {
|
||||
const p = (profile ?? '').toLowerCase();
|
||||
const isLossless = p.includes('ma') || p.includes('hd ma') || p.includes('x');
|
||||
if (isLossless) return isMkv ? 'flac' : 'eac3';
|
||||
// Lossy DTS variants → EAC3
|
||||
return 'eac3';
|
||||
}
|
||||
|
||||
// TrueHD (including Atmos) → FLAC in MKV, EAC3 in MP4
|
||||
if (TRUEHD_CODECS.has(c)) {
|
||||
return isMkv ? 'flac' : 'eac3';
|
||||
}
|
||||
|
||||
// Any other incompatible codec → EAC3 as safe fallback
|
||||
return 'eac3';
|
||||
}
|
||||
|
||||
/** Determine overall Apple compatibility for a set of kept audio streams. */
|
||||
export function computeAppleCompat(
|
||||
keptAudioCodecs: string[],
|
||||
container: string | null,
|
||||
): 'direct_play' | 'remux' | 'audio_transcode' {
|
||||
const hasIncompatible = keptAudioCodecs.some(c => !isAppleCompatible(c));
|
||||
if (hasIncompatible) return 'audio_transcode';
|
||||
|
||||
const isMkv = !container || container.toLowerCase() === 'mkv' || container.toLowerCase() === 'matroska';
|
||||
if (isMkv) return 'remux';
|
||||
|
||||
return 'direct_play';
|
||||
}
|
||||
@@ -361,6 +361,110 @@ export function buildExtractOnlyCommand(
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single FFmpeg command that:
|
||||
* 1. Extracts subtitles to sidecar files
|
||||
* 2. Remuxes with reordered/filtered audio
|
||||
* 3. Transcodes incompatible audio codecs
|
||||
*/
|
||||
export function buildPipelineCommand(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[],
|
||||
decisions: (StreamDecision & { stream?: MediaStream })[]
|
||||
): { command: string; extractedFiles: Array<{ path: string; language: string | null; codec: string | null; is_forced: number; is_hearing_impaired: number }> } {
|
||||
const inputPath = item.file_path;
|
||||
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
|
||||
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
|
||||
const basePath = inputPath.replace(/\.[^.]+$/, '');
|
||||
|
||||
// --- Subtitle extraction outputs ---
|
||||
const extractionEntries = computeExtractionEntries(streams, basePath);
|
||||
const subOutputArgs: string[] = [];
|
||||
for (const e of extractionEntries) {
|
||||
subOutputArgs.push(`-map 0:s:${e.typeIdx}`, `-c:s ${e.codecArg}`, shellQuote(e.outPath));
|
||||
}
|
||||
|
||||
// --- Kept streams for remuxed output ---
|
||||
// Enrich decisions with stream data
|
||||
const enriched = decisions.map(d => {
|
||||
const stream = d.stream ?? streams.find(s => s.id === d.stream_id);
|
||||
return { ...d, stream: stream! };
|
||||
}).filter(d => d.action === 'keep' && d.stream);
|
||||
|
||||
// Sort by type priority then target_index
|
||||
const typeOrder: Record<string, number> = { Video: 0, Audio: 1, Data: 2, EmbeddedImage: 3 };
|
||||
enriched.sort((a, b) => {
|
||||
const ta = typeOrder[a.stream.type] ?? 9;
|
||||
const tb = typeOrder[b.stream.type] ?? 9;
|
||||
if (ta !== tb) return ta - tb;
|
||||
return (a.target_index ?? 0) - (b.target_index ?? 0);
|
||||
});
|
||||
|
||||
// Build -map flags
|
||||
const maps = buildMaps(streams, enriched.map(d => ({ stream: d.stream, dec: d })));
|
||||
|
||||
// Build per-stream codec flags
|
||||
const codecFlags: string[] = ['-c:v copy'];
|
||||
let audioIdx = 0;
|
||||
for (const d of enriched) {
|
||||
if (d.stream.type === 'Audio') {
|
||||
if (d.transcode_codec) {
|
||||
codecFlags.push(`-c:a:${audioIdx} ${d.transcode_codec}`);
|
||||
// For EAC3, set a reasonable bitrate based on channel count
|
||||
if (d.transcode_codec === 'eac3') {
|
||||
const bitrate = (d.stream.channels ?? 2) >= 6 ? '640k' : '256k';
|
||||
codecFlags.push(`-b:a:${audioIdx} ${bitrate}`);
|
||||
}
|
||||
} else {
|
||||
codecFlags.push(`-c:a:${audioIdx} copy`);
|
||||
}
|
||||
audioIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
// If no audio transcoding, simplify to -c copy (covers video + audio)
|
||||
const hasTranscode = enriched.some(d => d.transcode_codec);
|
||||
const finalCodecFlags = hasTranscode ? codecFlags : ['-c copy'];
|
||||
|
||||
// Disposition + metadata flags for audio
|
||||
const streamFlags = buildStreamFlags(enriched.map(d => ({ stream: d.stream, dec: d })));
|
||||
|
||||
// Assemble command
|
||||
const parts: string[] = [
|
||||
'ffmpeg', '-y',
|
||||
'-i', shellQuote(inputPath),
|
||||
];
|
||||
|
||||
// Subtitle extraction outputs first
|
||||
parts.push(...subOutputArgs);
|
||||
|
||||
// Map flags for remuxed output
|
||||
parts.push(...maps);
|
||||
|
||||
// Codec flags
|
||||
parts.push(...finalCodecFlags);
|
||||
|
||||
// Stream flags (disposition, metadata)
|
||||
parts.push(...streamFlags);
|
||||
|
||||
// Output file
|
||||
parts.push(shellQuote(tmpPath));
|
||||
|
||||
const command = parts.join(' ')
|
||||
+ ` && mv ${shellQuote(tmpPath)} ${shellQuote(inputPath)}`;
|
||||
|
||||
return {
|
||||
command,
|
||||
extractedFiles: extractionEntries.map(e => ({
|
||||
path: e.outPath,
|
||||
language: e.stream.language,
|
||||
codec: e.stream.codec,
|
||||
is_forced: e.stream.is_forced ? 1 : 0,
|
||||
is_hearing_impaired: e.stream.is_hearing_impaired ? 1 : 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Safely quote a path for shell usage. */
|
||||
export function shellQuote(s: string): string {
|
||||
return `'${s.replace(/'/g, "'\\''")}'`;
|
||||
|
||||
81
server/services/scheduler.ts
Normal file
81
server/services/scheduler.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { getConfig, setConfig } from '../db';
|
||||
|
||||
export interface SchedulerState {
|
||||
job_sleep_seconds: number;
|
||||
schedule_enabled: boolean;
|
||||
schedule_start: string; // "HH:MM"
|
||||
schedule_end: string; // "HH:MM"
|
||||
}
|
||||
|
||||
export function getSchedulerState(): SchedulerState {
|
||||
return {
|
||||
job_sleep_seconds: parseInt(getConfig('job_sleep_seconds') ?? '0', 10),
|
||||
schedule_enabled: getConfig('schedule_enabled') === '1',
|
||||
schedule_start: getConfig('schedule_start') ?? '01:00',
|
||||
schedule_end: getConfig('schedule_end') ?? '07:00',
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSchedulerState(updates: Partial<SchedulerState>): void {
|
||||
if (updates.job_sleep_seconds != null) setConfig('job_sleep_seconds', String(updates.job_sleep_seconds));
|
||||
if (updates.schedule_enabled != null) setConfig('schedule_enabled', updates.schedule_enabled ? '1' : '0');
|
||||
if (updates.schedule_start != null) setConfig('schedule_start', updates.schedule_start);
|
||||
if (updates.schedule_end != null) setConfig('schedule_end', updates.schedule_end);
|
||||
}
|
||||
|
||||
/** Check if current time is within the schedule window. */
|
||||
export function isInScheduleWindow(): boolean {
|
||||
const state = getSchedulerState();
|
||||
if (!state.schedule_enabled) return true; // no schedule = always allowed
|
||||
|
||||
const now = new Date();
|
||||
const minutes = now.getHours() * 60 + now.getMinutes();
|
||||
const start = parseTime(state.schedule_start);
|
||||
const end = parseTime(state.schedule_end);
|
||||
|
||||
// Handle overnight windows (e.g., 23:00 → 07:00)
|
||||
if (start <= end) {
|
||||
return minutes >= start && minutes < end;
|
||||
} else {
|
||||
return minutes >= start || minutes < end;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns milliseconds until the next schedule window opens. */
|
||||
export function msUntilWindow(): number {
|
||||
const state = getSchedulerState();
|
||||
const now = new Date();
|
||||
const minutes = now.getHours() * 60 + now.getMinutes();
|
||||
const start = parseTime(state.schedule_start);
|
||||
|
||||
if (minutes < start) {
|
||||
return (start - minutes) * 60_000;
|
||||
} else {
|
||||
// Next day
|
||||
return (24 * 60 - minutes + start) * 60_000;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the schedule_start time as "HH:MM" for display. */
|
||||
export function nextWindowTime(): string {
|
||||
return getSchedulerState().schedule_start;
|
||||
}
|
||||
|
||||
function parseTime(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
/** Sleep for the configured duration between jobs. */
|
||||
export function sleepBetweenJobs(): Promise<void> {
|
||||
const seconds = getSchedulerState().job_sleep_seconds;
|
||||
if (seconds <= 0) return Promise.resolve();
|
||||
return new Promise(resolve => setTimeout(resolve, seconds * 1000));
|
||||
}
|
||||
|
||||
/** Wait until the schedule window opens. Resolves immediately if already in window. */
|
||||
export function waitForWindow(): Promise<void> {
|
||||
if (isInScheduleWindow()) return Promise.resolve();
|
||||
const ms = msUntilWindow();
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -48,6 +48,9 @@ export interface ReviewPlan {
|
||||
item_id: number;
|
||||
status: 'pending' | 'approved' | 'skipped' | 'done' | 'error';
|
||||
is_noop: number;
|
||||
confidence: 'high' | 'low';
|
||||
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null;
|
||||
job_type: 'copy' | 'transcode';
|
||||
subs_extracted: number;
|
||||
notes: string | null;
|
||||
reviewed_at: string | null;
|
||||
@@ -73,13 +76,14 @@ export interface StreamDecision {
|
||||
action: 'keep' | 'remove';
|
||||
target_index: number | null;
|
||||
custom_title: string | null;
|
||||
transcode_codec: string | null;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: number;
|
||||
item_id: number;
|
||||
command: string;
|
||||
job_type: 'audio' | 'subtitle';
|
||||
job_type: 'copy' | 'transcode';
|
||||
node_id: number | null;
|
||||
status: 'pending' | 'running' | 'done' | 'error';
|
||||
output: string | null;
|
||||
@@ -115,8 +119,11 @@ export interface StreamWithDecision extends MediaStream {
|
||||
export interface PlanResult {
|
||||
is_noop: boolean;
|
||||
has_subs: boolean;
|
||||
decisions: Array<{ stream_id: number; action: 'keep' | 'remove'; target_index: number | null }>;
|
||||
notes: string | null;
|
||||
confidence: 'high' | 'low';
|
||||
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null;
|
||||
job_type: 'copy' | 'transcode';
|
||||
decisions: Array<{ stream_id: number; action: 'keep' | 'remove'; target_index: number | null; transcode_codec: string | null }>;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
// ─── Jellyfin API types ───────────────────────────────────────────────────────
|
||||
|
||||
28
src/features/pipeline/DoneColumn.tsx
Normal file
28
src/features/pipeline/DoneColumn.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
|
||||
interface DoneColumnProps {
|
||||
items: any[];
|
||||
}
|
||||
|
||||
export function DoneColumn({ items }: DoneColumnProps) {
|
||||
return (
|
||||
<div className="flex flex-col w-64 min-w-64 bg-gray-50 rounded-lg">
|
||||
<div className="px-3 py-2 border-b font-medium text-sm">
|
||||
Done <span className="text-gray-400">({items.length})</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{items.map((item: any) => (
|
||||
<div key={item.id} className="rounded border bg-white p-2">
|
||||
<p className="text-xs font-medium truncate">{item.name}</p>
|
||||
<Badge variant={item.status === 'done' ? 'done' : 'error'}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-8">No completed items</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/features/pipeline/PipelineCard.tsx
Normal file
59
src/features/pipeline/PipelineCard.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { LANG_NAMES, langName } from '~/shared/lib/lang';
|
||||
|
||||
interface PipelineCardProps {
|
||||
item: any;
|
||||
onLanguageChange?: (lang: string) => void;
|
||||
showApproveUpTo?: boolean;
|
||||
onApproveUpTo?: () => void;
|
||||
}
|
||||
|
||||
export function PipelineCard({ item, onLanguageChange, showApproveUpTo, onApproveUpTo }: PipelineCardProps) {
|
||||
const title = item.type === 'Episode'
|
||||
? `S${String(item.season_number).padStart(2, '0')}E${String(item.episode_number).padStart(2, '0')} — ${item.name}`
|
||||
: item.name;
|
||||
|
||||
const confidenceColor = item.confidence === 'high' ? 'bg-green-50 border-green-200' : 'bg-amber-50 border-amber-200';
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 ${confidenceColor}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{title}</p>
|
||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
{onLanguageChange ? (
|
||||
<select
|
||||
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white"
|
||||
value={item.original_language ?? ''}
|
||||
onChange={(e) => onLanguageChange(e.target.value)}
|
||||
>
|
||||
<option value="">unknown</option>
|
||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||
<option key={code} value={code}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Badge variant="default">{langName(item.original_language)}</Badge>
|
||||
)}
|
||||
|
||||
{item.apple_compat === 'audio_transcode' && (
|
||||
<Badge variant="manual">transcode</Badge>
|
||||
)}
|
||||
{item.job_type === 'copy' && item.apple_compat !== 'audio_transcode' && (
|
||||
<Badge variant="noop">copy</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showApproveUpTo && onApproveUpTo && (
|
||||
<button
|
||||
onClick={onApproveUpTo}
|
||||
className="mt-2 w-full text-xs py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
|
||||
>
|
||||
Approve up to here
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/features/pipeline/PipelinePage.tsx
Normal file
87
src/features/pipeline/PipelinePage.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { ReviewColumn } from './ReviewColumn';
|
||||
import { QueueColumn } from './QueueColumn';
|
||||
import { ProcessingColumn } from './ProcessingColumn';
|
||||
import { DoneColumn } from './DoneColumn';
|
||||
import { ScheduleControls } from './ScheduleControls';
|
||||
|
||||
interface PipelineData {
|
||||
review: any[];
|
||||
queued: any[];
|
||||
processing: any[];
|
||||
done: any[];
|
||||
noopCount: number;
|
||||
}
|
||||
|
||||
interface SchedulerState {
|
||||
job_sleep_seconds: number;
|
||||
schedule_enabled: boolean;
|
||||
schedule_start: string;
|
||||
schedule_end: string;
|
||||
}
|
||||
|
||||
interface Progress {
|
||||
id: number;
|
||||
seconds: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface QueueStatus {
|
||||
status: string;
|
||||
until?: string;
|
||||
seconds?: number;
|
||||
}
|
||||
|
||||
export function PipelinePage() {
|
||||
const [data, setData] = useState<PipelineData | null>(null);
|
||||
const [scheduler, setScheduler] = useState<SchedulerState | null>(null);
|
||||
const [progress, setProgress] = useState<Progress | null>(null);
|
||||
const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [pipelineRes, schedulerRes] = await Promise.all([
|
||||
api.get<PipelineData>('/api/review/pipeline'),
|
||||
api.get<SchedulerState>('/api/execute/scheduler'),
|
||||
]);
|
||||
setData(pipelineRes);
|
||||
setScheduler(schedulerRes);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// SSE for live updates
|
||||
useEffect(() => {
|
||||
const es = new EventSource('/api/execute/events');
|
||||
es.addEventListener('job_update', () => load());
|
||||
es.addEventListener('job_progress', (e) => {
|
||||
setProgress(JSON.parse((e as MessageEvent).data));
|
||||
});
|
||||
es.addEventListener('queue_status', (e) => {
|
||||
setQueueStatus(JSON.parse((e as MessageEvent).data));
|
||||
});
|
||||
return () => es.close();
|
||||
}, [load]);
|
||||
|
||||
if (loading || !data) return <div className="p-6 text-gray-500">Loading pipeline...</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)]">
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b">
|
||||
<h1 className="text-lg font-semibold">Pipeline</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">{data.noopCount} files already processed</span>
|
||||
{scheduler && <ScheduleControls scheduler={scheduler} onUpdate={load} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 gap-4 p-4 overflow-x-auto">
|
||||
<ReviewColumn items={data.review} onMutate={load} />
|
||||
<QueueColumn items={data.queued} />
|
||||
<ProcessingColumn items={data.processing} progress={progress} queueStatus={queueStatus} />
|
||||
<DoneColumn items={data.done} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/features/pipeline/ProcessingColumn.tsx
Normal file
62
src/features/pipeline/ProcessingColumn.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
|
||||
interface ProcessingColumnProps {
|
||||
items: any[];
|
||||
progress?: { id: number; seconds: number; total: number } | null;
|
||||
queueStatus?: { status: string; until?: string; seconds?: number } | null;
|
||||
}
|
||||
|
||||
export function ProcessingColumn({ items, progress, queueStatus }: ProcessingColumnProps) {
|
||||
const job = items[0]; // at most one running job
|
||||
|
||||
const formatTime = (s: number) => {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-72 min-w-72 bg-gray-50 rounded-lg">
|
||||
<div className="px-3 py-2 border-b font-medium text-sm">Processing</div>
|
||||
<div className="flex-1 p-3">
|
||||
{queueStatus && queueStatus.status !== 'running' && (
|
||||
<div className="mb-3 text-xs text-gray-500 bg-white rounded border p-2">
|
||||
{queueStatus.status === 'paused' && <>Paused until {queueStatus.until}</>}
|
||||
{queueStatus.status === 'sleeping' && <>Sleeping {queueStatus.seconds}s between jobs</>}
|
||||
{queueStatus.status === 'idle' && <>Idle</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job ? (
|
||||
<div className="rounded border bg-white p-3">
|
||||
<p className="text-sm font-medium truncate">{job.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="running">running</Badge>
|
||||
<Badge variant={job.job_type === 'transcode' ? 'manual' : 'noop'}>
|
||||
{job.job_type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{progress && progress.total > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>{formatTime(progress.seconds)}</span>
|
||||
<span>{Math.round((progress.seconds / progress.total) * 100)}%</span>
|
||||
<span>{formatTime(progress.total)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(100, (progress.seconds / progress.total) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 text-center py-8">No active job</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/features/pipeline/QueueColumn.tsx
Normal file
28
src/features/pipeline/QueueColumn.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
|
||||
interface QueueColumnProps {
|
||||
items: any[];
|
||||
}
|
||||
|
||||
export function QueueColumn({ items }: QueueColumnProps) {
|
||||
return (
|
||||
<div className="flex flex-col w-64 min-w-64 bg-gray-50 rounded-lg">
|
||||
<div className="px-3 py-2 border-b font-medium text-sm">
|
||||
Queued <span className="text-gray-400">({items.length})</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{items.map((item: any) => (
|
||||
<div key={item.id} className="rounded border bg-white p-2">
|
||||
<p className="text-xs font-medium truncate">{item.name}</p>
|
||||
<Badge variant={item.job_type === 'transcode' ? 'manual' : 'noop'}>
|
||||
{item.job_type}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-8">Queue empty</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/features/pipeline/ReviewColumn.tsx
Normal file
76
src/features/pipeline/ReviewColumn.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { PipelineCard } from './PipelineCard';
|
||||
import { SeriesCard } from './SeriesCard';
|
||||
|
||||
interface ReviewColumnProps {
|
||||
items: any[];
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
export function ReviewColumn({ items, onMutate }: ReviewColumnProps) {
|
||||
// Group by series (movies are standalone)
|
||||
const movies = items.filter((i: any) => i.type === 'Movie');
|
||||
const seriesMap = new Map<string, { name: string; key: string; episodes: any[] }>();
|
||||
|
||||
for (const item of items.filter((i: any) => i.type === 'Episode')) {
|
||||
const key = item.series_jellyfin_id ?? item.series_name;
|
||||
if (!seriesMap.has(key)) {
|
||||
seriesMap.set(key, { name: item.series_name, key, episodes: [] });
|
||||
}
|
||||
seriesMap.get(key)!.episodes.push(item);
|
||||
}
|
||||
|
||||
const approveUpTo = async (planId: number) => {
|
||||
await api.post(`/api/review/approve-up-to/${planId}`);
|
||||
onMutate();
|
||||
};
|
||||
|
||||
// Interleave movies and series, sorted by confidence (high first)
|
||||
const allItems = [
|
||||
...movies.map((m: any) => ({ type: 'movie' as const, item: m, sortKey: m.confidence === 'high' ? 0 : 1 })),
|
||||
...[...seriesMap.values()].map(s => ({
|
||||
type: 'series' as const,
|
||||
item: s,
|
||||
sortKey: s.episodes.every((e: any) => e.confidence === 'high') ? 0 : 1,
|
||||
})),
|
||||
].sort((a, b) => a.sortKey - b.sortKey);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-80 min-w-80 bg-gray-50 rounded-lg">
|
||||
<div className="px-3 py-2 border-b font-medium text-sm">
|
||||
Review <span className="text-gray-400">({items.length})</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
{allItems.map((entry) => {
|
||||
if (entry.type === 'movie') {
|
||||
return (
|
||||
<PipelineCard
|
||||
key={entry.item.id}
|
||||
item={entry.item}
|
||||
onLanguageChange={async (lang) => {
|
||||
await api.patch(`/api/review/${entry.item.item_id}/language`, { language: lang });
|
||||
onMutate();
|
||||
}}
|
||||
showApproveUpTo
|
||||
onApproveUpTo={() => approveUpTo(entry.item.id)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<SeriesCard
|
||||
key={entry.item.key}
|
||||
seriesKey={entry.item.key}
|
||||
seriesName={entry.item.name}
|
||||
episodes={entry.item.episodes}
|
||||
onMutate={onMutate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{allItems.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-8">No items to review</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/features/pipeline/ScheduleControls.tsx
Normal file
88
src/features/pipeline/ScheduleControls.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Input } from '~/shared/components/ui/input';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
|
||||
interface ScheduleControlsProps {
|
||||
scheduler: {
|
||||
job_sleep_seconds: number;
|
||||
schedule_enabled: boolean;
|
||||
schedule_start: string;
|
||||
schedule_end: string;
|
||||
};
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [state, setState] = useState(scheduler);
|
||||
|
||||
const save = async () => {
|
||||
await api.patch('/api/execute/scheduler', state);
|
||||
onUpdate();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const startAll = async () => {
|
||||
await api.post('/api/execute/start');
|
||||
onUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={startAll}>
|
||||
Start queue
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 cursor-pointer"
|
||||
>
|
||||
Schedule settings
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-10 z-50 bg-white border rounded-lg shadow-lg p-4 w-72">
|
||||
<h3 className="text-sm font-medium mb-3">Schedule Settings</h3>
|
||||
|
||||
<label className="block text-xs text-gray-600 mb-1">Sleep between jobs (seconds)</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={state.job_sleep_seconds}
|
||||
onChange={(e) => setState({ ...state, job_sleep_seconds: parseInt(e.target.value) || 0 })}
|
||||
className="mb-3"
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.schedule_enabled}
|
||||
onChange={(e) => setState({ ...state, schedule_enabled: e.target.checked })}
|
||||
/>
|
||||
Enable time window
|
||||
</label>
|
||||
|
||||
{state.schedule_enabled && (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Input
|
||||
type="time"
|
||||
value={state.schedule_start}
|
||||
onChange={(e) => setState({ ...state, schedule_start: e.target.value })}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">to</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={state.schedule_end}
|
||||
onChange={(e) => setState({ ...state, schedule_end: e.target.value })}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="primary" size="sm" onClick={save}>Save</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/features/pipeline/SeriesCard.tsx
Normal file
79
src/features/pipeline/SeriesCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { LANG_NAMES } from '~/shared/lib/lang';
|
||||
import { PipelineCard } from './PipelineCard';
|
||||
|
||||
interface SeriesCardProps {
|
||||
seriesKey: string;
|
||||
seriesName: string;
|
||||
episodes: any[];
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
export function SeriesCard({ seriesKey, seriesName, episodes, onMutate }: SeriesCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const seriesLang = episodes[0]?.original_language ?? '';
|
||||
|
||||
const setSeriesLanguage = async (lang: string) => {
|
||||
await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang });
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const approveSeries = async () => {
|
||||
await api.post(`/api/review/series/${encodeURIComponent(seriesKey)}/approve-all`);
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const highCount = episodes.filter((e: any) => e.confidence === 'high').length;
|
||||
const lowCount = episodes.filter((e: any) => e.confidence === 'low').length;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-gray-400">{expanded ? '▼' : '▶'}</span>
|
||||
<p className="text-sm font-medium truncate">{seriesName}</p>
|
||||
<span className="text-xs text-gray-500">{episodes.length} eps</span>
|
||||
{highCount > 0 && <span className="text-xs text-green-600">{highCount} ready</span>}
|
||||
{lowCount > 0 && <span className="text-xs text-amber-600">{lowCount} review</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<select
|
||||
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white"
|
||||
value={seriesLang}
|
||||
onChange={(e) => setSeriesLanguage(e.target.value)}
|
||||
>
|
||||
<option value="">unknown</option>
|
||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||
<option key={code} value={code}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={approveSeries}
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
Approve all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="border-t px-3 pb-3 space-y-2 pt-2">
|
||||
{episodes.map((ep: any) => (
|
||||
<PipelineCard
|
||||
key={ep.id}
|
||||
item={ep}
|
||||
onLanguageChange={async (lang) => {
|
||||
await api.patch(`/api/review/${ep.item_id}/language`, { language: lang });
|
||||
onMutate();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
@@ -29,6 +30,7 @@ export function ScanPage() {
|
||||
const [limit, setLimit] = useState('');
|
||||
const [log, setLog] = useState<LogEntry[]>([]);
|
||||
const [statusLabel, setStatusLabel] = useState('');
|
||||
const [scanComplete, setScanComplete] = useState(false);
|
||||
const [currentItem, setCurrentItem] = useState('');
|
||||
const [progressScanned, setProgressScanned] = useState(0);
|
||||
const [progressTotal, setProgressTotal] = useState(0);
|
||||
@@ -58,6 +60,7 @@ export function ScanPage() {
|
||||
const d = b.complete;
|
||||
b.complete = null;
|
||||
setStatusLabel(`Scan complete — ${d.scanned ?? '?'} items, ${d.errors ?? 0} errors`);
|
||||
setScanComplete(true);
|
||||
setStatus((prev) => prev ? { ...prev, running: false } : prev);
|
||||
stopFlushing();
|
||||
}
|
||||
@@ -150,6 +153,7 @@ export function ScanPage() {
|
||||
setErrors(0);
|
||||
setCurrentItem('');
|
||||
setStatusLabel('Scan in progress…');
|
||||
setScanComplete(false);
|
||||
setStatus((prev) => prev ? { ...prev, running: true } : prev);
|
||||
bufRef.current = freshBuf();
|
||||
|
||||
@@ -179,6 +183,11 @@ export function ScanPage() {
|
||||
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6">
|
||||
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||
<span className="text-sm font-medium">{statusLabel || (running ? 'Scan in progress…' : 'Scan idle')}</span>
|
||||
{scanComplete && (
|
||||
<Link to="/pipeline" className="text-blue-600 hover:underline text-sm">
|
||||
Review in Pipeline →
|
||||
</Link>
|
||||
)}
|
||||
{running ? (
|
||||
<Button variant="secondary" size="sm" onClick={stopScan}>Stop</Button>
|
||||
) : (
|
||||
|
||||
@@ -51,11 +51,8 @@ function RootLayout() {
|
||||
<VersionBadge />
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
<NavLink to="/scan">Scan</NavLink>
|
||||
<NavLink to="/paths">Paths</NavLink>
|
||||
<NavLink to="/review/audio">Audio</NavLink>
|
||||
<NavLink to="/review/subtitles/extract">ST Extract</NavLink>
|
||||
<NavLink to="/review/subtitles">ST Manager</NavLink>
|
||||
<NavLink to="/execute">Execute</NavLink>
|
||||
<NavLink to="/pipeline">Pipeline</NavLink>
|
||||
<NavLink to="/review/subtitles">Subtitles</NavLink>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
|
||||
6
src/routes/pipeline.tsx
Normal file
6
src/routes/pipeline.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { PipelinePage } from '~/features/pipeline/PipelinePage';
|
||||
|
||||
export const Route = createFileRoute('/pipeline')({
|
||||
component: PipelinePage,
|
||||
});
|
||||
@@ -46,6 +46,9 @@ export interface ReviewPlan {
|
||||
item_id: number;
|
||||
status: string;
|
||||
is_noop: number;
|
||||
confidence: 'high' | 'low';
|
||||
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null;
|
||||
job_type: 'copy' | 'transcode';
|
||||
subs_extracted: number;
|
||||
notes: string | null;
|
||||
reviewed_at: string | null;
|
||||
@@ -71,6 +74,7 @@ export interface StreamDecision {
|
||||
action: 'keep' | 'remove';
|
||||
target_index: number | null;
|
||||
custom_title: string | null;
|
||||
transcode_codec: string | null;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
@@ -78,7 +82,7 @@ export interface Job {
|
||||
item_id: number;
|
||||
node_id: number | null;
|
||||
command: string;
|
||||
job_type: 'audio' | 'subtitle';
|
||||
job_type: 'copy' | 'transcode';
|
||||
status: 'pending' | 'running' | 'done' | 'error';
|
||||
output: string | null;
|
||||
exit_code: number | null;
|
||||
|
||||
Reference in New Issue
Block a user