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 +10 -0
- nps/__main__.py +6 -0
- nps/aria2.py +98 -0
- nps/catalog.py +195 -0
- nps/cli.py +172 -0
- nps/download.py +186 -0
- nps/models.py +163 -0
- nps/monitoring.py +68 -0
- nps/observability.py +54 -0
- nps/progress.py +55 -0
- nps/tui/__init__.py +17 -0
- nps/tui/app.py +294 -0
- nps/tui/app.tcss +39 -0
- trovenps-0.4.0.dist-info/METADATA +61 -0
- trovenps-0.4.0.dist-info/RECORD +18 -0
- trovenps-0.4.0.dist-info/WHEEL +4 -0
- trovenps-0.4.0.dist-info/entry_points.txt +3 -0
- trovenps-0.4.0.dist-info/licenses/LICENSE +21 -0
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
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
|