trovenps 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nps/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Trove — a NoPayStation catalog browser & downloader."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("trovenps")
7
+ except PackageNotFoundError: # running from a source tree that isn't installed
8
+ __version__ = "0.0.0"
9
+
10
+ __all__ = ["__version__"]
nps/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """``python -m nps`` -> the command-line interface."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
nps/aria2.py ADDED
@@ -0,0 +1,98 @@
1
+ """aria2 hand-off: write an input file, run a local aria2c, or push over RPC."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+ from loguru import logger
12
+
13
+ from . import monitoring
14
+ from .models import Game
15
+
16
+
17
+ def write_aria2_input(games: list[Game], output_dir: Path, dest: Path) -> int:
18
+ """Write an aria2 ``--input-file`` (``aria2c -c -i <dest>``); return entry count."""
19
+ targets = [g for g in games if g.downloadable]
20
+ lines: list[str] = []
21
+ for g in targets:
22
+ lines.append(g.download_url)
23
+ lines.append(f" out={g.filename}")
24
+ lines.append(f" dir={output_dir}")
25
+ if g.sha256:
26
+ lines.append(f" checksum=sha-256={g.sha256.lower()}")
27
+ dest.write_text("\n".join(lines) + "\n", encoding="utf-8")
28
+ return len(targets)
29
+
30
+
31
+ def run_aria2c(games: list[Game], output_dir: Path, *, concurrency: int = 3) -> int:
32
+ """Download matches now with a local ``aria2c`` (single command).
33
+
34
+ Writes a temporary input file and hands it to ``aria2c -c``; aria2c streams
35
+ its own progress and verifies the embedded SHA-256 checksums. Returns aria2c's
36
+ exit code. Raises ``FileNotFoundError`` if ``aria2c`` isn't on PATH.
37
+ """
38
+ aria2c = shutil.which("aria2c")
39
+ if aria2c is None:
40
+ raise FileNotFoundError(
41
+ "aria2c not found on PATH; install aria2, or use --aria2 FILE to export an input file."
42
+ )
43
+ with tempfile.NamedTemporaryFile("w", suffix=".aria2.txt", delete=False, encoding="utf-8") as fh:
44
+ tmp = Path(fh.name)
45
+ try:
46
+ count = write_aria2_input(games, output_dir, tmp)
47
+ logger.info("Handing {} download(s) to aria2c (-j{}).", count, concurrency)
48
+ return subprocess.run([aria2c, "-c", f"-j{concurrency}", "-i", str(tmp)]).returncode
49
+ finally:
50
+ tmp.unlink(missing_ok=True)
51
+
52
+
53
+ async def add_to_aria2_rpc(
54
+ games: list[Game],
55
+ rpc_url: str,
56
+ *,
57
+ secret: str | None = None,
58
+ remote_dir: str | None = None,
59
+ client: httpx.AsyncClient | None = None,
60
+ ) -> list[str]:
61
+ """Queue downloads on a running aria2 daemon; return the assigned GIDs.
62
+
63
+ ``remote_dir`` is a path on the aria2 host; omit it for the daemon's default.
64
+ """
65
+ targets = [g for g in games if g.downloadable]
66
+ owns_client = client is None
67
+ client = client or httpx.AsyncClient(timeout=httpx.Timeout(30.0))
68
+ gids: list[str] = []
69
+ try:
70
+ for g in targets:
71
+ options: dict[str, str] = {"out": g.filename, "continue": "true"}
72
+ if remote_dir is not None:
73
+ options["dir"] = remote_dir
74
+ if g.sha256:
75
+ options["checksum"] = f"sha-256={g.sha256.lower()}"
76
+
77
+ params: list = [] # aria2.addUri params: [secret?, [uris], options]
78
+ if secret:
79
+ params.append(f"token:{secret}")
80
+ params.append([g.download_url])
81
+ params.append(options)
82
+ payload = {"jsonrpc": "2.0", "id": g.title_id, "method": "aria2.addUri", "params": params}
83
+ try:
84
+ resp = await client.post(rpc_url, json=payload)
85
+ resp.raise_for_status()
86
+ data = resp.json()
87
+ if "error" in data:
88
+ logger.error("aria2 rejected {}: {}", g.title_id, data["error"])
89
+ continue
90
+ gids.append(data["result"])
91
+ logger.info("Queued {} ({}) -> aria2 gid {}", g.title_id, g.name, data["result"])
92
+ except Exception as exc:
93
+ monitoring.capture_exception(exc)
94
+ logger.error("Failed to queue {} ({}): {}", g.title_id, g.name, exc)
95
+ finally:
96
+ if owns_client:
97
+ await client.aclose()
98
+ return gids
nps/catalog.py ADDED
@@ -0,0 +1,195 @@
1
+ """Remote NoPayStation catalog: fetch TSVs (cached), parse into ``Game`` records.
2
+
3
+ Datasets are cached for ``CACHE_TTL``; once stale they're revalidated with an
4
+ ETag conditional request, so an unchanged dataset costs a ``304``, not a refetch.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import csv
11
+ import json
12
+ import os
13
+ import time
14
+ from datetime import timedelta
15
+ from pathlib import Path
16
+
17
+ import httpx
18
+ from loguru import logger
19
+ from platformdirs import user_cache_dir
20
+ from pydantic import ValidationError
21
+
22
+ from .models import COLUMNS, ContentType, Game, Platform
23
+
24
+ BASE_URL = "https://nopaystation.com/tsv"
25
+ CACHE_TTL = timedelta(days=30).total_seconds()
26
+ # Default to the OS cache dir (shared across runs, outside the repo); override
27
+ # with NPS_CACHE_DIR for a fixed location.
28
+ CACHE_DIR = Path(os.getenv("NPS_CACHE_DIR") or user_cache_dir("nps"))
29
+
30
+ # NoPayStation doesn't publish every platform×type combination.
31
+ DATASETS: dict[Platform, tuple[ContentType, ...]] = {
32
+ Platform.PSV: (
33
+ ContentType.GAMES,
34
+ ContentType.DLCS,
35
+ ContentType.THEMES,
36
+ ContentType.UPDATES,
37
+ ContentType.DEMOS,
38
+ ),
39
+ Platform.PSP: (ContentType.GAMES, ContentType.DLCS),
40
+ Platform.PS3: (
41
+ ContentType.GAMES,
42
+ ContentType.DLCS,
43
+ ContentType.THEMES,
44
+ ContentType.AVATARS,
45
+ ),
46
+ Platform.PSX: (ContentType.GAMES,),
47
+ Platform.PSM: (ContentType.GAMES,),
48
+ }
49
+
50
+
51
+ def dataset_name(platform: Platform, content_type: ContentType) -> str:
52
+ return f"{platform.value}_{content_type.value}"
53
+
54
+
55
+ def _paths(name: str) -> tuple[Path, Path]:
56
+ return CACHE_DIR / f"{name}.tsv", CACHE_DIR / f"{name}.meta.json"
57
+
58
+
59
+ def reset_cache() -> int:
60
+ """Delete every cached dataset; return the number of files removed."""
61
+ if not CACHE_DIR.exists():
62
+ return 0
63
+ removed = 0
64
+ for path in CACHE_DIR.glob("*"):
65
+ path.unlink()
66
+ removed += 1
67
+ return removed
68
+
69
+
70
+ def _load_meta(meta_path: Path) -> dict:
71
+ try:
72
+ return json.loads(meta_path.read_text(encoding="utf-8"))
73
+ except (OSError, json.JSONDecodeError):
74
+ return {}
75
+
76
+
77
+ def _is_fresh(meta: dict) -> bool:
78
+ fetched_at = meta.get("fetched_at")
79
+ return isinstance(fetched_at, (int, float)) and (time.time() - fetched_at) < CACHE_TTL
80
+
81
+
82
+ async def fetch_dataset(
83
+ platform: Platform,
84
+ content_type: ContentType,
85
+ *,
86
+ client: httpx.AsyncClient | None = None,
87
+ refresh: bool = False,
88
+ offline: bool = False,
89
+ ) -> Path | None:
90
+ """Path to the cached TSV, fetching/revalidating as needed.
91
+
92
+ ``None`` if it can't be obtained (invalid combo, or offline/network failure
93
+ with no cached fallback).
94
+ """
95
+ name = dataset_name(platform, content_type)
96
+ tsv_path, meta_path = _paths(name)
97
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
98
+
99
+ if offline:
100
+ return tsv_path if tsv_path.exists() else None
101
+
102
+ meta = _load_meta(meta_path)
103
+ if tsv_path.exists() and not refresh and _is_fresh(meta):
104
+ return tsv_path
105
+
106
+ headers: dict[str, str] = {}
107
+ if tsv_path.exists() and not refresh and meta.get("etag"):
108
+ headers["If-None-Match"] = meta["etag"]
109
+
110
+ url = f"{BASE_URL}/{name}.tsv"
111
+ owns_client = client is None
112
+ client = client or httpx.AsyncClient(timeout=httpx.Timeout(30.0, read=120.0))
113
+ try:
114
+ resp = await client.get(url, headers=headers, follow_redirects=True)
115
+ if resp.status_code == 304:
116
+ meta["fetched_at"] = time.time()
117
+ meta_path.write_text(json.dumps(meta), encoding="utf-8")
118
+ return tsv_path
119
+ resp.raise_for_status()
120
+ body = resp.content
121
+ if not body.startswith(b"Title ID\t"): # an error page, not a dataset
122
+ logger.warning("{} is not a valid dataset; skipping.", name)
123
+ return None
124
+ tsv_path.write_bytes(body)
125
+ meta_path.write_text(
126
+ json.dumps(
127
+ {
128
+ "etag": resp.headers.get("ETag"),
129
+ "last_modified": resp.headers.get("Last-Modified"),
130
+ "fetched_at": time.time(),
131
+ }
132
+ ),
133
+ encoding="utf-8",
134
+ )
135
+ logger.info("Fetched {} ({:,} bytes).", name, len(body))
136
+ return tsv_path
137
+ except httpx.HTTPError as exc:
138
+ if tsv_path.exists():
139
+ logger.warning("Fetch failed for {} ({}); using cached copy.", name, exc)
140
+ return tsv_path
141
+ logger.error("Fetch failed for {} and no cache available: {}", name, exc)
142
+ return None
143
+ finally:
144
+ if owns_client:
145
+ await client.aclose()
146
+
147
+
148
+ def _dedupe(games: list[Game]) -> list[Game]:
149
+ """Collapse rows sharing an identity, preferring a downloadable variant."""
150
+ best: dict[str, Game] = {}
151
+ for g in games:
152
+ existing = best.get(g.identity)
153
+ if existing is None or (g.downloadable and not existing.downloadable):
154
+ best[g.identity] = g
155
+ return list(best.values())
156
+
157
+
158
+ def parse_tsv(path: Path, platform: Platform, content_type: ContentType) -> list[Game]:
159
+ games: list[Game] = []
160
+ skipped = 0
161
+ with path.open(encoding="utf-8", newline="") as fh:
162
+ for row in csv.DictReader(fh, delimiter="\t"):
163
+ data: dict[str, object] = {}
164
+ for header, field in COLUMNS.items():
165
+ if value := row.get(header):
166
+ data.setdefault(field, value) # first non-empty wins
167
+ if not data.get("title_id"):
168
+ continue
169
+ data["platform"] = platform
170
+ data["content_type"] = content_type
171
+ try:
172
+ games.append(Game.model_validate(data))
173
+ except ValidationError:
174
+ skipped += 1
175
+ if skipped:
176
+ logger.warning("Skipped {} malformed row(s) in {}.", skipped, dataset_name(platform, content_type))
177
+ return games
178
+
179
+
180
+ async def load_games(
181
+ platform: Platform,
182
+ content_type: ContentType,
183
+ *,
184
+ client: httpx.AsyncClient | None = None,
185
+ refresh: bool = False,
186
+ offline: bool = False,
187
+ ) -> list[Game]:
188
+ """Fetch (cached) and parse a single dataset."""
189
+ path = await fetch_dataset(
190
+ platform, content_type, client=client, refresh=refresh, offline=offline
191
+ )
192
+ if path is None:
193
+ return []
194
+ games = await asyncio.to_thread(parse_tsv, path, platform, content_type)
195
+ return _dedupe(games)
nps/cli.py ADDED
@@ -0,0 +1,172 @@
1
+ """Command-line interface for the nps catalog downloader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from loguru import logger
12
+
13
+ from . import __version__
14
+ from .aria2 import add_to_aria2_rpc, run_aria2c, write_aria2_input
15
+ from .catalog import load_games, reset_cache
16
+ from .download import download_games
17
+ from .models import ContentType, Filter, Game, Platform
18
+
19
+ _SIZE_UNITS = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4}
20
+
21
+
22
+ def parse_size(text: str) -> int:
23
+ """Parse a size like ``2GB``, ``500MB``, or a raw byte count into bytes."""
24
+ s = text.strip().upper()
25
+ for unit in ("TB", "GB", "MB", "KB", "B"):
26
+ if s.endswith(unit):
27
+ return int(float(s[: -len(unit)].strip()) * _SIZE_UNITS[unit])
28
+ return int(s) # bare number = bytes
29
+
30
+
31
+ def _game_json(g: Game) -> dict[str, object]:
32
+ """A flat, agent-friendly view of a game (stable keys, no internal model noise)."""
33
+ return {
34
+ "title_id": g.title_id,
35
+ "name": g.name,
36
+ "region": g.region,
37
+ "platform": g.platform.value if g.platform else None,
38
+ "content_type": g.content_type.value if g.content_type else None,
39
+ "content_subtype": g.content_subtype,
40
+ "downloadable": g.downloadable,
41
+ "url": g.download_url if g.downloadable else None,
42
+ "file_size": g.file_size,
43
+ "sha256": g.sha256,
44
+ "required_fw": g.required_fw,
45
+ "content_id": g.content_id,
46
+ "last_modification_date": g.last_modification_date,
47
+ }
48
+
49
+
50
+ def _build_parser() -> argparse.ArgumentParser:
51
+ p = argparse.ArgumentParser(
52
+ prog="nps", description="Trove — browse and download from NoPayStation."
53
+ )
54
+ p.add_argument("query", nargs="?", help="Filter by Title ID or name (case-insensitive).")
55
+ p.add_argument("-p", "--platform", type=Platform, choices=list(Platform), default=Platform.PSV,
56
+ help="Console platform (default: PSV).")
57
+ p.add_argument("-t", "--type", type=ContentType, choices=list(ContentType), default=ContentType.GAMES,
58
+ dest="content_type", help="Content type (default: GAMES).")
59
+ p.add_argument("-r", "--region", action="append", help="Region to include (repeatable), e.g. US EU JP.")
60
+ p.add_argument("--name", help="Filter by name substring only (case-insensitive).")
61
+ p.add_argument("--title-id", dest="title_id", help="Filter by Title ID substring only.")
62
+ p.add_argument("--max-fw", type=float, metavar="VERSION",
63
+ help="Only items requiring firmware <= VERSION (e.g. 3.60).")
64
+ p.add_argument("--min-size", type=parse_size, metavar="SIZE",
65
+ help="Only items at least SIZE (e.g. 100MB, 2GB).")
66
+ p.add_argument("--max-size", type=parse_size, metavar="SIZE",
67
+ help="Only items at most SIZE (e.g. 500MB, 4GB).")
68
+ p.add_argument("-o", "--output", type=Path, default=Path("downloads"), help="Output directory.")
69
+ p.add_argument("-l", "--list", action="store_true", help="List matches without downloading.")
70
+ p.add_argument("--json", action="store_true",
71
+ help="Print matches as JSON (no download); for scripts and agents.")
72
+ p.add_argument("-a", "--all", action="store_true", help="Download every downloadable match.")
73
+ p.add_argument("-c", "--concurrency", type=int, default=3, help="Max concurrent downloads.")
74
+ p.add_argument("--no-verify", action="store_true", help="Skip SHA256 verification.")
75
+ p.add_argument("--refresh", action="store_true", help="Force-refresh the catalog from NoPayStation.")
76
+ p.add_argument("--reset-cache", action="store_true", help="Delete all cached catalogs and exit.")
77
+ p.add_argument("--offline", action="store_true", help="Use only the cached catalog (no network).")
78
+ p.add_argument("--aria2", type=Path, metavar="FILE", help="Export matches as an aria2 input file.")
79
+ p.add_argument("--aria2-run", action="store_true",
80
+ help="Download matches now via a local aria2c (single command; needs aria2c on PATH).")
81
+ p.add_argument("--aria2-rpc", nargs="?", const=os.getenv("ARIA2_RPC_URL"), metavar="URL",
82
+ help="Push matches to a running aria2 daemon (URL or ARIA2_RPC_URL env).")
83
+ p.add_argument("--aria2-secret", default=os.getenv("ARIA2_RPC_SECRET"),
84
+ help="aria2 RPC secret token (or ARIA2_RPC_SECRET env).")
85
+ p.add_argument("--aria2-dir", help="Download dir on the aria2 host (RPC mode).")
86
+ p.add_argument("--version", action="version", version=f"nps {__version__}")
87
+ return p
88
+
89
+
90
+ def main(argv: list[str] | None = None) -> None:
91
+ from .observability import setup
92
+
93
+ args = _build_parser().parse_args(argv)
94
+ # In --json mode, logs (loguru -> tqdm.write -> stdout) would corrupt the
95
+ # payload, so silence the console sink and let stdout carry JSON alone.
96
+ setup(console=not args.json)
97
+
98
+ if args.reset_cache:
99
+ logger.info("Removed {} cached file(s).", reset_cache())
100
+ return
101
+
102
+ games = asyncio.run(
103
+ load_games(args.platform, args.content_type, refresh=args.refresh, offline=args.offline)
104
+ )
105
+ if not games:
106
+ if args.json:
107
+ print("[]")
108
+ return
109
+ logger.warning("No catalog data for {} {}.", args.platform.value, args.content_type.value)
110
+ return
111
+
112
+ fkw: dict[str, object] = dict(
113
+ query=args.query,
114
+ title_id=args.title_id,
115
+ name=args.name,
116
+ regions=set(args.region) if args.region else None,
117
+ max_fw=args.max_fw,
118
+ min_size=args.min_size,
119
+ max_size=args.max_size,
120
+ )
121
+ flt = Filter(**fkw)
122
+ matches = flt.apply(games)
123
+
124
+ if args.json or args.list or not (args.query or args.all):
125
+ shown = Filter(**fkw, downloadable_only=False).apply(games)
126
+ if args.json:
127
+ print(json.dumps([_game_json(g) for g in shown], indent=2))
128
+ return
129
+ print(f"{len(shown)} match(es), {len(matches)} downloadable:\n")
130
+ for g in shown:
131
+ mark = " " if g.downloadable else "x"
132
+ print(f" [{mark}] {g.title_id} {g.region:4} {g.name}")
133
+ if not (args.query or args.all):
134
+ print("\nPass a Title ID / name, or use --all to download everything.")
135
+ return
136
+
137
+ if not matches:
138
+ logger.warning("No downloadable matches.")
139
+ return
140
+
141
+ if args.aria2:
142
+ count = write_aria2_input(matches, args.output, args.aria2)
143
+ logger.info("Wrote {} entries to {}", count, args.aria2)
144
+ logger.info("Run: aria2c -c -j{} -i {}", args.concurrency, args.aria2)
145
+ return
146
+
147
+ if args.aria2_run:
148
+ try:
149
+ run_aria2c(matches, args.output, concurrency=args.concurrency)
150
+ except FileNotFoundError as exc:
151
+ logger.error(str(exc))
152
+ return
153
+
154
+ if args.aria2_rpc:
155
+ gids = asyncio.run(
156
+ add_to_aria2_rpc(
157
+ matches, args.aria2_rpc, secret=args.aria2_secret, remote_dir=args.aria2_dir
158
+ )
159
+ )
160
+ logger.info("Queued {}/{} download(s) to aria2.", len(gids), len(matches))
161
+ return
162
+
163
+ logger.info(
164
+ "Downloading {} item(s) to {} (concurrency={})", len(matches), args.output, args.concurrency
165
+ )
166
+ asyncio.run(
167
+ download_games(matches, args.output, concurrency=args.concurrency, verify=not args.no_verify)
168
+ )
169
+
170
+
171
+ if __name__ == "__main__":
172
+ main()
nps/download.py ADDED
@@ -0,0 +1,186 @@
1
+ """Async, resumable PKG downloader with Range resume, retry, and SHA256 verify."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+ from loguru import logger
11
+
12
+ from . import monitoring
13
+ from .models import Game
14
+ from .progress import ProgressSink, TqdmSink
15
+
16
+ _RETRYABLE = (httpx.TransportError, httpx.TimeoutException)
17
+
18
+
19
+ def _sha256(path: Path) -> str:
20
+ digest = hashlib.sha256()
21
+ with path.open("rb") as fh:
22
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
23
+ digest.update(chunk)
24
+ return digest.hexdigest()
25
+
26
+
27
+ def _already_complete(game: Game, dest: Path, verify: bool) -> bool:
28
+ if not (dest.exists() and game.file_size and dest.stat().st_size == game.file_size):
29
+ return False
30
+ if not verify or not game.sha256:
31
+ return True
32
+ return _sha256(dest) == game.sha256.lower()
33
+
34
+
35
+ async def _stream_attempt(
36
+ client: httpx.AsyncClient,
37
+ url: str,
38
+ tmp: Path,
39
+ *,
40
+ desc: str,
41
+ fallback_total: int,
42
+ sink: ProgressSink,
43
+ key: str,
44
+ ) -> None:
45
+ resume_from = tmp.stat().st_size if tmp.exists() else 0
46
+ headers = {"Range": f"bytes={resume_from}-"} if resume_from else {}
47
+
48
+ async with client.stream("GET", url, headers=headers, follow_redirects=True) as resp:
49
+ if resp.status_code == 416: # already complete on the server side
50
+ return
51
+ if resume_from and resp.status_code == 200: # Range ignored; restart clean
52
+ resume_from = 0
53
+ resp.raise_for_status()
54
+
55
+ if resp.status_code == 206:
56
+ content_range = resp.headers.get("Content-Range", "")
57
+ total = (
58
+ int(content_range.rsplit("/", 1)[-1])
59
+ if "/" in content_range
60
+ else resume_from + int(resp.headers.get("Content-Length", 0))
61
+ )
62
+ else:
63
+ total = int(resp.headers.get("Content-Length", 0)) or fallback_total
64
+
65
+ sink.start(key, desc, total or None, initial=resume_from)
66
+ with tmp.open("ab" if resume_from else "wb") as fh:
67
+ async for chunk in resp.aiter_bytes(chunk_size=1024 * 256):
68
+ fh.write(chunk)
69
+ sink.advance(key, len(chunk))
70
+
71
+
72
+ async def download_game(
73
+ game: Game,
74
+ output_dir: Path,
75
+ *,
76
+ client: httpx.AsyncClient | None = None,
77
+ verify: bool = True,
78
+ sink: ProgressSink | None = None,
79
+ max_retries: int = 5,
80
+ ) -> Path:
81
+ url = game.download_url
82
+ output_dir.mkdir(parents=True, exist_ok=True)
83
+ dest = output_dir / game.filename
84
+ sink = sink if sink is not None else TqdmSink()
85
+ key = game.filename
86
+
87
+ if await asyncio.to_thread(_already_complete, game, dest, verify):
88
+ logger.info("Skipping {} (already downloaded)", game.name)
89
+ return dest
90
+
91
+ owns_client = client is None
92
+ client = client or httpx.AsyncClient(timeout=httpx.Timeout(30.0, read=300.0))
93
+ tmp = dest.with_suffix(dest.suffix + ".part")
94
+ fallback_total = game.file_size or 0
95
+
96
+ resume_bytes = tmp.stat().st_size if tmp.exists() else 0
97
+ monitoring.add_breadcrumb(
98
+ category="download",
99
+ message=("Resuming" if resume_bytes else "Starting") + f" {game.title_id}",
100
+ data={"resume_from": resume_bytes, "url": url},
101
+ )
102
+
103
+ try:
104
+ for attempt in range(1, max_retries + 1):
105
+ try:
106
+ await _stream_attempt(
107
+ client, url, tmp, desc=game.name[:40],
108
+ fallback_total=fallback_total, sink=sink, key=key,
109
+ )
110
+ break
111
+ except _RETRYABLE as exc:
112
+ if attempt == max_retries:
113
+ raise
114
+ wait = min(2**attempt, 30) # .part is kept, so the retry resumes
115
+ monitoring.add_breadcrumb(
116
+ category="download",
117
+ level="warning",
118
+ message=f"{type(exc).__name__} on attempt {attempt}/{max_retries}, "
119
+ f"retrying in {wait}s",
120
+ data={"error": str(exc)},
121
+ )
122
+ logger.warning(
123
+ "{}: {}, retrying in {}s ({}/{})...",
124
+ game.title_id, type(exc).__name__, wait, attempt, max_retries,
125
+ )
126
+ await asyncio.sleep(wait)
127
+ finally:
128
+ sink.finish(key)
129
+ if owns_client:
130
+ await client.aclose()
131
+
132
+ # Re-hash from disk: an in-memory digest can't survive a cross-run resume.
133
+ if verify and game.sha256:
134
+ actual = await asyncio.to_thread(_sha256, tmp)
135
+ if actual != game.sha256.lower():
136
+ tmp.unlink(missing_ok=True)
137
+ raise ValueError(
138
+ f"SHA256 mismatch for {game.name}: expected {game.sha256}, got {actual}"
139
+ )
140
+
141
+ tmp.replace(dest)
142
+ return dest
143
+
144
+
145
+ async def download_games(
146
+ games: list[Game],
147
+ output_dir: Path,
148
+ *,
149
+ concurrency: int = 3,
150
+ verify: bool = True,
151
+ sink: ProgressSink | None = None,
152
+ ) -> list[Path]:
153
+ """Download many games concurrently, capped at ``concurrency`` in flight."""
154
+ targets = [g for g in games if g.downloadable]
155
+ sink = sink if sink is not None else TqdmSink()
156
+ sem = asyncio.Semaphore(concurrency)
157
+ results: list[Path] = []
158
+
159
+ async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, read=300.0)) as client:
160
+
161
+ async def worker(game: Game) -> None:
162
+ async with sem:
163
+ with monitoring.isolation_scope() as scope:
164
+ scope.set_tag("title_id", game.title_id)
165
+ scope.set_tag("region", game.region)
166
+ scope.set_context(
167
+ "game",
168
+ {
169
+ "title_id": game.title_id,
170
+ "name": game.name,
171
+ "region": game.region,
172
+ "url": game.pkg_direct_link,
173
+ "file_size": game.file_size,
174
+ },
175
+ )
176
+ try:
177
+ results.append(
178
+ await download_game(game, output_dir, client=client, verify=verify, sink=sink)
179
+ )
180
+ except Exception as exc: # report every failure, but keep going
181
+ monitoring.capture_exception(exc)
182
+ logger.error("Failed {} ({}): {}", game.title_id, game.name, exc)
183
+
184
+ await asyncio.gather(*(worker(g) for g in targets))
185
+
186
+ return results