diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py
index 8791aed..02ffa91 100644
--- a/backend/app/routes/sync.py
+++ b/backend/app/routes/sync.py
@@ -13,6 +13,7 @@ from app.sync.runner import (
run_rss_poll,
)
from app.sync.subscriptions import import_subscriptions
+from app.scheduler import running_job_ids
router = APIRouter(prefix="/api/sync", tags=["sync"])
@@ -170,6 +171,9 @@ def my_status(
"quota_used_today": quota.units_used_today(db),
"quota_remaining_today": quota.remaining_today(db),
"paused": state.is_sync_paused(db),
+ # True only while a job that advances this view (channel sync) is actually running,
+ # so the header shows live activity rather than a perpetual spinner over pending work.
+ "sync_active": bool({"backfill", "rss_poll"} & running_job_ids()),
}
diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py
index 7541b0d..abcb70f 100644
--- a/backend/app/scheduler.py
+++ b/backend/app/scheduler.py
@@ -272,6 +272,13 @@ def _is_running(job_id: str) -> bool:
return bool(_activity.get(job_id, {}).get("running"))
+def running_job_ids() -> set[str]:
+ """Ids of jobs executing right now (any trigger). Lets non-admin surfaces — e.g. the
+ header's sync indicator — reflect real activity instead of just pending-work counts."""
+ with _activity_lock:
+ return {jid for jid, a in _activity.items() if a.get("running")}
+
+
def trigger_job(job_id: str, actor_id: int | None = None) -> str:
"""Run a job immediately in a background thread, independent of its interval schedule.
The wrapper handles its own DB session, activity tracking and pause-skip, so the live
diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx
index 0175365..56bbb19 100644
--- a/frontend/src/components/SyncStatus.tsx
+++ b/frontend/src/components/SyncStatus.tsx
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
-import { Database, History, Loader2, Pause, Play } from "lucide-react";
+import { Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api } from "../lib/api";
import { formatViews } from "../lib/format";
import Tooltip from "./Tooltip";
@@ -38,6 +38,10 @@ export default function SyncStatus({
const syncing = data.channels_recent_pending;
const notFull = data.channels_deep_pending; // your channels without full history yet
+ // Spin only while a sync job is actually running; otherwise pending work shows as a calm,
+ // static state (or, for deep history, just the "N without full history" link below).
+ const active = data.sync_active;
+ const showMain = data.paused || active || syncing > 0 || notFull === 0;
return (
@@ -54,23 +58,31 @@ export default function SyncStatus({
- ·
- {data.paused ? (
- {t("header.sync.paused")}
- ) : syncing > 0 ? (
-
-
- {t("header.sync.syncing", { count: syncing })}
-
- ) : notFull > 0 ? (
- // Recent uploads are in, but the scheduler is still backfilling full history —
- // so "all synced" would be misleading. Reflect the deep work as active.
-
-
- {t("header.sync.backfillingHistory")}
-
- ) : (
- {t("header.sync.allSynced")}
+ {showMain && (
+ <>
+ ·
+ {data.paused ? (
+ {t("header.sync.paused")}
+ ) : active ? (
+ // A sync job is running right now — spin and name what it's doing.
+
+
+ {syncing > 0
+ ? t("header.sync.syncing", { count: syncing })
+ : notFull > 0
+ ? t("header.sync.backfillingHistory")
+ : t("header.sync.working")}
+
+ ) : syncing > 0 ? (
+ // Recent uploads queued for the next run, but nothing running now — static.
+
+
+ {t("header.sync.recentQueued", { count: syncing })}
+
+ ) : (
+ {t("header.sync.allSynced")}
+ )}
+ >
)}
{notFull > 0 && (
<>
diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json
index 6c24304..2fcc887 100644
--- a/frontend/src/i18n/locales/de/header.json
+++ b/frontend/src/i18n/locales/de/header.json
@@ -31,6 +31,8 @@
"paused": "pausiert",
"syncing": "{{count}} werden synchronisiert",
"backfillingHistory": "Verlauf wird geladen",
+ "working": "Synchronisierung…",
+ "recentQueued": "{{count}} in Warteschlange",
"allSynced": "alles synchronisiert",
"withoutFullHistory": "{{count}} ohne vollständigen Verlauf",
"fullHistoryTooltip": "Einige deiner Kanäle haben nur ihre neuesten Uploads. Klicke, um die Kanalverwaltung (auf diese gefiltert) zu öffnen und ihren vollständigen Katalog anzufordern.",
diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json
index 794deea..0c027fe 100644
--- a/frontend/src/i18n/locales/en/header.json
+++ b/frontend/src/i18n/locales/en/header.json
@@ -31,6 +31,8 @@
"paused": "paused",
"syncing": "{{count}} syncing",
"backfillingHistory": "fetching history",
+ "working": "syncing…",
+ "recentQueued": "{{count}} queued",
"allSynced": "all synced",
"withoutFullHistory": "{{count}} without full history",
"fullHistoryTooltip": "Some of your channels only have their recent uploads. Click to open the channel manager (filtered to these) and request their full back-catalog.",
diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json
index 0e8b17f..f2d6531 100644
--- a/frontend/src/i18n/locales/hu/header.json
+++ b/frontend/src/i18n/locales/hu/header.json
@@ -31,6 +31,8 @@
"paused": "szüneteltetve",
"syncing": "{{count}} szinkronizálás alatt",
"backfillingHistory": "előzmény letöltése",
+ "working": "szinkronizálás…",
+ "recentQueued": "{{count}} sorban",
"allSynced": "minden szinkronban",
"withoutFullHistory": "{{count}} teljes előzmény nélkül",
"fullHistoryTooltip": "Néhány csatornádnak csak a legutóbbi feltöltései vannak meg. Kattints a csatornakezelő megnyitásához (ezekre szűrve), és kérd le a teljes archívumukat.",
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 448d593..57e24b6 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -316,6 +316,7 @@ export interface MyStatus {
quota_used_today: number;
quota_remaining_today: number;
paused: boolean;
+ sync_active: boolean; // a channel-sync job (backfill/rss) is running right now
}
export interface MyUsage {