gog-cli 0.2.1__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.
- gog_cli/__init__.py +5 -0
- gog_cli/api.py +143 -0
- gog_cli/aria2c.py +136 -0
- gog_cli/auth.py +217 -0
- gog_cli/backup.py +197 -0
- gog_cli/cli.py +550 -0
- gog_cli/config.py +120 -0
- gog_cli/downloader.py +196 -0
- gog_cli/errors.py +54 -0
- gog_cli/execution.py +1054 -0
- gog_cli/layout.py +72 -0
- gog_cli/listing.py +668 -0
- gog_cli/log.py +19 -0
- gog_cli/metadata.py +212 -0
- gog_cli/output.py +99 -0
- gog_cli/prompt.py +57 -0
- gog_cli/refresh.py +231 -0
- gog_cli/state.py +193 -0
- gog_cli/sync.py +146 -0
- gog_cli-0.2.1.dist-info/METADATA +193 -0
- gog_cli-0.2.1.dist-info/RECORD +25 -0
- gog_cli-0.2.1.dist-info/WHEEL +5 -0
- gog_cli-0.2.1.dist-info/entry_points.txt +2 -0
- gog_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
- gog_cli-0.2.1.dist-info/top_level.txt +1 -0
gog_cli/metadata.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Helpers for normalizing GOG metadata shapes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_PLATFORM_ORDER = ("windows", "mac", "linux")
|
|
9
|
+
_PLATFORM_ALIASES = {
|
|
10
|
+
"windows": "windows",
|
|
11
|
+
"win": "windows",
|
|
12
|
+
"mac": "mac",
|
|
13
|
+
"osx": "mac",
|
|
14
|
+
"macos": "mac",
|
|
15
|
+
"linux": "linux",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize_platforms(values: Any) -> list[str]:
|
|
20
|
+
"""Normalize platform names while preserving a stable display order."""
|
|
21
|
+
if isinstance(values, dict):
|
|
22
|
+
names = [key for key, enabled in values.items() if enabled]
|
|
23
|
+
elif isinstance(values, list | tuple | set):
|
|
24
|
+
names = list(values)
|
|
25
|
+
else:
|
|
26
|
+
names = []
|
|
27
|
+
|
|
28
|
+
normalized = {
|
|
29
|
+
platform
|
|
30
|
+
for value in names
|
|
31
|
+
if (platform := _PLATFORM_ALIASES.get(str(value).strip().lower()))
|
|
32
|
+
}
|
|
33
|
+
return [platform for platform in _PLATFORM_ORDER if platform in normalized]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_download_platforms(product_or_cache: dict[str, Any]) -> list[str]:
|
|
37
|
+
"""Return platforms implied by product download metadata."""
|
|
38
|
+
product = product_or_cache.get("data", product_or_cache)
|
|
39
|
+
if not isinstance(product, dict):
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
platform_values: list[str] = []
|
|
43
|
+
downloads = product.get("downloads", {})
|
|
44
|
+
if isinstance(downloads, dict):
|
|
45
|
+
for entries in downloads.values():
|
|
46
|
+
if not isinstance(entries, list):
|
|
47
|
+
continue
|
|
48
|
+
for entry in entries:
|
|
49
|
+
if not isinstance(entry, dict):
|
|
50
|
+
continue
|
|
51
|
+
os_value = entry.get("os")
|
|
52
|
+
if os_value:
|
|
53
|
+
platform_values.append(str(os_value))
|
|
54
|
+
|
|
55
|
+
compatibility = product.get("content_system_compatibility")
|
|
56
|
+
if isinstance(compatibility, dict):
|
|
57
|
+
platform_values.extend(
|
|
58
|
+
str(platform) for platform, supported in compatibility.items() if supported
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return normalize_platforms(platform_values)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def normalize_genres(*values: Any) -> list[str]:
|
|
65
|
+
"""Normalize genre/category/tag-like metadata into display values."""
|
|
66
|
+
genres: list[str] = []
|
|
67
|
+
seen: set[str] = set()
|
|
68
|
+
for value in values:
|
|
69
|
+
for item in _flatten_metadata_values(value):
|
|
70
|
+
normalized = " ".join(str(item).strip().split())
|
|
71
|
+
if not normalized:
|
|
72
|
+
continue
|
|
73
|
+
if normalized.isdigit():
|
|
74
|
+
continue
|
|
75
|
+
key = normalized.casefold()
|
|
76
|
+
if key not in seen:
|
|
77
|
+
genres.append(normalized)
|
|
78
|
+
seen.add(key)
|
|
79
|
+
return genres
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def normalize_release_date(value: Any) -> str:
|
|
83
|
+
"""Normalize GOG release date shapes to YYYY-MM-DD where possible."""
|
|
84
|
+
if isinstance(value, dict):
|
|
85
|
+
value = value.get("date")
|
|
86
|
+
if not isinstance(value, str) or not value.strip():
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
raw = value.strip()
|
|
90
|
+
candidates = [
|
|
91
|
+
raw,
|
|
92
|
+
raw.replace("Z", "+00:00"),
|
|
93
|
+
raw.replace(" ", "T"),
|
|
94
|
+
]
|
|
95
|
+
for candidate in candidates:
|
|
96
|
+
try:
|
|
97
|
+
parsed = datetime.fromisoformat(candidate)
|
|
98
|
+
except ValueError:
|
|
99
|
+
continue
|
|
100
|
+
if parsed.tzinfo is not None:
|
|
101
|
+
parsed = parsed.astimezone(UTC)
|
|
102
|
+
return parsed.date().isoformat()
|
|
103
|
+
|
|
104
|
+
if len(raw) >= 10 and raw[4] == "-" and raw[7] == "-":
|
|
105
|
+
return raw[:10]
|
|
106
|
+
if len(raw) >= 4 and raw[:4].isdigit():
|
|
107
|
+
return raw[:4]
|
|
108
|
+
return raw
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def release_year(value: Any) -> int | None:
|
|
112
|
+
"""Return the year from a normalized or raw GOG release date."""
|
|
113
|
+
release_date = normalize_release_date(value)
|
|
114
|
+
if len(release_date) >= 4 and release_date[:4].isdigit():
|
|
115
|
+
return int(release_date[:4])
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def extract_size_summary(product_or_cache: dict[str, Any]) -> dict[str, Any]:
|
|
120
|
+
"""Return installer sizes by platform and total extras size from download metadata."""
|
|
121
|
+
product = product_or_cache.get("data", product_or_cache)
|
|
122
|
+
if not isinstance(product, dict):
|
|
123
|
+
return {}
|
|
124
|
+
downloads = product.get("downloads")
|
|
125
|
+
if not isinstance(downloads, dict):
|
|
126
|
+
return {}
|
|
127
|
+
|
|
128
|
+
installer_sizes: dict[str, int] = {}
|
|
129
|
+
for entry in downloads.get("installers", []):
|
|
130
|
+
if not isinstance(entry, dict):
|
|
131
|
+
continue
|
|
132
|
+
platform = _PLATFORM_ALIASES.get(str(entry.get("os", "")).strip().lower())
|
|
133
|
+
if not platform:
|
|
134
|
+
continue
|
|
135
|
+
for file_entry in entry.get("files") or []:
|
|
136
|
+
if not isinstance(file_entry, dict):
|
|
137
|
+
continue
|
|
138
|
+
raw = (
|
|
139
|
+
file_entry["size"] if file_entry.get("size") is not None
|
|
140
|
+
else entry.get("total_size")
|
|
141
|
+
)
|
|
142
|
+
size = _parse_int(raw)
|
|
143
|
+
if size:
|
|
144
|
+
installer_sizes[platform] = installer_sizes.get(platform, 0) + size
|
|
145
|
+
|
|
146
|
+
extras_total = 0
|
|
147
|
+
for entry in downloads.get("bonus_content", []):
|
|
148
|
+
if not isinstance(entry, dict):
|
|
149
|
+
continue
|
|
150
|
+
for file_entry in entry.get("files") or []:
|
|
151
|
+
if not isinstance(file_entry, dict):
|
|
152
|
+
continue
|
|
153
|
+
raw = (
|
|
154
|
+
file_entry["size"] if file_entry.get("size") is not None
|
|
155
|
+
else entry.get("total_size")
|
|
156
|
+
)
|
|
157
|
+
size = _parse_int(raw)
|
|
158
|
+
if size:
|
|
159
|
+
extras_total += size
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"installer_sizes": installer_sizes if installer_sizes else None,
|
|
163
|
+
"extras_size": extras_total if extras_total else None,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _parse_int(value: Any) -> int | None:
|
|
168
|
+
if value is None:
|
|
169
|
+
return None
|
|
170
|
+
try:
|
|
171
|
+
return int(value)
|
|
172
|
+
except (TypeError, ValueError):
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def extract_download_summary(product_or_cache: dict[str, Any]) -> dict[str, Any]:
|
|
177
|
+
"""Return list-facing metadata implied by product download metadata."""
|
|
178
|
+
product = product_or_cache.get("data", product_or_cache)
|
|
179
|
+
if not isinstance(product, dict):
|
|
180
|
+
return {}
|
|
181
|
+
|
|
182
|
+
summary: dict[str, Any] = {
|
|
183
|
+
"platforms": extract_download_platforms(product_or_cache),
|
|
184
|
+
"is_installable": (
|
|
185
|
+
bool(product["is_installable"]) if "is_installable" in product else None
|
|
186
|
+
),
|
|
187
|
+
"download_type": str(product.get("game_type") or ""),
|
|
188
|
+
}
|
|
189
|
+
release_date = normalize_release_date(product.get("release_date"))
|
|
190
|
+
year = release_year(release_date)
|
|
191
|
+
if release_date and year is not None and year >= 1995:
|
|
192
|
+
summary["release_date"] = release_date
|
|
193
|
+
summary["release_year"] = year
|
|
194
|
+
return summary
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _flatten_metadata_values(value: Any) -> list[Any]:
|
|
198
|
+
if value is None:
|
|
199
|
+
return []
|
|
200
|
+
if isinstance(value, str):
|
|
201
|
+
return [part for part in value.split(",") if part.strip()]
|
|
202
|
+
if isinstance(value, dict):
|
|
203
|
+
for key in ("name", "title", "label"):
|
|
204
|
+
if value.get(key):
|
|
205
|
+
return [value[key]]
|
|
206
|
+
return []
|
|
207
|
+
if isinstance(value, list | tuple | set):
|
|
208
|
+
items: list[Any] = []
|
|
209
|
+
for item in value:
|
|
210
|
+
items.extend(_flatten_metadata_values(item))
|
|
211
|
+
return items
|
|
212
|
+
return [value]
|
gog_cli/output.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Machine-readable output contracts and status vocabulary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from gog_cli.state import utc_timestamp
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Format enum
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OutputFormat(StrEnum):
|
|
19
|
+
HUMAN = "human"
|
|
20
|
+
JSON = "json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# JSON envelope
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class JsonEnvelope:
|
|
30
|
+
command: str
|
|
31
|
+
data: Any
|
|
32
|
+
schema_version: int = 1
|
|
33
|
+
generated_at: str = ""
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if not self.generated_at:
|
|
37
|
+
self.generated_at = utc_timestamp()
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"schema_version": self.schema_version,
|
|
42
|
+
"command": self.command,
|
|
43
|
+
"generated_at": self.generated_at,
|
|
44
|
+
"data": self.data,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Output helpers
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def print_json(envelope: JsonEnvelope, *, file: Any = None) -> None:
|
|
54
|
+
if file is None:
|
|
55
|
+
file = sys.stdout
|
|
56
|
+
print(json.dumps(envelope.to_dict(), indent=2), file=file)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def print_human(lines: list[str], *, file: Any = None) -> None:
|
|
60
|
+
if file is None:
|
|
61
|
+
file = sys.stdout
|
|
62
|
+
for line in lines:
|
|
63
|
+
print(line, file=file)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def print_error(message: str, *, file: Any = None) -> None:
|
|
67
|
+
if file is None:
|
|
68
|
+
file = sys.stderr
|
|
69
|
+
print(message, file=file)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Status vocabulary constants
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
# Cache status (TASK-0006)
|
|
77
|
+
CACHE_FRESH = "fresh"
|
|
78
|
+
CACHE_STALE = "stale"
|
|
79
|
+
CACHE_MISSING = "missing"
|
|
80
|
+
CACHE_CORRUPT = "corrupt"
|
|
81
|
+
CACHE_UNSUPPORTED = "unsupported"
|
|
82
|
+
|
|
83
|
+
# Game/backup status (TASK-0002)
|
|
84
|
+
GAME_CURRENT = "current"
|
|
85
|
+
GAME_PARTIAL = "partial"
|
|
86
|
+
GAME_STALE = "stale"
|
|
87
|
+
GAME_MISSING = "missing"
|
|
88
|
+
GAME_UNVERIFIED = "unverified"
|
|
89
|
+
GAME_ERROR = "error"
|
|
90
|
+
|
|
91
|
+
# File status (TASK-0002)
|
|
92
|
+
FILE_PLANNED = "planned"
|
|
93
|
+
FILE_DOWNLOADING = "downloading"
|
|
94
|
+
FILE_PARTIAL = "partial"
|
|
95
|
+
FILE_DOWNLOADED = "downloaded"
|
|
96
|
+
FILE_VERIFIED = "verified"
|
|
97
|
+
FILE_FAILED = "failed"
|
|
98
|
+
FILE_STALE = "stale"
|
|
99
|
+
FILE_MISSING = "missing"
|
gog_cli/prompt.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Interactive and non-interactive selection helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from gog_cli.errors import UsageError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_interactive() -> bool:
|
|
11
|
+
"""Return True when both stdin and stdout are TTYs."""
|
|
12
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def numbered_prompt(items: list[str], prompt: str = "Enter selection:") -> list[int]:
|
|
16
|
+
"""Print a numbered list to stderr and read a selection from stdin.
|
|
17
|
+
|
|
18
|
+
Accepts 'all' to select everything, or comma-separated 1-based numbers.
|
|
19
|
+
Returns a list of 0-based indices. Raises UsageError on invalid input.
|
|
20
|
+
"""
|
|
21
|
+
if not items:
|
|
22
|
+
raise UsageError("No items available for selection")
|
|
23
|
+
|
|
24
|
+
for i, item in enumerate(items, 1):
|
|
25
|
+
print(f" {i}. {item}", file=sys.stderr)
|
|
26
|
+
|
|
27
|
+
print(f"{prompt} ", end="", file=sys.stderr)
|
|
28
|
+
sys.stderr.flush()
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
raw = sys.stdin.readline().strip()
|
|
32
|
+
except (EOFError, KeyboardInterrupt) as exc:
|
|
33
|
+
raise UsageError("Selection cancelled") from exc
|
|
34
|
+
|
|
35
|
+
if not raw:
|
|
36
|
+
raise UsageError("No selection made")
|
|
37
|
+
|
|
38
|
+
if raw.lower() == "all":
|
|
39
|
+
return list(range(len(items)))
|
|
40
|
+
|
|
41
|
+
indices: list[int] = []
|
|
42
|
+
for part in raw.split(","):
|
|
43
|
+
part = part.strip()
|
|
44
|
+
if not part:
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
n = int(part)
|
|
48
|
+
except ValueError:
|
|
49
|
+
raise UsageError(f"Invalid selection {part!r}: expected a number or 'all'") from None
|
|
50
|
+
if n < 1 or n > len(items):
|
|
51
|
+
raise UsageError(f"Selection {n} is out of range 1–{len(items)}")
|
|
52
|
+
indices.append(n - 1)
|
|
53
|
+
|
|
54
|
+
if not indices:
|
|
55
|
+
raise UsageError("No selection made")
|
|
56
|
+
|
|
57
|
+
return indices
|
gog_cli/refresh.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""gog refresh — fetch library and download-metadata caches from GOG API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from gog_cli import log
|
|
10
|
+
from gog_cli.api import GogApiClient
|
|
11
|
+
from gog_cli.auth import FileTokenStore
|
|
12
|
+
from gog_cli.errors import ExitCode, NetworkError
|
|
13
|
+
from gog_cli.metadata import (
|
|
14
|
+
extract_download_summary,
|
|
15
|
+
normalize_genres,
|
|
16
|
+
normalize_platforms,
|
|
17
|
+
normalize_release_date,
|
|
18
|
+
release_year,
|
|
19
|
+
)
|
|
20
|
+
from gog_cli.output import JsonEnvelope, OutputFormat, print_human, print_json
|
|
21
|
+
from gog_cli.state import (
|
|
22
|
+
StateFileMissingError,
|
|
23
|
+
read_json_file,
|
|
24
|
+
resolve_app_paths,
|
|
25
|
+
utc_timestamp,
|
|
26
|
+
write_json_file_atomic,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_log = log.get_logger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize_game(product: dict) -> dict:
|
|
33
|
+
platforms = normalize_platforms(product.get("worksOn", {}))
|
|
34
|
+
release_date = normalize_release_date(product.get("releaseDate"))
|
|
35
|
+
return {
|
|
36
|
+
"product_id": product["id"],
|
|
37
|
+
"title": product.get("title", ""),
|
|
38
|
+
"slug": product.get("slug", ""),
|
|
39
|
+
"platforms": platforms,
|
|
40
|
+
"release_date": release_date,
|
|
41
|
+
"release_year": release_year(release_date),
|
|
42
|
+
"category": str(product.get("category") or ""),
|
|
43
|
+
"genres": normalize_genres(product.get("category"), product.get("tags", [])),
|
|
44
|
+
"image_url": product.get("image", ""),
|
|
45
|
+
"is_pre_order": bool(product.get("isComingSoon", False)),
|
|
46
|
+
"is_game": bool(product.get("isGame", True)),
|
|
47
|
+
"is_movie": bool(product.get("isMovie", False)),
|
|
48
|
+
"is_galaxy_compatible": bool(product.get("isGalaxyCompatible", False)),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _fetch_library(client: GogApiClient, *, progress: bool = False) -> list[dict]:
|
|
53
|
+
page = 1
|
|
54
|
+
games: list[dict] = []
|
|
55
|
+
total_pages: int | None = None
|
|
56
|
+
while True:
|
|
57
|
+
page_label = f"{page}/{total_pages}" if total_pages else str(page)
|
|
58
|
+
_print_progress(progress, f"Fetching library page {page_label}...")
|
|
59
|
+
data = client.get_library_page(page)
|
|
60
|
+
total_pages = int(data.get("totalPages", 1))
|
|
61
|
+
for product in data.get("products", []):
|
|
62
|
+
games.append(_normalize_game(product))
|
|
63
|
+
if page >= total_pages:
|
|
64
|
+
break
|
|
65
|
+
page += 1
|
|
66
|
+
_print_progress(progress, f"Fetched {len(games)} library entries.")
|
|
67
|
+
return games
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _compute_delta(
|
|
71
|
+
old_games: list[dict], new_games: list[dict]
|
|
72
|
+
) -> tuple[int, int, int]:
|
|
73
|
+
old_by_id = {g["product_id"]: g for g in old_games}
|
|
74
|
+
new_by_id = {g["product_id"]: g for g in new_games}
|
|
75
|
+
added = sum(1 for pid in new_by_id if pid not in old_by_id)
|
|
76
|
+
removed = sum(1 for pid in old_by_id if pid not in new_by_id)
|
|
77
|
+
changed = sum(
|
|
78
|
+
1
|
|
79
|
+
for pid, g in new_by_id.items()
|
|
80
|
+
if pid in old_by_id
|
|
81
|
+
and (
|
|
82
|
+
g["title"] != old_by_id[pid]["title"]
|
|
83
|
+
or g["slug"] != old_by_id[pid]["slug"]
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return added, removed, changed
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _load_old_games(library_cache: Path) -> list[dict]:
|
|
90
|
+
try:
|
|
91
|
+
data = read_json_file(library_cache)
|
|
92
|
+
return data.get("games", [])
|
|
93
|
+
except (StateFileMissingError, Exception):
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def handle_refresh(args: argparse.Namespace) -> int:
|
|
98
|
+
paths = resolve_app_paths()
|
|
99
|
+
store = FileTokenStore(paths)
|
|
100
|
+
client = GogApiClient(store)
|
|
101
|
+
|
|
102
|
+
# load tokens early so AuthError surfaces before any network calls
|
|
103
|
+
store.load_tokens()
|
|
104
|
+
|
|
105
|
+
output_format = OutputFormat(getattr(args, "output_format", "human"))
|
|
106
|
+
force = getattr(args, "force", False)
|
|
107
|
+
|
|
108
|
+
old_games = _load_old_games(paths.library_cache)
|
|
109
|
+
|
|
110
|
+
progress = output_format == OutputFormat.HUMAN
|
|
111
|
+
games = _fetch_library(client, progress=progress)
|
|
112
|
+
|
|
113
|
+
failures: list[str] = []
|
|
114
|
+
fetched_at = utc_timestamp()
|
|
115
|
+
total_games = len(games)
|
|
116
|
+
_print_progress(progress, f"Refreshing download metadata for {total_games} games...")
|
|
117
|
+
|
|
118
|
+
for index, game in enumerate(games, start=1):
|
|
119
|
+
product_id = game["product_id"]
|
|
120
|
+
cache_path = paths.download_cache(str(product_id))
|
|
121
|
+
title = str(game.get("title") or product_id)
|
|
122
|
+
|
|
123
|
+
if not force and cache_path.exists():
|
|
124
|
+
_enrich_game_from_download_cache(game, cache_path)
|
|
125
|
+
_print_metadata_progress(progress, index, total_games, title, cached=True)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
download_data = client.get_product_downloads(product_id)
|
|
130
|
+
except (NetworkError, Exception) as exc: # noqa: BLE001
|
|
131
|
+
failures.append(f"{game['title']} ({product_id}): {exc}")
|
|
132
|
+
_log.warning("download fetch failed for %s: %s", product_id, exc)
|
|
133
|
+
_print_metadata_progress(progress, index, total_games, title, failed=True)
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
_enrich_game_from_download_data(game, download_data)
|
|
137
|
+
_print_metadata_progress(progress, index, total_games, title)
|
|
138
|
+
|
|
139
|
+
write_json_file_atomic(
|
|
140
|
+
cache_path,
|
|
141
|
+
{
|
|
142
|
+
"fetched_at": fetched_at,
|
|
143
|
+
"product_id": product_id,
|
|
144
|
+
"data": download_data,
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
write_json_file_atomic(
|
|
149
|
+
paths.library_cache,
|
|
150
|
+
{"fetched_at": fetched_at, "games": games},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
added, removed, changed = _compute_delta(old_games, games)
|
|
154
|
+
total = len(games)
|
|
155
|
+
|
|
156
|
+
if output_format == OutputFormat.JSON:
|
|
157
|
+
print_json(
|
|
158
|
+
JsonEnvelope(
|
|
159
|
+
command="refresh",
|
|
160
|
+
data={
|
|
161
|
+
"total": total,
|
|
162
|
+
"added": added,
|
|
163
|
+
"removed": removed,
|
|
164
|
+
"changed": changed,
|
|
165
|
+
"failures": failures,
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
print_human(
|
|
171
|
+
[
|
|
172
|
+
(
|
|
173
|
+
f"Refreshed {total} games "
|
|
174
|
+
f"(+{added} added, -{removed} removed, ~{changed} changed)."
|
|
175
|
+
)
|
|
176
|
+
]
|
|
177
|
+
)
|
|
178
|
+
for msg in failures:
|
|
179
|
+
print(f" warning: {msg}", file=sys.stderr)
|
|
180
|
+
|
|
181
|
+
if failures:
|
|
182
|
+
return ExitCode.NETWORK
|
|
183
|
+
return ExitCode.SUCCESS
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _print_progress(enabled: bool, message: str) -> None:
|
|
187
|
+
if enabled:
|
|
188
|
+
print(message, file=sys.stderr, flush=True)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _print_metadata_progress(
|
|
192
|
+
enabled: bool,
|
|
193
|
+
index: int,
|
|
194
|
+
total: int,
|
|
195
|
+
title: str,
|
|
196
|
+
*,
|
|
197
|
+
cached: bool = False,
|
|
198
|
+
failed: bool = False,
|
|
199
|
+
) -> None:
|
|
200
|
+
if not enabled:
|
|
201
|
+
return
|
|
202
|
+
if index != 1 and index != total and index % 10 != 0 and not failed:
|
|
203
|
+
return
|
|
204
|
+
status = "cached" if cached else "fetched"
|
|
205
|
+
if failed:
|
|
206
|
+
status = "failed"
|
|
207
|
+
print(
|
|
208
|
+
f"Download metadata {index}/{total}: {status} {title}",
|
|
209
|
+
file=sys.stderr,
|
|
210
|
+
flush=True,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _enrich_game_from_download_cache(game: dict, cache_path: Path) -> None:
|
|
215
|
+
try:
|
|
216
|
+
cache = read_json_file(cache_path)
|
|
217
|
+
except Exception: # noqa: BLE001
|
|
218
|
+
return
|
|
219
|
+
if isinstance(cache, dict):
|
|
220
|
+
_enrich_game_from_download_data(game, cache)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _enrich_game_from_download_data(game: dict, download_data: dict) -> None:
|
|
224
|
+
summary = extract_download_summary(download_data)
|
|
225
|
+
platforms = summary.get("platforms")
|
|
226
|
+
if platforms:
|
|
227
|
+
game["platforms"] = platforms
|
|
228
|
+
for key in ("is_installable", "download_type"):
|
|
229
|
+
value = summary.get(key)
|
|
230
|
+
if value not in (None, ""):
|
|
231
|
+
game[key] = value
|