Merge bug/scheduler-progress-visibility: live progress for any scheduler job + parallel RSS polling
This commit is contained in:
commit
a004fa4107
9 changed files with 91 additions and 25 deletions
|
|
@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
log = logging.getLogger("subfeed.autotag")
|
log = logging.getLogger("subfeed.autotag")
|
||||||
|
|
||||||
|
from app import progress
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import Channel, ChannelTag, Tag, Video
|
from app.models import Channel, ChannelTag, Tag, Video
|
||||||
|
|
||||||
|
|
@ -270,13 +271,14 @@ def run_autotag_all(db: Session, only_missing: bool = False) -> dict:
|
||||||
)
|
)
|
||||||
channels = db.execute(query).scalars().all()
|
channels = db.execute(query).scalars().all()
|
||||||
tagged = 0
|
tagged = 0
|
||||||
for channel in channels:
|
for i, channel in enumerate(channels, 1):
|
||||||
try:
|
try:
|
||||||
apply_channel_autotags(db, channel)
|
apply_channel_autotags(db, channel)
|
||||||
tagged += 1
|
tagged += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
log.exception("Auto-tagging failed for channel %s", channel.id)
|
log.exception("Auto-tagging failed for channel %s", channel.id)
|
||||||
|
progress.report(i, len(channels), "autotag")
|
||||||
removed = _cleanup_orphan_system_tags(db)
|
removed = _cleanup_orphan_system_tags(db)
|
||||||
return {"channels_tagged": tagged, "orphan_tags_removed": removed}
|
return {"channels_tagged": tagged, "orphan_tags_removed": removed}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from datetime import datetime, timezone
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota
|
from app import progress, quota
|
||||||
from app.auth import has_read_scope, has_write_scope
|
from app.auth import has_read_scope, has_write_scope
|
||||||
from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video
|
from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video
|
||||||
from app.sync.videos import apply_video_details, parse_dt
|
from app.sync.videos import apply_video_details, parse_dt
|
||||||
|
|
@ -382,8 +382,9 @@ def sync_all_playlists(db: Session) -> dict:
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
total = 0
|
total = 0
|
||||||
for user in users:
|
for i, user in enumerate(users, 1):
|
||||||
if not has_read_scope(user):
|
if not has_read_scope(user):
|
||||||
|
progress.report(i, len(users), "playlist_sync")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, "playlist_sync"):
|
with quota.attribute(user.id, "playlist_sync"):
|
||||||
|
|
@ -394,4 +395,5 @@ def sync_all_playlists(db: Session) -> dict:
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
log.exception("Playlist sync crashed for user %s", user.id)
|
log.exception("Playlist sync crashed for user %s", user.id)
|
||||||
|
progress.report(i, len(users), "playlist_sync")
|
||||||
return {"users": len(users), "playlists_synced": total}
|
return {"users": len(users), "playlists_synced": total}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ user" (any user with a stored refresh token) unless a YOUTUBE_API_KEY is configu
|
||||||
public reads.
|
public reads.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -16,15 +17,19 @@ log = logging.getLogger("subfeed.sync")
|
||||||
from app.models import Channel, OAuthToken, Subscription, User
|
from app.models import Channel, OAuthToken, Subscription, User
|
||||||
from app.sync.subscriptions import import_subscriptions
|
from app.sync.subscriptions import import_subscriptions
|
||||||
from app.sync.videos import (
|
from app.sync.videos import (
|
||||||
|
apply_rss_feed,
|
||||||
backfill_channel_deep,
|
backfill_channel_deep,
|
||||||
backfill_channel_recent,
|
backfill_channel_recent,
|
||||||
enrich_pending,
|
enrich_pending,
|
||||||
poll_rss_channel,
|
|
||||||
reconcile_full_history,
|
reconcile_full_history,
|
||||||
refresh_live,
|
refresh_live,
|
||||||
run_shorts_classification,
|
run_shorts_classification,
|
||||||
)
|
)
|
||||||
from app.youtube.client import YouTubeClient
|
from app.youtube.client import YouTubeClient
|
||||||
|
from app.youtube.rss import fetch_channel_feed
|
||||||
|
|
||||||
|
# RSS feeds are free and network-bound, so fetch many at once; DB writes stay single-threaded.
|
||||||
|
RSS_POLL_WORKERS = 16
|
||||||
|
|
||||||
|
|
||||||
def get_service_user(db: Session) -> User | None:
|
def get_service_user(db: Session) -> User | None:
|
||||||
|
|
@ -43,13 +48,30 @@ def get_service_user(db: Session) -> User | None:
|
||||||
def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int:
|
def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int:
|
||||||
if channels is None:
|
if channels is None:
|
||||||
channels = db.execute(select(Channel)).scalars().all()
|
channels = db.execute(select(Channel)).scalars().all()
|
||||||
|
total = len(channels)
|
||||||
new = 0
|
new = 0
|
||||||
for channel in channels:
|
done = 0
|
||||||
try:
|
# Fetch feeds concurrently (network-bound, no DB), then apply inserts on this thread as
|
||||||
new += poll_rss_channel(db, channel)
|
# each fetch returns — a SQLAlchemy session isn't thread-safe. Pass the channel id (a
|
||||||
except Exception:
|
# plain str) to the workers; the ORM instance is touched only here on the main thread.
|
||||||
db.rollback()
|
futures = {}
|
||||||
log.exception("RSS poll failed for channel %s", channel.id)
|
with ThreadPoolExecutor(max_workers=RSS_POLL_WORKERS) as pool:
|
||||||
|
for channel in channels:
|
||||||
|
futures[pool.submit(fetch_channel_feed, channel.id)] = channel
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
channel = futures[fut]
|
||||||
|
try:
|
||||||
|
entries = fut.result()
|
||||||
|
except Exception:
|
||||||
|
log.exception("RSS fetch failed for channel %s", channel.id)
|
||||||
|
entries = []
|
||||||
|
try:
|
||||||
|
new += apply_rss_feed(db, channel, entries)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
log.exception("RSS apply failed for channel %s", channel.id)
|
||||||
|
done += 1
|
||||||
|
progress.report(done, total, "rss_poll")
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -90,12 +112,13 @@ def run_subscription_resync(db: Session) -> dict:
|
||||||
.scalars()
|
.scalars()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for user in users:
|
for i, user in enumerate(users, 1):
|
||||||
try:
|
try:
|
||||||
import_subscriptions(db, user)
|
import_subscriptions(db, user)
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
log.exception("Subscription resync failed for user %s", user.id)
|
log.exception("Subscription resync failed for user %s", user.id)
|
||||||
|
progress.report(i, len(users), "subscriptions")
|
||||||
return {"users": len(users)}
|
return {"users": len(users)}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,10 @@ def _insert_stubs(db: Session, rows: list[dict]) -> int:
|
||||||
|
|
||||||
|
|
||||||
# --- RSS (free) ---
|
# --- RSS (free) ---
|
||||||
def poll_rss_channel(db: Session, channel: Channel) -> int:
|
def apply_rss_feed(db: Session, channel: Channel, entries: list[dict]) -> int:
|
||||||
entries = fetch_channel_feed(channel.id)
|
"""Insert new video stubs from an already-fetched feed and stamp the channel's last RSS
|
||||||
|
time. DB-only — must run on the session's own thread; the network fetch
|
||||||
|
(fetch_channel_feed) is kept separate so callers like run_rss_poll can parallelise it."""
|
||||||
inserted = _insert_stubs(db, entries)
|
inserted = _insert_stubs(db, entries)
|
||||||
channel.last_rss_at = _now()
|
channel.last_rss_at = _now()
|
||||||
db.add(channel)
|
db.add(channel)
|
||||||
|
|
@ -76,6 +78,10 @@ def poll_rss_channel(db: Session, channel: Channel) -> int:
|
||||||
return inserted
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def poll_rss_channel(db: Session, channel: Channel) -> int:
|
||||||
|
return apply_rss_feed(db, channel, fetch_channel_feed(channel.id))
|
||||||
|
|
||||||
|
|
||||||
# --- Backfill (uploads playlist; costs quota) ---
|
# --- Backfill (uploads playlist; costs quota) ---
|
||||||
def _stub_from_playlist_item(item: dict, channel_id: str) -> dict | None:
|
def _stub_from_playlist_item(item: dict, channel_id: str) -> dict | None:
|
||||||
content = item.get("contentDetails", {})
|
content = item.get("contentDetails", {})
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
|
||||||
const POLL_MS = 4000;
|
const POLL_MS = 4000;
|
||||||
|
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
|
||||||
|
const FAST_POLL_MS = 1500;
|
||||||
|
|
||||||
// Seconds until an ISO instant (negative = past).
|
// Seconds until an ISO instant (negative = past).
|
||||||
function secsUntil(iso: string | null): number | null {
|
function secsUntil(iso: string | null): number | null {
|
||||||
|
|
@ -73,19 +75,25 @@ function StatusLegend() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function JobProgress({ p }: { p: NonNullable<SchedulerJob["progress"]> }) {
|
// Shown for ANY running job. With reported counts it's a determinate bar; for a job that
|
||||||
|
// runs but doesn't report progress (or hasn't yet) it falls back to a generic "working"
|
||||||
|
// label and an indeterminate sliver — so every active run is visible, not just the ones
|
||||||
|
// that report numbers.
|
||||||
|
function JobProgress({ p }: { p: SchedulerJob["progress"] }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const phase = p.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : null;
|
const phase = p?.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : t("scheduler.phase.working");
|
||||||
const pct =
|
const pct =
|
||||||
p.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null;
|
p?.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null;
|
||||||
return (
|
return (
|
||||||
<div className="mt-1.5">
|
<div className="mt-1.5">
|
||||||
<div className="flex items-center justify-between text-[11px] text-muted mb-0.5">
|
<div className="flex items-center justify-between text-[11px] text-muted mb-0.5">
|
||||||
<span className="truncate">{phase}</span>
|
<span className="truncate">{phase}</span>
|
||||||
<span className="tabular-nums shrink-0">
|
<span className="tabular-nums shrink-0">
|
||||||
{p.total != null
|
{p?.total != null
|
||||||
? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}`
|
? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}`
|
||||||
: p.current.toLocaleString()}
|
: p
|
||||||
|
? p.current.toLocaleString()
|
||||||
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 rounded-full bg-border overflow-hidden">
|
<div className="h-1.5 rounded-full bg-border overflow-hidden">
|
||||||
|
|
@ -184,7 +192,7 @@ function JobRow({
|
||||||
<span> · {job.last_result}</span>
|
<span> · {job.last_result}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{job.running && job.progress && <JobProgress p={job.progress} />}
|
{job.running && <JobProgress p={job.progress} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right text-[11px] text-muted tabular-nums">
|
<div className="shrink-0 text-right text-[11px] text-muted tabular-nums">
|
||||||
{job.running ? (
|
{job.running ? (
|
||||||
|
|
@ -298,8 +306,10 @@ function Stat({
|
||||||
export default function Scheduler() {
|
export default function Scheduler() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
// Poll faster while any job is running so live progress is smooth, then ease back when
|
||||||
|
// idle. Derived from the freshest data by react-query itself (see useLiveQuery).
|
||||||
const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, {
|
const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, {
|
||||||
intervalMs: POLL_MS,
|
intervalMs: (d) => (d?.jobs?.some((j) => j.running) ? FAST_POLL_MS : POLL_MS),
|
||||||
});
|
});
|
||||||
const data = q.data;
|
const data = q.data;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,12 @@
|
||||||
"enrich": "Videos anreichern",
|
"enrich": "Videos anreichern",
|
||||||
"backfill_recent": "Neue Uploads nachladen",
|
"backfill_recent": "Neue Uploads nachladen",
|
||||||
"backfill_deep": "Ganze Historie nachladen",
|
"backfill_deep": "Ganze Historie nachladen",
|
||||||
"probe": "Shorts klassifizieren"
|
"probe": "Shorts klassifizieren",
|
||||||
|
"rss_poll": "Auf neue Uploads prüfen",
|
||||||
|
"subscriptions": "Abos synchronisieren",
|
||||||
|
"autotag": "Kanäle automatisch taggen",
|
||||||
|
"playlist_sync": "Playlists synchronisieren",
|
||||||
|
"working": "Arbeitet…"
|
||||||
},
|
},
|
||||||
"maintenance": {
|
"maintenance": {
|
||||||
"title": "Wartung",
|
"title": "Wartung",
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,12 @@
|
||||||
"enrich": "Enriching videos",
|
"enrich": "Enriching videos",
|
||||||
"backfill_recent": "Backfilling recent uploads",
|
"backfill_recent": "Backfilling recent uploads",
|
||||||
"backfill_deep": "Backfilling full history",
|
"backfill_deep": "Backfilling full history",
|
||||||
"probe": "Classifying Shorts"
|
"probe": "Classifying Shorts",
|
||||||
|
"rss_poll": "Checking for new uploads",
|
||||||
|
"subscriptions": "Syncing subscriptions",
|
||||||
|
"autotag": "Auto-tagging channels",
|
||||||
|
"playlist_sync": "Syncing playlists",
|
||||||
|
"working": "Working…"
|
||||||
},
|
},
|
||||||
"maintenance": {
|
"maintenance": {
|
||||||
"title": "Maintenance",
|
"title": "Maintenance",
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,12 @@
|
||||||
"enrich": "Videók dúsítása",
|
"enrich": "Videók dúsítása",
|
||||||
"backfill_recent": "Friss feltöltések behúzása",
|
"backfill_recent": "Friss feltöltések behúzása",
|
||||||
"backfill_deep": "Teljes előzmény behúzása",
|
"backfill_deep": "Teljes előzmény behúzása",
|
||||||
"probe": "Shorts besorolás"
|
"probe": "Shorts besorolás",
|
||||||
|
"rss_poll": "Új feltöltések keresése",
|
||||||
|
"subscriptions": "Feliratkozások szinkronizálása",
|
||||||
|
"autotag": "Csatornák automatikus címkézése",
|
||||||
|
"playlist_sync": "Lejátszási listák szinkronizálása",
|
||||||
|
"working": "Dolgozik…"
|
||||||
},
|
},
|
||||||
"maintenance": {
|
"maintenance": {
|
||||||
"title": "Karbantartás",
|
"title": "Karbantartás",
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,22 @@ import { useQuery, type QueryKey } from "@tanstack/react-query";
|
||||||
export function useLiveQuery<T>(
|
export function useLiveQuery<T>(
|
||||||
key: QueryKey,
|
key: QueryKey,
|
||||||
queryFn: () => Promise<T>,
|
queryFn: () => Promise<T>,
|
||||||
opts: { intervalMs?: number; enabled?: boolean } = {}
|
// intervalMs may be a function of the latest data, so the poll cadence can adapt to it
|
||||||
|
// (e.g. poll faster while a job is running). react-query re-evaluates it after each fetch
|
||||||
|
// against fresh data — the right place for this, vs. a React state toggle that can lag or
|
||||||
|
// stall the live updates.
|
||||||
|
opts: { intervalMs?: number | ((data: T | undefined) => number); enabled?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
const { intervalMs = 4000, enabled = true } = opts;
|
const { intervalMs = 4000, enabled = true } = opts;
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: key,
|
queryKey: key,
|
||||||
queryFn,
|
queryFn,
|
||||||
enabled,
|
enabled,
|
||||||
refetchInterval: enabled ? intervalMs : false,
|
refetchInterval: !enabled
|
||||||
|
? false
|
||||||
|
: typeof intervalMs === "function"
|
||||||
|
? (query) => intervalMs(query.state.data as T | undefined)
|
||||||
|
: intervalMs,
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue