Files
pristontale-eu-linux/ptlinux/ui/viewmodels/downloader.py
2026-09-03 19:07:09 +02:00

241 lines
6.7 KiB
Python

"""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"