"""Download and long-running-operation presentation state.""" from __future__ import annotations from collections import deque import math import threading import time from typing import Mapping DOWNLOAD_SPEED_PRESETS_MBPS: dict[str, float | None] = { "Unlimited": None, "5 MB/s": 5.0, "10 MB/s": 10.0, "25 MB/s": 25.0, "50 MB/s": 50.0, } DOWNLOAD_SPEED_CUSTOM_LABEL = "Custom…" TRANSFER_RATE_WINDOW_SECONDS = 3.0 TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2 TRANSFER_RATE_IDLE_SECONDS = 2.0 def download_speed_setting_label(limit_mbps: float | None) -> str: for label, preset in DOWNLOAD_SPEED_PRESETS_MBPS.items(): if preset == limit_mbps: return label return DOWNLOAD_SPEED_CUSTOM_LABEL def download_speed_setting_value(label: str, custom_value: str) -> float | None: if label in DOWNLOAD_SPEED_PRESETS_MBPS: return DOWNLOAD_SPEED_PRESETS_MBPS[label] if label != DOWNLOAD_SPEED_CUSTOM_LABEL: raise ValueError(f"unsupported download speed choice: {label}") try: value = float(custom_value.strip()) except ValueError as error: raise ValueError("custom download speed must be a number in MB/s") from error if not math.isfinite(value) or value <= 0 or value > 10_000: raise ValueError( "custom download speed must be greater than 0 and at most 10000 MB/s" ) return value def download_activity_metadata( *, current_server: str | None, retry_count: int, retry_reason: str | None, active_workers: int, target_workers: int, ) -> str: """Render compact download details for the existing activity heading.""" if retry_count < 0 or active_workers < 0 or target_workers < 0: raise ValueError("download activity counts must not be negative") parts: list[str] = [] if current_server: parts.append(f"CDN: {_ellipsize(current_server, 28)}") if target_workers: parts.append(f"Workers: {active_workers}/{target_workers}") retry_text = f"Retries: {retry_count}" if retry_reason: retry_text += f" ({_ellipsize(retry_reason, 26, keep_tail=True)})" parts.append(retry_text) return " · ".join(parts) def _ellipsize(value: str, limit: int, *, keep_tail: bool = False) -> str: normalized = " ".join(value.split()) if len(normalized) <= limit: return normalized if keep_tail: return f"…{normalized[-(limit - 1):]}" return f"{normalized[:limit - 1]}…" def operation_activity_text( message: str, *, started_at: float | None, step: int = 0, total: int = 0, detail: str | None = None, timestamp: float | None = None, ) -> str: """Render live operation progress without dropping its elapsed timer.""" if detail is not None: base = detail elif total > 0: base = f"Step {step}/{total} · {message}" else: return message if started_at is None: return base now = time.monotonic() if timestamp is None else timestamp elapsed = int(max(0.0, now - started_at)) return f"{base} · {elapsed}s elapsed" def should_refresh_operation_elapsed( *, active: bool, started_at: float | None, stage_total: int, detail: str | None, ) -> bool: """Return whether the dashboard has operation text with a live timer.""" return ( active and started_at is not None and (stage_total > 0 or detail is not None) ) class ProgressCoalescer: """Keep only the latest cross-thread progress event for the Tk loop.""" def __init__(self) -> None: self._lock = threading.Lock() self._latest: dict[str, object] | None = None def publish(self, event: Mapping[str, object]) -> None: with self._lock: self._latest = dict(event) def take(self) -> dict[str, object] | None: with self._lock: latest = self._latest self._latest = None return latest def clear(self) -> None: with self._lock: self._latest = None class TransferRateEstimator: """Estimate a stable byte rate from cumulative network-transfer samples.""" def __init__( self, window_seconds: float = TRANSFER_RATE_WINDOW_SECONDS, minimum_interval_seconds: float = TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS, ) -> None: if window_seconds <= 0: raise ValueError("transfer-rate window must be positive") if minimum_interval_seconds < 0: raise ValueError("transfer-rate minimum interval must not be negative") self._window_seconds = window_seconds self._minimum_interval_seconds = minimum_interval_seconds self._samples: deque[tuple[float, int]] = deque() def reset(self) -> None: self._samples.clear() def update( self, transferred_bytes: int, *, timestamp: float | None = None, ) -> float | None: """Record one cumulative sample and return bytes per second when stable.""" if transferred_bytes < 0: raise ValueError("transferred bytes must not be negative") now = time.monotonic() if timestamp is None else timestamp if self._samples: last_time, last_bytes = self._samples[-1] if now <= last_time or transferred_bytes < last_bytes: self.reset() self._samples.append((now, transferred_bytes)) cutoff = now - self._window_seconds while len(self._samples) > 2 and self._samples[1][0] <= cutoff: self._samples.popleft() if len(self._samples) < 2: return None first_time, first_bytes = self._samples[0] elapsed = now - first_time if elapsed < self._minimum_interval_seconds: return None return max(0.0, (transferred_bytes - first_bytes) / elapsed) def is_idle( self, threshold_seconds: float = TRANSFER_RATE_IDLE_SECONDS, *, timestamp: float | None = None, ) -> bool: """Return whether no cumulative sample arrived within the threshold.""" if threshold_seconds < 0: raise ValueError("idle threshold must not be negative") if not self._samples: return False now = time.monotonic() if timestamp is None else timestamp return now - self._samples[-1][0] >= threshold_seconds def as_non_negative_int(value: object) -> int: return value if isinstance(value, int) and value >= 0 else 0 def as_non_negative_float(value: object) -> float | None: if isinstance(value, bool) or not isinstance(value, (int, float)): return None number = float(value) return number if math.isfinite(number) and number >= 0 else None def format_bytes(value: int) -> str: units = ("B", "KiB", "MiB", "GiB") amount = float(value) for unit in units: if amount < 1024 or unit == units[-1]: return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} {unit}" amount /= 1024 return f"{value} B" def format_transfer_rate(bytes_per_second: float) -> str: return f"{format_bytes(max(0, round(bytes_per_second)))}/s" def format_eta(seconds: float | None) -> str: if seconds is None or not math.isfinite(seconds) or seconds < 0: return "calculating…" total_seconds = max(0, math.ceil(seconds)) hours, remainder = divmod(total_seconds, 60 * 60) minutes, seconds = divmod(remainder, 60) if hours: return f"{hours}h {minutes:02d}m" if minutes: return f"{minutes}m {seconds:02d}s" return f"{seconds}s"