split scheduling into scan + process windows, move controls to settings page
Some checks failed
Build and Push Docker Image / build (push) Failing after 8s
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:
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user