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:
@@ -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",
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user