241 lines
6.7 KiB
Python
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"
|