split scheduling into scan + process windows, move controls to settings page
Some checks failed
Build and Push Docker Image / build (push) Failing after 8s

the old one-window scheduler gated only the job queue. now the scan loop and
the processing queue have independent windows — useful when the container
runs as an always-on service and we only want to hammer jellyfin + ffmpeg
at night.

config keys renamed from schedule_* to scan_schedule_* / process_schedule_*,
plus the existing job_sleep_seconds. scheduler.ts exposes parallel helpers
(isInScanWindow / isInProcessWindow, waitForScanWindow / waitForProcessWindow)
so each caller picks its window without cross-contamination.

scan.ts checks the scan window between items and emits paused/resumed sse.
execute.ts keeps its per-job pause + sleep-between-jobs but now on the
process window. /api/execute/scheduler moved to /api/settings/schedule.

frontend: ScheduleControls popup deleted from the pipeline header, replaced
with a plain Start queue button. settings page grows a Schedule section with
both windows and the job sleep input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 14:50:25 +02:00
parent 6fcaeca82c
commit 23dca8bf0b
9 changed files with 234 additions and 191 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "netfelix-audio-fix", "name": "netfelix-audio-fix",
"version": "2026.04.13+1", "version": "2026.04.13+2",
"scripts": { "scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite", "dev:client": "vite",

View File

@@ -8,13 +8,12 @@ import { getItem, refreshItem } from "../services/jellyfin";
import { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "../services/radarr"; import { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "../services/radarr";
import { upsertJellyfinItem } from "../services/rescan"; import { upsertJellyfinItem } from "../services/rescan";
import { import {
getSchedulerState, getScheduleConfig,
isInScheduleWindow, isInProcessWindow,
msUntilWindow, msUntilProcessWindow,
nextWindowTime, nextProcessWindowTime,
sleepBetweenJobs, sleepBetweenJobs,
updateSchedulerState, waitForProcessWindow,
waitForWindow,
} from "../services/scheduler"; } from "../services/scheduler";
import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "../services/sonarr"; import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "../services/sonarr";
import type { Job, MediaItem, MediaStream } from "../types"; import type { Job, MediaItem, MediaStream } from "../types";
@@ -105,17 +104,20 @@ async function runSequential(jobs: Job[]): Promise<void> {
try { try {
let first = true; let first = true;
for (const job of jobs) { for (const job of jobs) {
// Pause outside the scheduler window // Pause outside the processing window
if (!isInScheduleWindow()) { if (!isInProcessWindow()) {
emitQueueStatus("paused", { until: nextWindowTime(), seconds: Math.round(msUntilWindow() / 1000) }); emitQueueStatus("paused", {
await waitForWindow(); until: nextProcessWindowTime(),
seconds: Math.round(msUntilProcessWindow() / 1000),
});
await waitForProcessWindow();
} }
// Sleep between jobs (but not before the first one) // Sleep between jobs (but not before the first one)
if (!first) { if (!first) {
const state = getSchedulerState(); const cfg = getScheduleConfig();
if (state.job_sleep_seconds > 0) { if (cfg.job_sleep_seconds > 0) {
emitQueueStatus("sleeping", { seconds: state.job_sleep_seconds }); emitQueueStatus("sleeping", { seconds: cfg.job_sleep_seconds });
await sleepBetweenJobs(); await sleepBetweenJobs();
} }
} }
@@ -527,17 +529,7 @@ async function runJob(job: Job): Promise<void> {
} }
} }
// ─── Scheduler ──────────────────────────────────────────────────────────────── // Scheduler endpoints live on /api/settings/schedule now — see server/api/settings.ts.
app.get("/scheduler", (c) => {
return c.json(getSchedulerState());
});
app.patch("/scheduler", async (c) => {
const body = await c.req.json();
updateSchedulerState(body);
return c.json(getSchedulerState());
});
// ─── FFmpeg progress parsing ─────────────────────────────────────────────────── // ─── FFmpeg progress parsing ───────────────────────────────────────────────────

View File

@@ -5,6 +5,7 @@ import { log, error as logError, warn } from "../lib/log";
import { getAllItems, getDevItems } from "../services/jellyfin"; import { getAllItems, getDevItems } from "../services/jellyfin";
import { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "../services/radarr"; import { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "../services/radarr";
import { upsertJellyfinItem } from "../services/rescan"; import { upsertJellyfinItem } from "../services/rescan";
import { isInScanWindow, msUntilScanWindow, nextScanWindowTime, waitForScanWindow } from "../services/scheduler";
import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "../services/sonarr"; import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "../services/sonarr";
const app = new Hono(); const app = new Hono();
@@ -209,6 +210,19 @@ async function runScan(limit: number | null = null): Promise<void> {
continue; continue;
} }
// Honour the scan window between items so overnight-only setups don't hog
// Jellyfin during the day. Checked between items rather than mid-item so
// we don't leave a partial upsert sitting in flight.
if (!isInScanWindow()) {
emitSse("paused", {
until: nextScanWindowTime(),
seconds: Math.round(msUntilScanWindow() / 1000),
});
await waitForScanWindow();
if (signal.aborted) break;
emitSse("resumed", {});
}
processed++; processed++;
emitSse("progress", { scanned: processed, total, current_item: jellyfinItem.Name, errors, running: true }); emitSse("progress", { scanned: processed, total, current_item: jellyfinItem.Name, errors, running: true });

View File

@@ -2,6 +2,7 @@ import { Hono } from "hono";
import { getAllConfig, getDb, getEnvLockedKeys, setConfig } from "../db/index"; import { getAllConfig, getDb, getEnvLockedKeys, setConfig } from "../db/index";
import { getUsers, testConnection as testJellyfin } from "../services/jellyfin"; import { getUsers, testConnection as testJellyfin } from "../services/jellyfin";
import { testConnection as testRadarr } from "../services/radarr"; import { testConnection as testRadarr } from "../services/radarr";
import { getScheduleConfig, type ScheduleConfig, updateScheduleConfig } from "../services/scheduler";
import { testConnection as testSonarr } from "../services/sonarr"; import { testConnection as testSonarr } from "../services/sonarr";
const app = new Hono(); const app = new Hono();
@@ -96,6 +97,16 @@ app.post("/audio-languages", async (c) => {
return c.json({ ok: true }); return c.json({ ok: true });
}); });
app.get("/schedule", (c) => {
return c.json(getScheduleConfig());
});
app.patch("/schedule", async (c) => {
const body = await c.req.json<Partial<ScheduleConfig>>();
updateScheduleConfig(body);
return c.json(getScheduleConfig());
});
app.post("/clear-scan", (c) => { app.post("/clear-scan", (c) => {
const db = getDb(); const db = getDb();
// Delete children first to avoid slow cascade deletes // Delete children first to avoid slow cascade deletes

View File

@@ -138,7 +138,10 @@ export const DEFAULT_CONFIG: Record<string, string> = {
scan_running: "0", scan_running: "0",
job_sleep_seconds: "0", job_sleep_seconds: "0",
schedule_enabled: "0", scan_schedule_enabled: "0",
schedule_start: "01:00", scan_schedule_start: "01:00",
schedule_end: "07:00", scan_schedule_end: "07:00",
process_schedule_enabled: "0",
process_schedule_start: "01:00",
process_schedule_end: "07:00",
}; };

View File

@@ -1,65 +1,50 @@
import { getConfig, setConfig } from "../db"; import { getConfig, setConfig } from "../db";
export interface SchedulerState { export interface ScheduleWindow {
job_sleep_seconds: number; enabled: boolean;
schedule_enabled: boolean; start: string; // "HH:MM" — 24h
schedule_start: string; // "HH:MM" end: string; // "HH:MM" — 24h
schedule_end: string; // "HH:MM"
} }
export function getSchedulerState(): SchedulerState { export interface ScheduleConfig {
job_sleep_seconds: number;
scan: ScheduleWindow;
process: ScheduleWindow;
}
type WindowKind = "scan" | "process";
const DEFAULTS: Record<WindowKind, ScheduleWindow> = {
scan: { enabled: false, start: "01:00", end: "07:00" },
process: { enabled: false, start: "01:00", end: "07:00" },
};
function readWindow(kind: WindowKind): ScheduleWindow {
return { return {
job_sleep_seconds: parseInt(getConfig("job_sleep_seconds") ?? "0", 10), enabled: getConfig(`${kind}_schedule_enabled`) === "1",
schedule_enabled: getConfig("schedule_enabled") === "1", start: getConfig(`${kind}_schedule_start`) ?? DEFAULTS[kind].start,
schedule_start: getConfig("schedule_start") ?? "01:00", end: getConfig(`${kind}_schedule_end`) ?? DEFAULTS[kind].end,
schedule_end: getConfig("schedule_end") ?? "07:00",
}; };
} }
export function updateSchedulerState(updates: Partial<SchedulerState>): void { function writeWindow(kind: WindowKind, w: Partial<ScheduleWindow>): void {
if (w.enabled != null) setConfig(`${kind}_schedule_enabled`, w.enabled ? "1" : "0");
if (w.start != null) setConfig(`${kind}_schedule_start`, w.start);
if (w.end != null) setConfig(`${kind}_schedule_end`, w.end);
}
export function getScheduleConfig(): ScheduleConfig {
return {
job_sleep_seconds: Number.parseInt(getConfig("job_sleep_seconds") ?? "0", 10),
scan: readWindow("scan"),
process: readWindow("process"),
};
}
export function updateScheduleConfig(updates: Partial<ScheduleConfig>): void {
if (updates.job_sleep_seconds != null) setConfig("job_sleep_seconds", String(updates.job_sleep_seconds)); 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.scan) writeWindow("scan", updates.scan);
if (updates.schedule_start != null) setConfig("schedule_start", updates.schedule_start); if (updates.process) writeWindow("process", updates.process);
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). End is inclusive so
// "07:00 → 07:00" spans a full day and the closing minute is covered.
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 { function parseTime(hhmm: string): number {
@@ -67,16 +52,45 @@ function parseTime(hhmm: string): number {
return h * 60 + m; return h * 60 + m;
} }
function isInWindow(w: ScheduleWindow): boolean {
if (!w.enabled) return true;
const now = new Date();
const minutes = now.getHours() * 60 + now.getMinutes();
const start = parseTime(w.start);
const end = parseTime(w.end);
// Overnight windows (e.g. 23:00 → 07:00) wrap midnight.
if (start <= end) return minutes >= start && minutes <= end;
return minutes >= start || minutes <= end;
}
function msUntilWindow(w: ScheduleWindow): number {
const now = new Date();
const minutes = now.getHours() * 60 + now.getMinutes();
const start = parseTime(w.start);
if (minutes < start) return (start - minutes) * 60_000;
return (24 * 60 - minutes + start) * 60_000;
}
function waitForWindow(w: ScheduleWindow): Promise<void> {
if (isInWindow(w)) return Promise.resolve();
return new Promise((resolve) => setTimeout(resolve, msUntilWindow(w)));
}
// ─── Scan window ─────────────────────────────────────────────────────────────
export const isInScanWindow = () => isInWindow(readWindow("scan"));
export const msUntilScanWindow = () => msUntilWindow(readWindow("scan"));
export const nextScanWindowTime = () => readWindow("scan").start;
export const waitForScanWindow = () => waitForWindow(readWindow("scan"));
// ─── Process window ──────────────────────────────────────────────────────────
export const isInProcessWindow = () => isInWindow(readWindow("process"));
export const msUntilProcessWindow = () => msUntilWindow(readWindow("process"));
export const nextProcessWindowTime = () => readWindow("process").start;
export const waitForProcessWindow = () => waitForWindow(readWindow("process"));
/** Sleep for the configured duration between jobs. */ /** Sleep for the configured duration between jobs. */
export function sleepBetweenJobs(): Promise<void> { export function sleepBetweenJobs(): Promise<void> {
const seconds = getSchedulerState().job_sleep_seconds; const seconds = Number.parseInt(getConfig("job_sleep_seconds") ?? "0", 10);
if (seconds <= 0) return Promise.resolve(); if (seconds <= 0) return Promise.resolve();
return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); 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));
}

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "~/shared/components/ui/button";
import { api } from "~/shared/lib/api"; import { api } from "~/shared/lib/api";
import { DoneColumn } from "./DoneColumn"; import { DoneColumn } from "./DoneColumn";
import { ProcessingColumn } from "./ProcessingColumn"; import { ProcessingColumn } from "./ProcessingColumn";
import { QueueColumn } from "./QueueColumn"; import { QueueColumn } from "./QueueColumn";
import { ReviewColumn } from "./ReviewColumn"; import { ReviewColumn } from "./ReviewColumn";
import { ScheduleControls } from "./ScheduleControls";
interface PipelineData { interface PipelineData {
review: any[]; review: any[];
@@ -16,13 +16,6 @@ interface PipelineData {
jellyfinUrl: string; jellyfinUrl: string;
} }
interface SchedulerState {
job_sleep_seconds: number;
schedule_enabled: boolean;
schedule_start: string;
schedule_end: string;
}
interface Progress { interface Progress {
id: number; id: number;
seconds: number; seconds: number;
@@ -37,21 +30,21 @@ interface QueueStatus {
export function PipelinePage() { export function PipelinePage() {
const [data, setData] = useState<PipelineData | null>(null); const [data, setData] = useState<PipelineData | null>(null);
const [scheduler, setScheduler] = useState<SchedulerState | null>(null);
const [progress, setProgress] = useState<Progress | null>(null); const [progress, setProgress] = useState<Progress | null>(null);
const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null); const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const load = useCallback(async () => { const load = useCallback(async () => {
const [pipelineRes, schedulerRes] = await Promise.all([ const pipelineRes = await api.get<PipelineData>("/api/review/pipeline");
api.get<PipelineData>("/api/review/pipeline"),
api.get<SchedulerState>("/api/execute/scheduler"),
]);
setData(pipelineRes); setData(pipelineRes);
setScheduler(schedulerRes);
setLoading(false); setLoading(false);
}, []); }, []);
const startQueue = useCallback(async () => {
await api.post("/api/execute/start");
load();
}, [load]);
useEffect(() => { useEffect(() => {
load(); load();
}, [load]); }, [load]);
@@ -100,7 +93,9 @@ export function PipelinePage() {
<h1 className="text-lg font-semibold">Pipeline</h1> <h1 className="text-lg font-semibold">Pipeline</h1>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm text-gray-500">{data.doneCount} files in desired state</span> <span className="text-sm text-gray-500">{data.doneCount} files in desired state</span>
{scheduler && <ScheduleControls scheduler={scheduler} onUpdate={load} />} <Button variant="primary" size="sm" onClick={startQueue}>
Start queue
</Button>
</div> </div>
</div> </div>
<div className="flex flex-1 gap-4 p-4 overflow-x-auto overflow-y-hidden min-h-0"> <div className="flex flex-1 gap-4 p-4 overflow-x-auto overflow-y-hidden min-h-0">

View File

@@ -1,87 +0,0 @@
import { useState } from "react";
import { Button } from "~/shared/components/ui/button";
import { Input } from "~/shared/components/ui/input";
import { api } from "~/shared/lib/api";
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, 10) || 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>
);
}

View File

@@ -5,6 +5,18 @@ import { Select } from "~/shared/components/ui/select";
import { api } from "~/shared/lib/api"; import { api } from "~/shared/lib/api";
import { LANG_NAMES } from "~/shared/lib/lang"; import { LANG_NAMES } from "~/shared/lib/lang";
interface ScheduleWindow {
enabled: boolean;
start: string;
end: string;
}
interface ScheduleConfig {
job_sleep_seconds: number;
scan: ScheduleWindow;
process: ScheduleWindow;
}
interface SettingsData { interface SettingsData {
config: Record<string, string>; config: Record<string, string>;
envLocked: string[]; envLocked: string[];
@@ -238,6 +250,92 @@ function ConnSection({
); );
} }
// ─── Schedule section ─────────────────────────────────────────────────────────
function WindowEditor({
label,
window,
onChange,
}: {
label: string;
window: ScheduleWindow;
onChange: (w: ScheduleWindow) => void;
}) {
return (
<div className="mb-3">
<label className="flex items-center gap-2 text-sm text-gray-700 mb-1.5">
<input
type="checkbox"
checked={window.enabled}
onChange={(e) => onChange({ ...window, enabled: e.target.checked })}
/>
{label}
</label>
{window.enabled && (
<div className="flex items-center gap-2 pl-5">
<Input
type="time"
value={window.start}
onChange={(e) => onChange({ ...window, start: e.target.value })}
className="w-28"
/>
<span className="text-xs text-gray-500">to</span>
<Input
type="time"
value={window.end}
onChange={(e) => onChange({ ...window, end: e.target.value })}
className="w-28"
/>
<span className="text-xs text-gray-500">(overnight ranges wrap midnight)</span>
</div>
)}
</div>
);
}
function ScheduleSection() {
const [cfg, setCfg] = useState<ScheduleConfig | null>(null);
const [saved, setSaved] = useState("");
useEffect(() => {
api.get<ScheduleConfig>("/api/settings/schedule").then(setCfg);
}, []);
const save = async () => {
if (!cfg) return;
const next = await api.patch<ScheduleConfig>("/api/settings/schedule", cfg);
setCfg(next);
setSaved("Saved.");
setTimeout(() => setSaved(""), 2000);
};
if (!cfg) return null;
return (
<SectionCard
title="Schedule"
subtitle="Restrict when the app is allowed to scan Jellyfin and process files. Useful when this container runs as an always-on service but you only want it to do real work at night."
>
<WindowEditor label="Scan window" window={cfg.scan} onChange={(scan) => setCfg({ ...cfg, scan })} />
<WindowEditor label="Processing window" window={cfg.process} onChange={(process) => setCfg({ ...cfg, process })} />
<label className="block text-sm text-gray-700 mt-4">
Sleep between jobs (seconds)
<Input
type="number"
min={0}
value={cfg.job_sleep_seconds}
onChange={(e) => setCfg({ ...cfg, job_sleep_seconds: Number.parseInt(e.target.value, 10) || 0 })}
className="mt-0.5 w-28"
/>
</label>
<div className="flex items-center gap-2 mt-3">
<Button onClick={save}>Save</Button>
{saved && <span className="text-green-700 text-sm">{saved}</span>}
</div>
</SectionCard>
);
}
// ─── Setup page ─────────────────────────────────────────────────────────────── // ─── Setup page ───────────────────────────────────────────────────────────────
export function SettingsPage() { export function SettingsPage() {
@@ -352,6 +450,9 @@ export function SettingsPage() {
onSave={saveSonarr} onSave={saveSonarr}
/> />
{/* Schedule */}
<ScheduleSection />
{/* Audio languages */} {/* Audio languages */}
<SectionCard <SectionCard
title="Audio Languages" title="Audio Languages"