Files
dc-nogi/bot/display.py
Minz ab0afa149a Two-phase backlog scan with self-healing found-count tracking
Split each channel's backlog scan into a find phase (settles the
found count first) and a react phase, so the progress display no
longer grows both numbers in lockstep. Found messages are logged in
a dedicated, message_id-deduped table and recovered across restarts
independent of the resume checkpoint, so nothing found is silently
abandoned if the script stops mid-react.

Also drops the separately-maintained messages_found counter, which
proved prone to drift under repeated interruptions/rescans, in favor
of computing it live from the dedup table - self-healing regardless
of how many times a channel gets rescanned.
2026-08-01 18:46:28 +02:00

79 lines
2.4 KiB
Python

import asyncio
from typing import List, Optional
from rich.console import Console
from rich.live import Live
from rich.panel import Panel
from rich.progress_bar import ProgressBar
from rich.table import Table
from bot.storage import Storage
_TYPE_LABELS = {"guild": "server", "group_dm": "group", "dm": "DM"}
def _render(stats: List[dict], friend_label: Optional[str] = None) -> Panel:
table = Table(expand=True)
table.add_column("Target")
table.add_column("Type")
table.add_column("Progress", ratio=1)
table.add_column("Reacted/Found", justify="right")
table.add_column("Last reaction (UTC)")
total_reacted = 0
total_found = 0
for row in stats:
reacted = row["reaction_count"]
found = max(row["messages_found"], reacted) # found can never be less than reacted
total_reacted += reacted
total_found += found
bar = ProgressBar(total=max(found, reacted, 1), completed=reacted, width=None)
table.add_row(
row["name"] or row["target_id"],
_TYPE_LABELS.get(row["target_type"], row["target_type"]),
bar,
f"{reacted}/{found}",
row["last_reaction_at"] or "-",
)
if not stats:
table.add_row("(no targets discovered yet)", "-", "-", "-", "-")
who = f" for {friend_label}" if friend_label else ""
return Panel(table, title=f"Reaction stats{who} — total: {total_reacted}/{total_found}", border_style="cyan")
class StatsDisplay:
"""A stats panel pinned to the bottom of the terminal, with normal
log/print output scrolling above it (via rich.live.Live)."""
def __init__(self, console: Console, storage: Storage):
self.console = console
self.storage = storage
self.friend_label: Optional[str] = None
self._live = Live(
_render([]),
console=console,
refresh_per_second=4,
transient=False,
)
def __enter__(self):
self._live.__enter__()
return self
def __exit__(self, *exc_info):
return self._live.__exit__(*exc_info)
def set_friend(self, label: str) -> None:
self.friend_label = label
def refresh_sync(self) -> None:
stats = self.storage.get_stats()
self._live.update(_render(stats, self.friend_label))
async def refresh(self) -> None:
stats = await asyncio.to_thread(self.storage.get_stats)
self._live.update(_render(stats, self.friend_label))