diff --git a/package.json b/package.json index f6a8211..4c1a84f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.13+1", + "version": "2026.04.13+2", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/server/api/execute.ts b/server/api/execute.ts index 7b34e1b..25770f4 100644 --- a/server/api/execute.ts +++ b/server/api/execute.ts @@ -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 { 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 { } } -// ─── 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 ─────────────────────────────────────────────────── diff --git a/server/api/scan.ts b/server/api/scan.ts index e7d28d7..d4ff264 100644 --- a/server/api/scan.ts +++ b/server/api/scan.ts @@ -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 { 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 }); diff --git a/server/api/settings.ts b/server/api/settings.ts index 4e328ba..e1588b1 100644 --- a/server/api/settings.ts +++ b/server/api/settings.ts @@ -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>(); + updateScheduleConfig(body); + return c.json(getScheduleConfig()); +}); + app.post("/clear-scan", (c) => { const db = getDb(); // Delete children first to avoid slow cascade deletes diff --git a/server/db/schema.ts b/server/db/schema.ts index d1d79b1..acf5ff5 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -138,7 +138,10 @@ export const DEFAULT_CONFIG: Record = { 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", }; diff --git a/server/services/scheduler.ts b/server/services/scheduler.ts index 4b89618..3404b93 100644 --- a/server/services/scheduler.ts +++ b/server/services/scheduler.ts @@ -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 = { + 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): void { +function writeWindow(kind: WindowKind, w: Partial): 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): 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 { + 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 { - 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 { - if (isInScheduleWindow()) return Promise.resolve(); - const ms = msUntilWindow(); - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/src/features/pipeline/PipelinePage.tsx b/src/features/pipeline/PipelinePage.tsx index cfaec9b..1d89e42 100644 --- a/src/features/pipeline/PipelinePage.tsx +++ b/src/features/pipeline/PipelinePage.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "~/shared/components/ui/button"; import { api } from "~/shared/lib/api"; import { DoneColumn } from "./DoneColumn"; import { ProcessingColumn } from "./ProcessingColumn"; import { QueueColumn } from "./QueueColumn"; import { ReviewColumn } from "./ReviewColumn"; -import { ScheduleControls } from "./ScheduleControls"; interface PipelineData { review: any[]; @@ -16,13 +16,6 @@ interface PipelineData { jellyfinUrl: string; } -interface SchedulerState { - job_sleep_seconds: number; - schedule_enabled: boolean; - schedule_start: string; - schedule_end: string; -} - interface Progress { id: number; seconds: number; @@ -37,21 +30,21 @@ interface QueueStatus { export function PipelinePage() { const [data, setData] = useState(null); - const [scheduler, setScheduler] = useState(null); const [progress, setProgress] = useState(null); const [queueStatus, setQueueStatus] = useState(null); const [loading, setLoading] = useState(true); const load = useCallback(async () => { - const [pipelineRes, schedulerRes] = await Promise.all([ - api.get("/api/review/pipeline"), - api.get("/api/execute/scheduler"), - ]); + const pipelineRes = await api.get("/api/review/pipeline"); setData(pipelineRes); - setScheduler(schedulerRes); setLoading(false); }, []); + const startQueue = useCallback(async () => { + await api.post("/api/execute/start"); + load(); + }, [load]); + useEffect(() => { load(); }, [load]); @@ -100,7 +93,9 @@ export function PipelinePage() {

Pipeline

{data.doneCount} files in desired state - {scheduler && } +
diff --git a/src/features/pipeline/ScheduleControls.tsx b/src/features/pipeline/ScheduleControls.tsx deleted file mode 100644 index 776cb23..0000000 --- a/src/features/pipeline/ScheduleControls.tsx +++ /dev/null @@ -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 ( -
- - - - {open && ( -
-

Schedule Settings

- - - setState({ ...state, job_sleep_seconds: parseInt(e.target.value, 10) || 0 })} - className="mb-3" - /> - - - - {state.schedule_enabled && ( -
- setState({ ...state, schedule_start: e.target.value })} - className="w-24" - /> - to - setState({ ...state, schedule_end: e.target.value })} - className="w-24" - /> -
- )} - - -
- )} -
- ); -} diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index bd1d7ba..d2a06a8 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -5,6 +5,18 @@ import { Select } from "~/shared/components/ui/select"; import { api } from "~/shared/lib/api"; 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 { config: Record; envLocked: string[]; @@ -238,6 +250,92 @@ function ConnSection({ ); } +// ─── Schedule section ───────────────────────────────────────────────────────── + +function WindowEditor({ + label, + window, + onChange, +}: { + label: string; + window: ScheduleWindow; + onChange: (w: ScheduleWindow) => void; +}) { + return ( +
+ + {window.enabled && ( +
+ onChange({ ...window, start: e.target.value })} + className="w-28" + /> + to + onChange({ ...window, end: e.target.value })} + className="w-28" + /> + (overnight ranges wrap midnight) +
+ )} +
+ ); +} + +function ScheduleSection() { + const [cfg, setCfg] = useState(null); + const [saved, setSaved] = useState(""); + + useEffect(() => { + api.get("/api/settings/schedule").then(setCfg); + }, []); + + const save = async () => { + if (!cfg) return; + const next = await api.patch("/api/settings/schedule", cfg); + setCfg(next); + setSaved("Saved."); + setTimeout(() => setSaved(""), 2000); + }; + + if (!cfg) return null; + + return ( + + setCfg({ ...cfg, scan })} /> + setCfg({ ...cfg, process })} /> + +
+ + {saved && {saved}} +
+
+ ); +} + // ─── Setup page ─────────────────────────────────────────────────────────────── export function SettingsPage() { @@ -352,6 +450,9 @@ export function SettingsPage() { onSave={saveSonarr} /> + {/* Schedule */} + + {/* Audio languages */}