feat(downloads): live per-file progress on running events — #709
Now that we own the walk, surface live counts on the in-flight download in the
Downloads view. ingest_core.run takes an event_id and does a TIME-THROTTLED
write (~5s, decoupled from page boundaries so it ticks steadily regardless of
how big/slow a page is) of {downloaded, skipped, errors, quarantined, posts} to
the running download_event's metadata.live (jsonb_set; short session; status
guard so a finalized event isn't clobbered). download_backends threads
event_id from ctx; the /api/downloads list surfaces `live`; ActiveDownloadsPanel
renders it beside the elapsed timer. Native (Patreon) only — gallery-dl is an
opaque subprocess; the row only shows when `live` is present. Phase 3 overwrites
metadata with run_stats on finish, dropping `live`.
Test: _write_live_progress updates a running event's metadata.live and leaves a
finalized (status != running) event alone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,9 @@ def _list_record(event: DownloadEvent, source: Source | None, artist: Artist | N
|
|||||||
"bytes_downloaded": event.bytes_downloaded,
|
"bytes_downloaded": event.bytes_downloaded,
|
||||||
"error": event.error,
|
"error": event.error,
|
||||||
"summary": _summary_from_metadata(event.metadata_),
|
"summary": _summary_from_metadata(event.metadata_),
|
||||||
|
# plan #709: mid-walk live counts for a RUNNING native-ingester event
|
||||||
|
# (None otherwise; phase 3 overwrites metadata with run_stats on finish).
|
||||||
|
"live": (event.metadata_ or {}).get("live"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ async def _run_native_ingester(
|
|||||||
resume_cursor=source_config.resume_cursor,
|
resume_cursor=source_config.resume_cursor,
|
||||||
time_budget_seconds=source_config.timeout,
|
time_budget_seconds=source_config.timeout,
|
||||||
posts_base=int(overrides.get("_backfill_posts", 0)),
|
posts_base=int(overrides.get("_backfill_posts", 0)),
|
||||||
|
# plan #709: live progress writes to this running event mid-walk.
|
||||||
|
event_id=ctx.get("event_id"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return dl_result, resolved_campaign_id
|
return dl_result, resolved_campaign_id
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Plain-HTTP homelab: no secure-context Web API.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -48,6 +49,12 @@ DEAD_LETTER_THRESHOLD = 3
|
|||||||
# last_error is Text but bound it so a giant traceback doesn't bloat the row.
|
# last_error is Text but bound it so a giant traceback doesn't bloat the row.
|
||||||
_ERROR_MAX = 1000
|
_ERROR_MAX = 1000
|
||||||
|
|
||||||
|
# plan #709: throttle the live-progress write to the running DownloadEvent to one
|
||||||
|
# every ~5s — a steady cadence for the Downloads view regardless of how big/slow a
|
||||||
|
# page is (page boundaries can be minutes apart on image-dense backfills, so a
|
||||||
|
# page-tied update would lurch). Trivial churn (~one single-row UPDATE / 5s).
|
||||||
|
_LIVE_PROGRESS_INTERVAL = 5.0
|
||||||
|
|
||||||
|
|
||||||
class Ingester:
|
class Ingester:
|
||||||
"""Generic native-ingest orchestration. Subclass with a platform adapter
|
"""Generic native-ingest orchestration. Subclass with a platform adapter
|
||||||
@@ -92,6 +99,7 @@ class Ingester:
|
|||||||
time_budget_seconds: float = 870.0,
|
time_budget_seconds: float = 870.0,
|
||||||
seen_threshold: int = _TICK_SEEN_THRESHOLD,
|
seen_threshold: int = _TICK_SEEN_THRESHOLD,
|
||||||
posts_base: int = 0,
|
posts_base: int = 0,
|
||||||
|
event_id: int | None = None,
|
||||||
) -> DownloadResult:
|
) -> DownloadResult:
|
||||||
"""Walk + download for one source, returning a gallery-dl-shaped result.
|
"""Walk + download for one source, returning a gallery-dl-shaped result.
|
||||||
|
|
||||||
@@ -109,6 +117,7 @@ class Ingester:
|
|||||||
checkpoint = mode in ("backfill", "recovery")
|
checkpoint = mode in ("backfill", "recovery")
|
||||||
ledger_key = self._ledger_key
|
ledger_key = self._ledger_key
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
|
last_live = start # plan #709: last live-progress write timestamp
|
||||||
log_lines: list[str] = []
|
log_lines: list[str] = []
|
||||||
written: list[str] = []
|
written: list[str] = []
|
||||||
quarantined_paths: list[str] = []
|
quarantined_paths: list[str] = []
|
||||||
@@ -288,6 +297,19 @@ class Ingester:
|
|||||||
if to_fail:
|
if to_fail:
|
||||||
self._record_failures(source_id, to_fail)
|
self._record_failures(source_id, to_fail)
|
||||||
|
|
||||||
|
# plan #709: time-throttled live progress to the running event so
|
||||||
|
# the Downloads view ticks ~every 5s, independent of page size.
|
||||||
|
now = time.monotonic()
|
||||||
|
if event_id is not None and (now - last_live) >= _LIVE_PROGRESS_INTERVAL:
|
||||||
|
last_live = now
|
||||||
|
self._write_live_progress(event_id, {
|
||||||
|
"downloaded": downloaded,
|
||||||
|
"skipped": skipped_count,
|
||||||
|
"errors": errors,
|
||||||
|
"quarantined": quarantined,
|
||||||
|
"posts": posts_processed,
|
||||||
|
})
|
||||||
|
|
||||||
if early_out:
|
if early_out:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -474,6 +496,25 @@ class Ingester:
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
def _write_live_progress(self, event_id: int, counts: dict) -> None:
|
||||||
|
"""Throttled mid-walk write of live counts to the RUNNING download_event
|
||||||
|
(plan #709) so the Downloads view shows progress before the chunk
|
||||||
|
finishes. A short session (never held across the walk); the `status =
|
||||||
|
'running'` guard avoids clobbering an event phase 3 already finalized.
|
||||||
|
`metadata` is JSONB — jsonb_set sets just the `live` key, leaving the rest
|
||||||
|
for phase 3 to overwrite with the final run_stats."""
|
||||||
|
with self.session_factory() as session:
|
||||||
|
session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE download_event SET metadata = jsonb_set("
|
||||||
|
" coalesce(metadata, '{}'::jsonb), '{live}',"
|
||||||
|
" cast(:live AS jsonb)) "
|
||||||
|
"WHERE id = :eid AND status = 'running'"
|
||||||
|
),
|
||||||
|
{"live": json.dumps(counts), "eid": event_id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
def _still_running(self, source_id: int) -> bool:
|
def _still_running(self, source_id: int) -> bool:
|
||||||
"""True while the source is armed for a deep walk (plan #708 B4).
|
"""True while the source is armed for a deep walk (plan #708 B4).
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,18 @@
|
|||||||
<span class="fc-active__dot" />
|
<span class="fc-active__dot" />
|
||||||
<PlatformChip :platform="e.platform" size="x-small" />
|
<PlatformChip :platform="e.platform" size="x-small" />
|
||||||
<span class="fc-active__artist">{{ e.artist_name || '—' }}</span>
|
<span class="fc-active__artist">{{ e.artist_name || '—' }}</span>
|
||||||
|
<!-- #709: live per-file progress for native (Patreon) downloads. -->
|
||||||
|
<span v-if="e.live" class="fc-active__counts">
|
||||||
|
<span class="fc-active__count" title="downloaded">↓ {{ e.live.downloaded }}</span>
|
||||||
|
<span v-if="e.live.skipped" class="fc-active__count" title="skipped">
|
||||||
|
⤼ {{ e.live.skipped }}</span>
|
||||||
|
<span
|
||||||
|
v-if="e.live.errors" class="fc-active__count fc-active__count--err"
|
||||||
|
title="errors"
|
||||||
|
>✕ {{ e.live.errors }}</span>
|
||||||
|
<span class="fc-active__count fc-active__count--posts" title="posts scanned">
|
||||||
|
{{ e.live.posts }} posts</span>
|
||||||
|
</span>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<span class="fc-active__timer" :title="`started ${e.started_at}`">
|
<span class="fc-active__timer" :title="`started ${e.started_at}`">
|
||||||
{{ elapsed(e.started_at) }}
|
{{ elapsed(e.started_at) }}
|
||||||
@@ -123,6 +135,13 @@ function elapsed (startedIso) {
|
|||||||
font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
|
font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
}
|
}
|
||||||
|
.fc-active__counts {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
font-variant-numeric: tabular-nums; font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.fc-active__count { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
.fc-active__count--err { color: rgb(var(--v-theme-error)); }
|
||||||
|
.fc-active__count--posts { opacity: 0.7; }
|
||||||
@keyframes fc-active-pulse {
|
@keyframes fc-active-pulse {
|
||||||
0%, 100% { opacity: 1; transform: scale(1); }
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
50% { opacity: 0.35; transform: scale(0.7); }
|
50% { opacity: 0.35; transform: scale(0.7); }
|
||||||
|
|||||||
@@ -421,6 +421,34 @@ async def test_preview_page_limit_caps_walk(source_id, sync_engine, tmp_path):
|
|||||||
assert result["has_more"] is True
|
assert result["has_more"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_live_progress_updates_running_event(
|
||||||
|
source_id, sync_engine, tmp_path, db,
|
||||||
|
):
|
||||||
|
"""plan #709: live counts land in the RUNNING event's metadata.live; a
|
||||||
|
finalized event is left alone (status guard)."""
|
||||||
|
from backend.app.models import DownloadEvent
|
||||||
|
|
||||||
|
ing = _ingester(sync_engine, tmp_path, _FakeClient([]), _FakeDownloader(tmp_path))
|
||||||
|
running = DownloadEvent(source_id=source_id, status="running")
|
||||||
|
done = DownloadEvent(source_id=source_id, status="ok")
|
||||||
|
db.add_all([running, done])
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
counts = {"downloaded": 3, "skipped": 1, "errors": 0, "quarantined": 0, "posts": 4}
|
||||||
|
ing._write_live_progress(running.id, counts)
|
||||||
|
ing._write_live_progress(done.id, {"downloaded": 9}) # guarded: status != running
|
||||||
|
|
||||||
|
live = (await db.execute(
|
||||||
|
select(DownloadEvent.metadata_).where(DownloadEvent.id == running.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert live["live"] == counts
|
||||||
|
done_meta = (await db.execute(
|
||||||
|
select(DownloadEvent.metadata_).where(DownloadEvent.id == done.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert "live" not in (done_meta or {})
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_still_running_reads_backfill_state(source_id, sync_engine, tmp_path, db):
|
async def test_still_running_reads_backfill_state(source_id, sync_engine, tmp_path, db):
|
||||||
"""plan #708 B4: _still_running reflects the source's `_backfill_state` — the
|
"""plan #708 B4: _still_running reflects the source's `_backfill_state` — the
|
||||||
|
|||||||
Reference in New Issue
Block a user