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

@@ -8,13 +8,12 @@ import { getItem, refreshItem } from "../services/jellyfin";
import { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "../services/radarr";
import { upsertJellyfinItem } from "../services/rescan";
import {
getSchedulerState,
isInScheduleWindow,
msUntilWindow,
nextWindowTime,
getScheduleConfig,
isInProcessWindow,
msUntilProcessWindow,
nextProcessWindowTime,
sleepBetweenJobs,
updateSchedulerState,
waitForWindow,
waitForProcessWindow,
} from "../services/scheduler";
import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "../services/sonarr";
import type { Job, MediaItem, MediaStream } from "../types";
@@ -105,17 +104,20 @@ async function runSequential(jobs: Job[]): Promise<void> {
try {
let first = true;
for (const job of jobs) {
// Pause outside the scheduler window
if (!isInScheduleWindow()) {
emitQueueStatus("paused", { until: nextWindowTime(), seconds: Math.round(msUntilWindow() / 1000) });
await waitForWindow();
// Pause outside the processing window
if (!isInProcessWindow()) {
emitQueueStatus("paused", {
until: nextProcessWindowTime(),
seconds: Math.round(msUntilProcessWindow() / 1000),
});
await waitForProcessWindow();
}
// Sleep between jobs (but not before the first one)
if (!first) {
const state = getSchedulerState();
if (state.job_sleep_seconds > 0) {
emitQueueStatus("sleeping", { seconds: state.job_sleep_seconds });
const cfg = getScheduleConfig();
if (cfg.job_sleep_seconds > 0) {
emitQueueStatus("sleeping", { seconds: cfg.job_sleep_seconds });
await sleepBetweenJobs();
}
}
@@ -527,17 +529,7 @@ async function runJob(job: Job): Promise<void> {
}
}
// ─── Scheduler ────────────────────────────────────────────────────────────────
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());
});
// Scheduler endpoints live on /api/settings/schedule now — see server/api/settings.ts.
// ─── 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 { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "../services/radarr";
import { upsertJellyfinItem } from "../services/rescan";
import { isInScanWindow, msUntilScanWindow, nextScanWindowTime, waitForScanWindow } from "../services/scheduler";
import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "../services/sonarr";
const app = new Hono();
@@ -209,6 +210,19 @@ async function runScan(limit: number | null = null): Promise<void> {
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++;
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 { getUsers, testConnection as testJellyfin } from "../services/jellyfin";
import { testConnection as testRadarr } from "../services/radarr";
import { getScheduleConfig, type ScheduleConfig, updateScheduleConfig } from "../services/scheduler";
import { testConnection as testSonarr } from "../services/sonarr";
const app = new Hono();
@@ -96,6 +97,16 @@ app.post("/audio-languages", async (c) => {
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) => {
const db = getDb();
// Delete children first to avoid slow cascade deletes

View File

@@ -138,7 +138,10 @@ export const DEFAULT_CONFIG: Record<string, string> = {
scan_running: "0",
job_sleep_seconds: "0",
schedule_enabled: "0",
schedule_start: "01:00",
schedule_end: "07:00",
scan_schedule_enabled: "0",
scan_schedule_start: "01: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";
export interface SchedulerState {
job_sleep_seconds: number;
schedule_enabled: boolean;
schedule_start: string; // "HH:MM"
schedule_end: string; // "HH:MM"
export interface ScheduleWindow {
enabled: boolean;
start: string; // "HH:MM" — 24h
end: string; // "HH:MM" — 24h
}
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 {
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",
enabled: getConfig(`${kind}_schedule_enabled`) === "1",
start: getConfig(`${kind}_schedule_start`) ?? DEFAULTS[kind].start,
end: getConfig(`${kind}_schedule_end`) ?? DEFAULTS[kind].end,
};
}
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.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). 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;
if (updates.scan) writeWindow("scan", updates.scan);
if (updates.process) writeWindow("process", updates.process);
}
function parseTime(hhmm: string): number {
@@ -67,16 +52,45 @@ function parseTime(hhmm: string): number {
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. */
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();
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));
}