Skip to content

Commit 6807240

Browse files
Per-channel sync state + Sync-now + next-run (live channels, plan steps 6-7)
Closes the gap between what shipped in v0.2.2 and the full new_goals.md plan (whose post-pseudocode sections were never seen until now): per-channel last-synced visibility, a per-channel re-run trigger, and a Dashboard "next run". Backend: - data/recipe_state.json: cosmetic per-channel sync metadata { "<num>": { checked_at, changed_at?, change_summary? } }. Written by the scheduler (atomic temp-swap) at the end of APPLY cycles only — checked_at for every live channel in the cycle, changed_at/change_summary only when that channel was actually patched; full cycles prune entries no longer live. This is NOT correctness state (the diff still reads live Tunarr) — it lives in its own file specifically so it never races with channels.json edits/deploys. This is the principled version of the plan's "write back to channels.json" step, without making the scheduler a channels.json writer. - run_cycle/_run_cycle_blocking gain an `only=<number>` filter to scope a cycle to a single live channel; POST /api/recipes/run?only=N exposes it. - get_status() adds next_run_seconds (seconds to next auto cycle, null if disabled/paused) and a channels map (recipe_state). last_auto_run switched from event-loop time to wall-clock so the sync status handler can compute next_run without touching the loop. Frontend: - Channels page: each live row shows "synced Xago" (from recipe_state); the edit modal gains a "Save & Sync now" button (live channels only) that saves then runs only=N&apply=true, applying the recipe in place without leaving the editor. Save logic refactored into a shared persist(). - Dashboard Auto-Updates card shows "next ~in Xh" from next_run_seconds. Verified locally in Docker against live Tunarr (all no-ops, no mutations): - status returns next_run_seconds (~12h) + channels map for all 4 live channels. - scoped run only=7 processes just #7; full apply writes recipe_state for all 4. - recipe_state.json written atomically; channels.json untouched. - frontend builds clean (tsc + vite). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 12169df commit 6807240

5 files changed

Lines changed: 171 additions & 30 deletions

File tree

backend/routers/recipes_router.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,14 @@ def recipes_status():
7878

7979

8080
@router.post("/recipes/run")
81-
async def recipes_run(apply: bool = Query(True)):
81+
async def recipes_run(apply: bool = Query(True), only: Optional[int] = Query(None)):
8282
"""Run one cycle on demand. apply=false is a dry run (detect + log, no patch).
83+
`only=N` limits the run to a single live channel (the per-channel "Sync now").
8384
8485
This is the manual-refresh / test trigger — it runs the exact same code path
8586
the background loop does, so you don't have to wait for the interval.
8687
"""
87-
return await scheduler.run_cycle(apply=apply)
88+
return await scheduler.run_cycle(apply=apply, only=only)
8889

8990

9091
@router.post("/recipes/pause")

backend/scheduler.py

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import json
2626
import os
2727
import sys
28+
import time
2829
from collections import Counter
2930
from datetime import datetime, timezone
3031
from pathlib import Path
@@ -48,9 +49,11 @@
4849
"paused": False,
4950
"running": False,
5051
"last_cycle": None, # summary dict of the most recent cycle
51-
"last_auto_run": None, # event-loop time of last automatic cycle (None = never)
52+
"last_auto_run": None, # wall-clock time.time() of last automatic cycle (None = never)
5253
}
5354

55+
STATE_FILE = "recipe_state.json" # cosmetic per-channel sync metadata (NOT correctness state)
56+
5457
DEFAULT_INTERVAL_HOURS = 12
5558

5659

@@ -80,6 +83,29 @@ def _live_channels(channels: list) -> list:
8083
return [ch for ch in channels if ch.get("live")]
8184

8285

86+
# ── Cosmetic per-channel sync state (recipe_state.json) ────────────────────────
87+
# Purely for the UI: last-synced timestamps + last change. NOT used by the diff —
88+
# correctness still reads live Tunarr. Kept in a separate file so it never races
89+
# with channels.json edits or deploys.
90+
91+
def _load_state() -> dict:
92+
try:
93+
with open(DATA_DIR / STATE_FILE) as f:
94+
return json.load(f)
95+
except Exception:
96+
return {}
97+
98+
99+
def _save_state(state: dict) -> None:
100+
try:
101+
tmp = DATA_DIR / (STATE_FILE + ".tmp")
102+
with open(tmp, "w") as f:
103+
json.dump(state, f, indent=2)
104+
tmp.replace(DATA_DIR / STATE_FILE) # atomic swap — no torn reads
105+
except Exception:
106+
pass # cosmetic state must never break a cycle
107+
108+
83109
# ── Diff helpers ───────────────────────────────────────────────────────────────
84110

85111
def _program_ids(resolved: list) -> set:
@@ -105,8 +131,13 @@ def _summarize(ids: set, id_label: dict) -> list:
105131

106132
# ── The cycle ──────────────────────────────────────────────────────────────────
107133

108-
def _run_cycle_blocking(apply: bool) -> dict:
109-
"""Synchronous body of one cycle. Runs in a worker thread."""
134+
def _run_cycle_blocking(apply: bool, only: int = None) -> dict:
135+
"""Synchronous body of one cycle. Runs in a worker thread.
136+
137+
`only` (a channel number) limits the cycle to a single live channel — used by
138+
the per-channel "Sync now" button. The full library index is still built once
139+
regardless, so this is about scope, not speed.
140+
"""
110141
started = datetime.now(timezone.utc)
111142
cfg = _load_config()
112143
tunarr_url = cfg.get("tunarr_url", "").rstrip("/")
@@ -128,6 +159,8 @@ def _run_cycle_blocking(apply: bool) -> dict:
128159
return summary
129160

130161
live = _live_channels(_load_channels())
162+
if only is not None:
163+
live = [ch for ch in live if ch.get("number") == only]
131164
summary["live"] = len(live)
132165
if not live:
133166
return summary
@@ -194,6 +227,29 @@ def _run_cycle_blocking(apply: bool) -> dict:
194227

195228
summary["changed"] = len(summary["changes"])
196229
_write_log(summary)
230+
231+
# Record cosmetic per-channel sync metadata (apply cycles only — a dry run
232+
# isn't a real "sync"). Carries forward prior change info for unchanged channels.
233+
if apply:
234+
state = _load_state()
235+
changed_by_num = {c["number"]: c for c in summary["changes"] if c.get("applied")}
236+
for ch in live:
237+
num = str(ch.get("number"))
238+
entry = state.get(num, {})
239+
entry["checked_at"] = summary["time"]
240+
c = changed_by_num.get(ch.get("number"))
241+
if c:
242+
entry["changed_at"] = summary["time"]
243+
entry["change_summary"] = (
244+
f"+{c['added_count']}" + (f" −{c['removed_count']}" if c["removed_count"] else "")
245+
)
246+
state[num] = entry
247+
if only is None:
248+
# Full cycle: drop entries for channels that are no longer live
249+
live_nums = {str(ch.get("number")) for ch in live}
250+
state = {k: v for k, v in state.items() if k in live_nums}
251+
_save_state(state)
252+
197253
return summary
198254

199255

@@ -216,12 +272,12 @@ def _write_log(summary: dict) -> None:
216272
pass # logging must never break a cycle
217273

218274

219-
async def run_cycle(apply: bool = True) -> dict:
275+
async def run_cycle(apply: bool = True, only: int = None) -> dict:
220276
"""Run one cycle under the deploy lock. Blocking work offloaded to a thread."""
221277
async with deploy_lock:
222278
_state["running"] = True
223279
try:
224-
summary = await asyncio.to_thread(_run_cycle_blocking, apply)
280+
summary = await asyncio.to_thread(_run_cycle_blocking, apply, only)
225281
finally:
226282
_state["running"] = False
227283
_state["last_cycle"] = summary
@@ -243,7 +299,7 @@ async def scheduler_loop() -> None:
243299
enabled = bool(cfg.get("recipes_enabled", False))
244300
interval_h = float(cfg.get("recipe_interval_hours", DEFAULT_INTERVAL_HOURS) or DEFAULT_INTERVAL_HOURS)
245301
interval_s = max(60.0, interval_h * 3600.0)
246-
now = asyncio.get_event_loop().time()
302+
now = time.time() # wall clock — comparable from the status handler too
247303
last = _state["last_auto_run"]
248304
due = last is None or (now - last) >= interval_s
249305
if enabled and not _state["paused"] and due:
@@ -260,13 +316,25 @@ async def scheduler_loop() -> None:
260316

261317
def get_status() -> dict:
262318
cfg = _load_config()
319+
enabled = bool(cfg.get("recipes_enabled", False))
320+
interval_h = float(cfg.get("recipe_interval_hours", DEFAULT_INTERVAL_HOURS) or DEFAULT_INTERVAL_HOURS)
321+
322+
# Seconds until the next automatic cycle (null if disabled/paused). last_auto_run
323+
# is wall-clock, so this is safe to compute from the sync status handler.
324+
next_run_seconds = None
325+
if enabled and not _state["paused"]:
326+
last = _state["last_auto_run"]
327+
next_run_seconds = 0 if last is None else max(0.0, last + interval_h * 3600.0 - time.time())
328+
263329
return {
264-
"enabled": bool(cfg.get("recipes_enabled", False)),
330+
"enabled": enabled,
265331
"paused": _state["paused"],
266332
"running": _state["running"],
267-
"interval_hours": float(cfg.get("recipe_interval_hours", DEFAULT_INTERVAL_HOURS) or DEFAULT_INTERVAL_HOURS),
333+
"interval_hours": interval_h,
334+
"next_run_seconds": next_run_seconds,
268335
"live_count": len(_live_channels(_load_channels())),
269336
"last_cycle": _state["last_cycle"],
337+
"channels": _load_state(),
270338
}
271339

272340

frontend/src/api/client.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,11 @@ export const api = {
6868
body: JSON.stringify({ value, order, exclude }),
6969
}),
7070
getRecipesStatus: () => req<RecipesStatus>('/recipes/status'),
71-
runRecipes: (apply: boolean) =>
72-
req<CycleSummary>(`/recipes/run?apply=${apply}`, { method: 'POST' }),
71+
runRecipes: (apply: boolean, only?: number) =>
72+
req<CycleSummary>(
73+
`/recipes/run?apply=${apply}${only !== undefined ? `&only=${only}` : ''}`,
74+
{ method: 'POST' },
75+
),
7376
pauseRecipes: (paused: boolean) =>
7477
req<RecipesStatus>(`/recipes/pause?paused=${paused}`, { method: 'POST' }),
7578
saveRecipeConfig: (enabled: boolean, interval_hours: number) =>
@@ -108,9 +111,12 @@ export interface CycleSummary {
108111
time: string; apply: boolean; live: number; changed: number;
109112
changes: CycleChange[]; skipped: CycleSkip[]; error: string | null;
110113
}
114+
export interface ChannelSyncState { checked_at?: string; changed_at?: string; change_summary?: string }
111115
export interface RecipesStatus {
112116
enabled: boolean; paused: boolean; running: boolean;
113-
interval_hours: number; live_count: number; last_cycle: CycleSummary | null;
117+
interval_hours: number; next_run_seconds: number | null;
118+
live_count: number; last_cycle: CycleSummary | null;
119+
channels: Record<string, ChannelSyncState>;
114120
}
115121
export interface ChannelsFile { channels: Channel[]; orphaned: string[]; suggested_channels: string[] }
116122
export interface CsvInfo {

frontend/src/pages/Channels.tsx

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,20 @@ import {
66
import { useDisclosure } from '@mantine/hooks';
77
import { notifications } from '@mantine/notifications';
88
import {
9-
IconCheck, IconEdit, IconPlus, IconRepeat, IconTag, IconTrash, IconX,
9+
IconBolt, IconCheck, IconEdit, IconPlus, IconRepeat, IconTag, IconTrash, IconX,
1010
} from '@tabler/icons-react';
1111
import { useEffect, useRef, useState } from 'react';
1212
import { useNavigate, useParams } from 'react-router-dom';
13-
import { api, Channel, ContentItem, isMatchRef, RecipeMatch } from '../api/client';
13+
import { api, Channel, ChannelSyncState, ContentItem, isMatchRef, RecipeMatch } from '../api/client';
14+
15+
function syncedAgo(iso?: string): string {
16+
if (!iso) return 'never';
17+
const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
18+
if (secs < 90) return 'just now';
19+
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
20+
if (secs < 86400) return `${Math.round(secs / 3600)}h ago`;
21+
return `${Math.round(secs / 86400)}d ago`;
22+
}
1423

1524
const SHUFFLE_COLOR: Record<string, string> = { ordered: 'blue', block: 'violet', shuffle: 'teal' };
1625
const SHUFFLE_OPTIONS = [
@@ -170,6 +179,7 @@ function ChannelModal({
170179
const [matchRef, setMatchRef] = useState<MatchRule | null>(null);
171180
const [building, setBuilding] = useState(false);
172181
const [saving, setSaving] = useState(false);
182+
const [syncing, setSyncing] = useState(false);
173183

174184
useEffect(() => {
175185
if (!channel) return;
@@ -198,25 +208,29 @@ function ChannelModal({
198208
setContent((c) => c.filter((_, idx) => idx !== i));
199209
}
200210

211+
async function persist() {
212+
const rawContent: ContentItem[] = content.map((c) => {
213+
const m = c.match(/^\{collection:\s*(.+)\}$/);
214+
return m ? { collection: m[1] } : c;
215+
});
216+
if (matchRef) {
217+
rawContent.push({
218+
match: 'title_contains',
219+
value: matchRef.value,
220+
order: matchRef.order,
221+
exclude: matchRef.exclude,
222+
});
223+
}
224+
const payload: any = { number: Number(number), name, shuffle, content: rawContent };
225+
if (live) payload.live = true;
226+
await api.updateChannel(channel!.number, payload);
227+
}
228+
201229
async function save() {
202230
if (!channel) return;
203231
setSaving(true);
204232
try {
205-
const rawContent: ContentItem[] = content.map((c) => {
206-
const m = c.match(/^\{collection:\s*(.+)\}$/);
207-
return m ? { collection: m[1] } : c;
208-
});
209-
if (matchRef) {
210-
rawContent.push({
211-
match: 'title_contains',
212-
value: matchRef.value,
213-
order: matchRef.order,
214-
exclude: matchRef.exclude,
215-
});
216-
}
217-
const payload: any = { number: Number(number), name, shuffle, content: rawContent };
218-
if (live) payload.live = true;
219-
await api.updateChannel(channel.number, payload);
233+
await persist();
220234
notifications.show({ message: 'Channel saved', color: 'green', icon: <IconCheck size={14} /> });
221235
onSaved();
222236
onClose();
@@ -227,6 +241,31 @@ function ChannelModal({
227241
}
228242
}
229243

244+
// Save, then immediately run a scheduler cycle scoped to this channel — applies
245+
// the recipe to Tunarr in place without leaving the editor.
246+
async function saveAndSync() {
247+
if (!channel) return;
248+
setSyncing(true);
249+
try {
250+
await persist();
251+
const res = await api.runRecipes(true, Number(number));
252+
const c = res.changes.find((x) => x.number === Number(number));
253+
notifications.show({
254+
message: c
255+
? `Synced #${number} — +${c.added_count}${c.removed_count ? ` −${c.removed_count}` : ''}`
256+
: `Synced #${number} — already up to date`,
257+
color: 'green',
258+
icon: <IconCheck size={14} />,
259+
});
260+
onSaved();
261+
onClose();
262+
} catch (e: any) {
263+
notifications.show({ title: 'Sync failed', message: e.message, color: 'red' });
264+
} finally {
265+
setSyncing(false);
266+
}
267+
}
268+
230269
return (
231270
<Modal
232271
opened={opened}
@@ -343,6 +382,17 @@ function ChannelModal({
343382

344383
<Group justify="flex-end">
345384
<Button variant="subtle" color="gray" onClick={onClose}>Cancel</Button>
385+
{live && (
386+
<Button
387+
variant="light"
388+
color="orange"
389+
leftSection={<IconBolt size={14} />}
390+
onClick={saveAndSync}
391+
loading={syncing}
392+
>
393+
Save &amp; Sync now
394+
</Button>
395+
)}
346396
<Button color="orange" onClick={save} loading={saving}>Save Channel</Button>
347397
</Group>
348398
</Stack>
@@ -354,10 +404,12 @@ function ChannelModal({
354404

355405
function ChannelRow({
356406
channel,
407+
sync,
357408
onEdit,
358409
onDelete,
359410
}: {
360411
channel: Channel;
412+
sync?: ChannelSyncState;
361413
onEdit: () => void;
362414
onDelete: () => void;
363415
}) {
@@ -402,6 +454,9 @@ function ChannelRow({
402454
live
403455
</Badge>
404456
)}
457+
{channel.live && (
458+
<Text size="xs" c="dimmed">synced {syncedAgo(sync?.checked_at)}</Text>
459+
)}
405460
</Group>
406461
</Box>
407462

@@ -428,6 +483,7 @@ export default function Channels() {
428483
const { number } = useParams<{ number?: string }>();
429484
const nav = useNavigate();
430485
const [channels, setChannels] = useState<Channel[]>([]);
486+
const [sync, setSync] = useState<Record<string, ChannelSyncState>>({});
431487
const [loading, setLoading] = useState(true);
432488
const [editing, setEditing] = useState<Channel | null>(null);
433489
const [opened, { open, close }] = useDisclosure(false);
@@ -436,6 +492,7 @@ export default function Channels() {
436492
const data = await api.getChannels();
437493
setChannels([...data.channels].sort((a, b) => a.number - b.number));
438494
setLoading(false);
495+
api.getRecipesStatus().then((s) => setSync(s.channels || {})).catch(() => {});
439496
}
440497

441498
useEffect(() => { load(); }, []);
@@ -491,6 +548,7 @@ export default function Channels() {
491548
<ChannelRow
492549
key={ch.number}
493550
channel={ch}
551+
sync={sync[String(ch.number)]}
494552
onEdit={() => edit(ch)}
495553
onDelete={() => load()}
496554
/>

frontend/src/pages/Dashboard.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ function relTime(iso: string): string {
6868
return `${Math.round(secs / 86400)}d ago`;
6969
}
7070

71+
function inTime(secs: number): string {
72+
if (secs < 90) return 'in <1m';
73+
if (secs < 3600) return `in ${Math.round(secs / 60)}m`;
74+
if (secs < 86400) return `in ${Math.round(secs / 3600)}h`;
75+
return `in ${Math.round(secs / 86400)}d`;
76+
}
77+
7178
function LiveRecipesCard({ status, onChange }: { status: RecipesStatus; onChange: () => void }) {
7279
const [busy, setBusy] = useState(false);
7380
const last = status.last_cycle;
@@ -117,6 +124,7 @@ function LiveRecipesCard({ status, onChange }: { status: RecipesStatus; onChange
117124
</Group>
118125
<Text size="xs" c="dimmed">
119126
{status.live_count} live channel{status.live_count !== 1 ? 's' : ''} · every {status.interval_hours}h
127+
{status.next_run_seconds != null && !status.paused ? ` · next ${inTime(status.next_run_seconds)}` : ''}
120128
</Text>
121129
</Box>
122130
</Group>

0 commit comments

Comments
 (0)