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/listing.py
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
"""List commands for purchased and backed-up games, and public catalog search."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from difflib import SequenceMatcher
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from gog_cli.api import search_catalog
|
|
13
|
+
from gog_cli.backup import BackupLayout
|
|
14
|
+
from gog_cli.config import load_config
|
|
15
|
+
from gog_cli.errors import ExitCode, UsageError
|
|
16
|
+
from gog_cli.metadata import (
|
|
17
|
+
extract_download_summary,
|
|
18
|
+
extract_size_summary,
|
|
19
|
+
normalize_genres,
|
|
20
|
+
normalize_platforms,
|
|
21
|
+
)
|
|
22
|
+
from gog_cli.output import (
|
|
23
|
+
GAME_CURRENT,
|
|
24
|
+
GAME_ERROR,
|
|
25
|
+
GAME_MISSING,
|
|
26
|
+
GAME_PARTIAL,
|
|
27
|
+
GAME_STALE,
|
|
28
|
+
GAME_UNVERIFIED,
|
|
29
|
+
JsonEnvelope,
|
|
30
|
+
OutputFormat,
|
|
31
|
+
print_human,
|
|
32
|
+
print_json,
|
|
33
|
+
)
|
|
34
|
+
from gog_cli.state import (
|
|
35
|
+
StateFileCorruptError,
|
|
36
|
+
StateFileInvalidError,
|
|
37
|
+
StateFileMissingError,
|
|
38
|
+
read_json_file,
|
|
39
|
+
resolve_app_paths,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_CACHE_MAX_AGE = timedelta(hours=24)
|
|
43
|
+
_SUPPORTED_MANIFEST_SCHEMA = 1
|
|
44
|
+
_PLATFORM_COLS: list[tuple[str, str]] = [("windows", "W"), ("mac", "M"), ("linux", "L")]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def handle_list_purchased(args: argparse.Namespace) -> int:
|
|
48
|
+
paths = resolve_app_paths()
|
|
49
|
+
try:
|
|
50
|
+
cache = _load_library_cache(paths.library_cache)
|
|
51
|
+
except StateFileMissingError:
|
|
52
|
+
print("Purchased library cache is missing. Run `gog refresh`.", file=sys.stderr)
|
|
53
|
+
return ExitCode.FAILURE
|
|
54
|
+
except (StateFileCorruptError, StateFileInvalidError) as exc:
|
|
55
|
+
print(f"Purchased library cache is unreadable: {exc}", file=sys.stderr)
|
|
56
|
+
return ExitCode.PARSER
|
|
57
|
+
|
|
58
|
+
games = [_enrich_game_metadata(game, paths) for game in cache["games"]]
|
|
59
|
+
games = _apply_purchased_filters(games, args)
|
|
60
|
+
games = _sort_purchased(games, getattr(args, "sort", None))
|
|
61
|
+
fetched_at = cache.get("fetched_at", "")
|
|
62
|
+
output_format = OutputFormat(getattr(args, "output_format", "human"))
|
|
63
|
+
|
|
64
|
+
if output_format == OutputFormat.JSON:
|
|
65
|
+
print_json(JsonEnvelope(command="list purchased", data=games))
|
|
66
|
+
return ExitCode.SUCCESS
|
|
67
|
+
|
|
68
|
+
active_platforms = normalize_platforms(getattr(args, "platforms", []))
|
|
69
|
+
plat_cols: list[tuple[str, str]] = [
|
|
70
|
+
(k, h) for k, h in _PLATFORM_COLS
|
|
71
|
+
if not active_platforms or k in active_platforms
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
plat_header = " ".join(f"{h:>9}" for _, h in plat_cols)
|
|
75
|
+
plat_sep = " ".join(f"{'-' * 9}" for _ in plat_cols)
|
|
76
|
+
header = (
|
|
77
|
+
f"{'ID':>10} {'Title':<34} {'Year':>4} {'Genre':<18}"
|
|
78
|
+
f" {plat_header} {'Extras':>9} {'Total':>9}"
|
|
79
|
+
)
|
|
80
|
+
sep = f"{'-' * 10} {'-' * 34} {'-' * 4} {'-' * 18} {plat_sep} {'-' * 9} {'-' * 9}"
|
|
81
|
+
lines: list[str] = [header, sep]
|
|
82
|
+
|
|
83
|
+
for game in games:
|
|
84
|
+
inst = game.get("installer_sizes") or {}
|
|
85
|
+
extras = game.get("extras_size") or 0
|
|
86
|
+
plat_total = sum(inst.get(k) or 0 for k, _ in plat_cols)
|
|
87
|
+
total = plat_total + extras
|
|
88
|
+
plat_cells = " ".join(f"{_format_size(inst.get(k)):>9}" for k, _ in plat_cols)
|
|
89
|
+
lines.append(
|
|
90
|
+
f"{str(game.get('product_id', '')):>10} "
|
|
91
|
+
f"{str(game.get('title', '')):<34.34} "
|
|
92
|
+
f"{_format_year(game.get('release_year')):>4} "
|
|
93
|
+
f"{_format_genres(game.get('genres', [])):<18.18} "
|
|
94
|
+
f"{plat_cells} "
|
|
95
|
+
f"{_format_size(extras or None):>9} "
|
|
96
|
+
f"{_format_size(total or None):>9}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
totals_by_plat = {
|
|
100
|
+
k: sum((g.get("installer_sizes") or {}).get(k) or 0 for g in games)
|
|
101
|
+
for k, _ in plat_cols
|
|
102
|
+
}
|
|
103
|
+
total_e = sum(game.get("extras_size") or 0 for game in games)
|
|
104
|
+
grand_total = sum(totals_by_plat.values()) + total_e
|
|
105
|
+
plat_total_cells = " ".join(
|
|
106
|
+
f"{_format_size(totals_by_plat[k] or None):>9}" for k, _ in plat_cols
|
|
107
|
+
)
|
|
108
|
+
lines.append(sep)
|
|
109
|
+
lines.append(
|
|
110
|
+
f"{'Totals':<72} "
|
|
111
|
+
f"{plat_total_cells} "
|
|
112
|
+
f"{_format_size(total_e or None):>9} "
|
|
113
|
+
f"{_format_size(grand_total or None):>9}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
cache_age = _format_cache_age(fetched_at)
|
|
117
|
+
lines.append(f"{len(games)} games. Cache age: {cache_age}.")
|
|
118
|
+
|
|
119
|
+
print_human(lines)
|
|
120
|
+
|
|
121
|
+
age = _parse_timestamp(fetched_at)
|
|
122
|
+
if age is None or datetime.now(UTC) - age > _CACHE_MAX_AGE:
|
|
123
|
+
warning = "Purchased library cache is older than 24h. Run `gog refresh`."
|
|
124
|
+
if age is None:
|
|
125
|
+
warning = "Purchased library cache age is unknown. Run `gog refresh`."
|
|
126
|
+
print(warning, file=sys.stderr)
|
|
127
|
+
|
|
128
|
+
return ExitCode.SUCCESS
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def handle_list_backed_up(args: argparse.Namespace) -> int:
|
|
132
|
+
paths = resolve_app_paths()
|
|
133
|
+
config = load_config(paths)
|
|
134
|
+
destination = getattr(args, "destination", None) or config.destination
|
|
135
|
+
if destination is None:
|
|
136
|
+
raise UsageError(
|
|
137
|
+
"Backup destination is required. Use --destination or set it in config."
|
|
138
|
+
)
|
|
139
|
+
layout = BackupLayout(Path(destination).expanduser())
|
|
140
|
+
try:
|
|
141
|
+
manifest = _load_manifest(layout.manifest_file)
|
|
142
|
+
except StateFileMissingError:
|
|
143
|
+
print(
|
|
144
|
+
f"No backup manifest exists at {layout.manifest_file}. Run `gog backup`.",
|
|
145
|
+
file=sys.stderr,
|
|
146
|
+
)
|
|
147
|
+
return ExitCode.FAILURE
|
|
148
|
+
except (StateFileCorruptError, StateFileInvalidError) as exc:
|
|
149
|
+
print(f"Backup manifest is unreadable: {exc}", file=sys.stderr)
|
|
150
|
+
return ExitCode.PARSER
|
|
151
|
+
|
|
152
|
+
output_format = OutputFormat(getattr(args, "output_format", "human"))
|
|
153
|
+
games = [_normalize_manifest_game(game) for game in manifest["games"]]
|
|
154
|
+
games = _sort_backed_up(games, getattr(args, "sort", None))
|
|
155
|
+
|
|
156
|
+
if output_format == OutputFormat.JSON:
|
|
157
|
+
print_json(JsonEnvelope(command="list backup", data=games))
|
|
158
|
+
return ExitCode.SUCCESS
|
|
159
|
+
|
|
160
|
+
lines: list[str] = [
|
|
161
|
+
f"{'ID':>10} {'Title':<28} {'Game Dir':<20} {'Files':>5} {'Size':>8} Status",
|
|
162
|
+
f"{'-' * 10} {'-' * 28} {'-' * 20} {'-' * 5} {'-' * 8} {'-' * 6}",
|
|
163
|
+
]
|
|
164
|
+
for game in games:
|
|
165
|
+
lines.append(
|
|
166
|
+
f"{str(game.get('product_id', '')):>10} "
|
|
167
|
+
f"{str(game.get('title', '')):<28.28} "
|
|
168
|
+
f"{str(game.get('directory_display', '')):<20.20} "
|
|
169
|
+
f"{int(game.get('files', 0)):>5} "
|
|
170
|
+
f"{_format_size(game.get('total_size_bytes')):>8} "
|
|
171
|
+
f"{game.get('status', GAME_MISSING)}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
total_files = sum(game.get("files") or 0 for game in games)
|
|
175
|
+
total_size = sum(game.get("total_size_bytes") or 0 for game in games)
|
|
176
|
+
lines.append(f"{'-' * 10} {'-' * 28} {'-' * 20} {'-' * 5} {'-' * 8} {'-' * 6}")
|
|
177
|
+
lines.append(
|
|
178
|
+
f"{'Totals':<62} "
|
|
179
|
+
f"{total_files:>5} "
|
|
180
|
+
f"{_format_size(total_size or None):>8}"
|
|
181
|
+
)
|
|
182
|
+
lines.append(f"{len(games)} games backed up to {layout.root}.")
|
|
183
|
+
print_human(lines)
|
|
184
|
+
return ExitCode.SUCCESS
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _load_library_cache(path: Path) -> dict[str, Any]:
|
|
188
|
+
data = read_json_file(path)
|
|
189
|
+
if not isinstance(data, dict):
|
|
190
|
+
raise StateFileInvalidError(f"library cache must contain an object: {path}")
|
|
191
|
+
games = data.get("games")
|
|
192
|
+
if not isinstance(games, list):
|
|
193
|
+
raise StateFileInvalidError(f"library cache must contain a games list: {path}")
|
|
194
|
+
return data
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _load_manifest(path: Path) -> dict[str, Any]:
|
|
198
|
+
data = read_json_file(path)
|
|
199
|
+
if not isinstance(data, dict):
|
|
200
|
+
raise StateFileInvalidError(f"manifest must contain an object: {path}")
|
|
201
|
+
schema_version = data.get("schema_version")
|
|
202
|
+
if schema_version != _SUPPORTED_MANIFEST_SCHEMA:
|
|
203
|
+
raise StateFileInvalidError(f"unsupported manifest schema: {schema_version!r}")
|
|
204
|
+
games = data.get("games")
|
|
205
|
+
if not isinstance(games, list):
|
|
206
|
+
raise StateFileInvalidError(f"manifest must contain a games list: {path}")
|
|
207
|
+
return data
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _enrich_game_metadata(game: dict[str, Any], paths: Any) -> dict[str, Any]:
|
|
211
|
+
enriched = {
|
|
212
|
+
**game,
|
|
213
|
+
"owned": True,
|
|
214
|
+
"platforms": normalize_platforms(game.get("platforms", [])),
|
|
215
|
+
"genres": normalize_genres(game.get("genres", []), game.get("category", "")),
|
|
216
|
+
}
|
|
217
|
+
product_id = game.get("product_id")
|
|
218
|
+
if product_id is None:
|
|
219
|
+
return enriched
|
|
220
|
+
try:
|
|
221
|
+
download_cache = read_json_file(paths.download_cache(str(product_id)))
|
|
222
|
+
except (StateFileMissingError, StateFileCorruptError, StateFileInvalidError):
|
|
223
|
+
return enriched
|
|
224
|
+
|
|
225
|
+
summary = extract_download_summary(download_cache)
|
|
226
|
+
size_summary = extract_size_summary(download_cache)
|
|
227
|
+
if summary.get("platforms") and not enriched.get("platforms"):
|
|
228
|
+
enriched["platforms"] = summary["platforms"]
|
|
229
|
+
for key in ("release_date", "release_year", "is_installable", "download_type"):
|
|
230
|
+
value = summary.get(key)
|
|
231
|
+
if value not in (None, "") and enriched.get(key) in (None, ""):
|
|
232
|
+
enriched[key] = value
|
|
233
|
+
enriched["installer_sizes"] = size_summary.get("installer_sizes")
|
|
234
|
+
enriched["extras_size"] = size_summary.get("extras_size")
|
|
235
|
+
return enriched
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _apply_purchased_filters(
|
|
239
|
+
games: list[dict[str, Any]],
|
|
240
|
+
args: argparse.Namespace,
|
|
241
|
+
) -> list[dict[str, Any]]:
|
|
242
|
+
platforms = normalize_platforms(_split_filter_values(getattr(args, "platforms", [])))
|
|
243
|
+
genres = [
|
|
244
|
+
value.casefold()
|
|
245
|
+
for value in _split_filter_values(getattr(args, "genres", []))
|
|
246
|
+
if value
|
|
247
|
+
]
|
|
248
|
+
include_unknown_genre = bool(getattr(args, "include_unknown_genre", False))
|
|
249
|
+
year_range = _parse_year_range(getattr(args, "year", None))
|
|
250
|
+
include_unknown_year = bool(getattr(args, "include_unknown_year", False))
|
|
251
|
+
search = str(getattr(args, "search", "") or "").strip()
|
|
252
|
+
|
|
253
|
+
filtered = [
|
|
254
|
+
game
|
|
255
|
+
for game in games
|
|
256
|
+
if _matches_platforms(game, platforms)
|
|
257
|
+
and _matches_year(game, year_range, include_unknown=include_unknown_year)
|
|
258
|
+
and _matches_genres(game, genres, include_unknown=include_unknown_genre)
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
if not search:
|
|
262
|
+
return filtered
|
|
263
|
+
|
|
264
|
+
scored = [
|
|
265
|
+
(score, game)
|
|
266
|
+
for game in filtered
|
|
267
|
+
if (score := _title_search_score(search, game)) > 0
|
|
268
|
+
]
|
|
269
|
+
scored.sort(
|
|
270
|
+
key=lambda item: (
|
|
271
|
+
-item[0],
|
|
272
|
+
str(item[1].get("title", "")).casefold(),
|
|
273
|
+
str(item[1].get("product_id", "")),
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
return [game for _, game in scored]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _split_filter_values(values: Any) -> list[str]:
|
|
280
|
+
if isinstance(values, str):
|
|
281
|
+
values = [values]
|
|
282
|
+
if not isinstance(values, list | tuple | set):
|
|
283
|
+
return []
|
|
284
|
+
result: list[str] = []
|
|
285
|
+
for value in values:
|
|
286
|
+
for part in str(value).split(","):
|
|
287
|
+
normalized = part.strip()
|
|
288
|
+
if normalized:
|
|
289
|
+
result.append(normalized)
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _parse_year_range(value: Any) -> tuple[int | None, int | None] | None:
|
|
294
|
+
if value in (None, ""):
|
|
295
|
+
return None
|
|
296
|
+
raw = str(value).strip()
|
|
297
|
+
if ".." not in raw:
|
|
298
|
+
if raw.isdigit() and len(raw) == 4:
|
|
299
|
+
year = int(raw)
|
|
300
|
+
return year, year
|
|
301
|
+
raise UsageError("Year filter must be YYYY or START..END")
|
|
302
|
+
|
|
303
|
+
start_raw, end_raw = raw.split("..", 1)
|
|
304
|
+
if ".." in end_raw:
|
|
305
|
+
raise UsageError("Year filter must contain only one '..' range separator")
|
|
306
|
+
start = _parse_optional_year(start_raw, "start")
|
|
307
|
+
end = _parse_optional_year(end_raw, "end")
|
|
308
|
+
if start is None and end is None:
|
|
309
|
+
raise UsageError("Year filter must include a start or end year")
|
|
310
|
+
if start is not None and end is not None and start > end:
|
|
311
|
+
raise UsageError("Year filter start must be before end")
|
|
312
|
+
return start, end
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _parse_optional_year(value: str, label: str) -> int | None:
|
|
316
|
+
raw = value.strip()
|
|
317
|
+
if not raw:
|
|
318
|
+
return None
|
|
319
|
+
if not raw.isdigit() or len(raw) != 4:
|
|
320
|
+
raise UsageError(f"Year filter {label} must be a four-digit year")
|
|
321
|
+
return int(raw)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _matches_platforms(game: dict[str, Any], platforms: list[str]) -> bool:
|
|
325
|
+
if not platforms:
|
|
326
|
+
return True
|
|
327
|
+
game_platforms = set(normalize_platforms(game.get("platforms", [])))
|
|
328
|
+
return any(platform in game_platforms for platform in platforms)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _matches_year(
|
|
332
|
+
game: dict[str, Any],
|
|
333
|
+
year_range: tuple[int | None, int | None] | None,
|
|
334
|
+
*,
|
|
335
|
+
include_unknown: bool,
|
|
336
|
+
) -> bool:
|
|
337
|
+
if year_range is None:
|
|
338
|
+
return True
|
|
339
|
+
year = game.get("release_year")
|
|
340
|
+
if not isinstance(year, int):
|
|
341
|
+
return include_unknown
|
|
342
|
+
start, end = year_range
|
|
343
|
+
if start is not None and year < start:
|
|
344
|
+
return False
|
|
345
|
+
return not (end is not None and year > end)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _matches_genres(
|
|
349
|
+
game: dict[str, Any],
|
|
350
|
+
genres: list[str],
|
|
351
|
+
*,
|
|
352
|
+
include_unknown: bool,
|
|
353
|
+
) -> bool:
|
|
354
|
+
if not genres:
|
|
355
|
+
return True
|
|
356
|
+
game_genres = {str(genre).casefold() for genre in game.get("genres", [])}
|
|
357
|
+
if not game_genres:
|
|
358
|
+
return include_unknown
|
|
359
|
+
return any(genre in game_genres for genre in genres)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _title_search_score(query: str, game: dict[str, Any]) -> int:
|
|
363
|
+
normalized_query = _search_key(query)
|
|
364
|
+
if not normalized_query:
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
title = _search_key(game.get("title", ""))
|
|
368
|
+
slug = _search_key(str(game.get("slug", "")).replace("_", " ").replace("-", " "))
|
|
369
|
+
candidates = [candidate for candidate in (title, slug) if candidate]
|
|
370
|
+
if not candidates:
|
|
371
|
+
return 0
|
|
372
|
+
|
|
373
|
+
scores: list[int] = []
|
|
374
|
+
for candidate in candidates:
|
|
375
|
+
if candidate == normalized_query:
|
|
376
|
+
scores.append(1000)
|
|
377
|
+
elif candidate.startswith(normalized_query):
|
|
378
|
+
scores.append(900 - min(len(candidate) - len(normalized_query), 100))
|
|
379
|
+
elif normalized_query in candidate:
|
|
380
|
+
scores.append(800 - min(candidate.index(normalized_query), 100))
|
|
381
|
+
else:
|
|
382
|
+
ratio = _best_fuzzy_ratio(normalized_query, candidate)
|
|
383
|
+
if ratio >= 0.78:
|
|
384
|
+
scores.append(int(ratio * 700))
|
|
385
|
+
return max(scores, default=0)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _search_key(value: Any) -> str:
|
|
389
|
+
return " ".join(str(value).casefold().split())
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _best_fuzzy_ratio(query: str, candidate: str) -> float:
|
|
393
|
+
choices = [candidate, *candidate.split()]
|
|
394
|
+
words = candidate.split()
|
|
395
|
+
if len(words) > 1:
|
|
396
|
+
choices.extend(
|
|
397
|
+
f"{left} {right}" for left, right in zip(words, words[1:], strict=False)
|
|
398
|
+
)
|
|
399
|
+
return max(SequenceMatcher(None, query, choice).ratio() for choice in choices)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _sort_purchased(games: list[dict[str, Any]], key: str | None) -> list[dict[str, Any]]:
|
|
403
|
+
if key == "title":
|
|
404
|
+
return sorted(games, key=lambda g: str(g.get("title", "")).casefold())
|
|
405
|
+
if key == "year":
|
|
406
|
+
return sorted(games, key=lambda g: (
|
|
407
|
+
g.get("release_year") is None, g.get("release_year") or 0
|
|
408
|
+
))
|
|
409
|
+
if key == "size":
|
|
410
|
+
def _total(g: dict[str, Any]) -> int:
|
|
411
|
+
inst = g.get("installer_sizes") or {}
|
|
412
|
+
return sum(inst.values()) + (g.get("extras_size") or 0)
|
|
413
|
+
return sorted(games, key=_total, reverse=True)
|
|
414
|
+
return games
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _sort_backed_up(games: list[dict[str, Any]], key: str | None) -> list[dict[str, Any]]:
|
|
418
|
+
if key == "title":
|
|
419
|
+
return sorted(games, key=lambda g: str(g.get("title", "")).casefold())
|
|
420
|
+
if key == "size":
|
|
421
|
+
return sorted(games, key=lambda g: g.get("total_size_bytes") or 0, reverse=True)
|
|
422
|
+
if key == "status":
|
|
423
|
+
return sorted(games, key=lambda g: str(g.get("status", "")))
|
|
424
|
+
if key == "files":
|
|
425
|
+
return sorted(games, key=lambda g: int(g.get("files", 0)), reverse=True)
|
|
426
|
+
return games
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _normalize_manifest_game(game: dict[str, Any]) -> dict[str, Any]:
|
|
430
|
+
files = game.get("files", [])
|
|
431
|
+
status = _normalize_game_status(game.get("status"), files)
|
|
432
|
+
directory = str(game.get("directory", game.get("slug", "")) or "")
|
|
433
|
+
if directory and not directory.endswith("/"):
|
|
434
|
+
directory_display = f"{directory}/"
|
|
435
|
+
else:
|
|
436
|
+
directory_display = directory or "-"
|
|
437
|
+
total_size_bytes = sum(
|
|
438
|
+
int(f.get("expected_size") or f.get("size_bytes") or 0)
|
|
439
|
+
for f in files
|
|
440
|
+
if isinstance(f, dict)
|
|
441
|
+
)
|
|
442
|
+
return {
|
|
443
|
+
"product_id": game.get("product_id", ""),
|
|
444
|
+
"title": game.get("title", ""),
|
|
445
|
+
"directory": directory,
|
|
446
|
+
"directory_display": directory_display,
|
|
447
|
+
"files": len(files) if isinstance(files, list) else 0,
|
|
448
|
+
"total_size_bytes": total_size_bytes or None,
|
|
449
|
+
"status": status,
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _normalize_game_status(status: Any, files: Any) -> str:
|
|
454
|
+
if status in {
|
|
455
|
+
GAME_CURRENT,
|
|
456
|
+
GAME_PARTIAL,
|
|
457
|
+
GAME_STALE,
|
|
458
|
+
GAME_MISSING,
|
|
459
|
+
GAME_UNVERIFIED,
|
|
460
|
+
GAME_ERROR,
|
|
461
|
+
}:
|
|
462
|
+
return str(status)
|
|
463
|
+
|
|
464
|
+
if not isinstance(files, list) or not files:
|
|
465
|
+
return GAME_MISSING
|
|
466
|
+
|
|
467
|
+
file_statuses = {file.get("status") for file in files if isinstance(file, dict)}
|
|
468
|
+
if "failed" in file_statuses:
|
|
469
|
+
return GAME_ERROR
|
|
470
|
+
if "partial" in file_statuses:
|
|
471
|
+
return GAME_PARTIAL
|
|
472
|
+
if "stale" in file_statuses:
|
|
473
|
+
return GAME_STALE
|
|
474
|
+
if "downloaded" in file_statuses:
|
|
475
|
+
return GAME_UNVERIFIED
|
|
476
|
+
if file_statuses <= {"verified"}:
|
|
477
|
+
return GAME_CURRENT
|
|
478
|
+
return GAME_MISSING
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _format_size(n: int | None) -> str:
|
|
482
|
+
if not n:
|
|
483
|
+
return "-"
|
|
484
|
+
units = ("B", "KB", "MB", "GB", "TB")
|
|
485
|
+
x = float(n)
|
|
486
|
+
for unit in units[:-1]:
|
|
487
|
+
if x < 1024:
|
|
488
|
+
if unit == "B":
|
|
489
|
+
return f"{int(x)} B"
|
|
490
|
+
return f"{x:.1f} {unit}"
|
|
491
|
+
x /= 1024
|
|
492
|
+
return f"{x:.2f} {units[-1]}"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _format_platforms(platforms: Any) -> str:
|
|
496
|
+
if not isinstance(platforms, list) or not platforms:
|
|
497
|
+
return "-"
|
|
498
|
+
return ", ".join(str(platform) for platform in platforms)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _format_genres(genres: Any) -> str:
|
|
502
|
+
if not isinstance(genres, list) or not genres:
|
|
503
|
+
return "-"
|
|
504
|
+
return ", ".join(str(genre) for genre in genres)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _format_year(year: Any) -> str:
|
|
508
|
+
if isinstance(year, int):
|
|
509
|
+
return str(year)
|
|
510
|
+
return "-"
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _parse_timestamp(value: str) -> datetime | None:
|
|
514
|
+
if not isinstance(value, str) or not value:
|
|
515
|
+
return None
|
|
516
|
+
try:
|
|
517
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
518
|
+
except ValueError:
|
|
519
|
+
return None
|
|
520
|
+
if parsed.tzinfo is None:
|
|
521
|
+
return parsed.replace(tzinfo=UTC)
|
|
522
|
+
return parsed.astimezone(UTC)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def handle_search_catalog(args: argparse.Namespace) -> int:
|
|
526
|
+
query = str(getattr(args, "query", "") or "").strip()
|
|
527
|
+
|
|
528
|
+
paths = resolve_app_paths()
|
|
529
|
+
owned_ids: set[int] | None = None
|
|
530
|
+
try:
|
|
531
|
+
cache = _load_library_cache(paths.library_cache)
|
|
532
|
+
owned_ids = {
|
|
533
|
+
int(g["product_id"])
|
|
534
|
+
for g in cache["games"]
|
|
535
|
+
if g.get("product_id") is not None
|
|
536
|
+
}
|
|
537
|
+
except (StateFileMissingError, StateFileCorruptError, StateFileInvalidError):
|
|
538
|
+
pass
|
|
539
|
+
|
|
540
|
+
raw = search_catalog(query)
|
|
541
|
+
games = [_normalize_catalog_result(p, owned_ids) for p in raw.get("products", [])]
|
|
542
|
+
games = _apply_catalog_filters(games, args)
|
|
543
|
+
|
|
544
|
+
output_format = OutputFormat(getattr(args, "output_format", "human"))
|
|
545
|
+
if output_format == OutputFormat.JSON:
|
|
546
|
+
print_json(JsonEnvelope(command="search", data=games))
|
|
547
|
+
return ExitCode.SUCCESS
|
|
548
|
+
|
|
549
|
+
if not games:
|
|
550
|
+
print_human([f'No results for "{query}".'])
|
|
551
|
+
return ExitCode.SUCCESS
|
|
552
|
+
|
|
553
|
+
lines: list[str] = [
|
|
554
|
+
f"{'ID':>10} {'Title':<34} {'Year':>4} {'Genre':<18} {'Platforms':<25} Owned",
|
|
555
|
+
f"{'-' * 10} {'-' * 34} {'-' * 4} {'-' * 18} {'-' * 25} {'-' * 5}",
|
|
556
|
+
]
|
|
557
|
+
for game in games:
|
|
558
|
+
lines.append(
|
|
559
|
+
f"{str(game.get('product_id', '') or ''):>10} "
|
|
560
|
+
f"{str(game.get('title', '')):<34.34} "
|
|
561
|
+
f"{_format_year(game.get('release_year')):>4} "
|
|
562
|
+
f"{_format_genres(game.get('genres', [])):<18.18} "
|
|
563
|
+
f"{_format_platforms(game.get('platforms', [])):<25.25} "
|
|
564
|
+
f"{_format_owned(game.get('owned'))}"
|
|
565
|
+
)
|
|
566
|
+
lines.append(f'{len(games)} result(s) for "{query}".')
|
|
567
|
+
print_human(lines)
|
|
568
|
+
return ExitCode.SUCCESS
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _normalize_catalog_result(
|
|
572
|
+
product: dict[str, Any], owned_ids: set[int] | None
|
|
573
|
+
) -> dict[str, Any]:
|
|
574
|
+
# catalog.gog.com/v1 returns id as a string
|
|
575
|
+
product_id_raw = product.get("id")
|
|
576
|
+
if isinstance(product_id_raw, str) and product_id_raw.isdigit():
|
|
577
|
+
product_id: int | None = int(product_id_raw)
|
|
578
|
+
elif isinstance(product_id_raw, int):
|
|
579
|
+
product_id = product_id_raw
|
|
580
|
+
else:
|
|
581
|
+
product_id = None
|
|
582
|
+
|
|
583
|
+
# releaseDate is "YYYY.MM.DD" in v1 catalog
|
|
584
|
+
release_year_val: int | None = None
|
|
585
|
+
release_date_raw = product.get("releaseDate") or ""
|
|
586
|
+
if (
|
|
587
|
+
isinstance(release_date_raw, str)
|
|
588
|
+
and len(release_date_raw) >= 4
|
|
589
|
+
and release_date_raw[:4].isdigit()
|
|
590
|
+
):
|
|
591
|
+
release_year_val = int(release_date_raw[:4])
|
|
592
|
+
|
|
593
|
+
platforms = normalize_platforms(product.get("operatingSystems", []))
|
|
594
|
+
# genres is a list of {"name": ..., "slug": ...} dicts in v1
|
|
595
|
+
genres = normalize_genres(product.get("genres", []))
|
|
596
|
+
|
|
597
|
+
# price in v1: {"finalMoney": {"amount": "4.99", ...}, ...}
|
|
598
|
+
price: str | None = None
|
|
599
|
+
price_data = product.get("price") or {}
|
|
600
|
+
final_money = price_data.get("finalMoney") or {}
|
|
601
|
+
amount = final_money.get("amount")
|
|
602
|
+
if amount is not None:
|
|
603
|
+
price = "free" if str(amount) in ("0", "0.00") else str(amount)
|
|
604
|
+
|
|
605
|
+
if owned_ids is None:
|
|
606
|
+
owned: bool | None = None
|
|
607
|
+
elif product_id is not None:
|
|
608
|
+
owned = product_id in owned_ids
|
|
609
|
+
else:
|
|
610
|
+
owned = False
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
"product_id": product_id,
|
|
614
|
+
"title": product.get("title", ""),
|
|
615
|
+
"slug": product.get("slug", ""),
|
|
616
|
+
"release_year": release_year_val,
|
|
617
|
+
"platforms": platforms,
|
|
618
|
+
"genres": genres,
|
|
619
|
+
"price": price,
|
|
620
|
+
"is_available": product.get("productState") == "default",
|
|
621
|
+
"owned": owned,
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _apply_catalog_filters(
|
|
626
|
+
games: list[dict[str, Any]],
|
|
627
|
+
args: argparse.Namespace,
|
|
628
|
+
) -> list[dict[str, Any]]:
|
|
629
|
+
platforms = normalize_platforms(_split_filter_values(getattr(args, "platforms", [])))
|
|
630
|
+
genres = [
|
|
631
|
+
value.casefold()
|
|
632
|
+
for value in _split_filter_values(getattr(args, "genres", []))
|
|
633
|
+
if value
|
|
634
|
+
]
|
|
635
|
+
year_range = _parse_year_range(getattr(args, "year", None))
|
|
636
|
+
return [
|
|
637
|
+
game
|
|
638
|
+
for game in games
|
|
639
|
+
if _matches_platforms(game, platforms)
|
|
640
|
+
and _matches_year(game, year_range, include_unknown=False)
|
|
641
|
+
and _matches_genres(game, genres, include_unknown=False)
|
|
642
|
+
]
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _format_owned(owned: bool | None) -> str:
|
|
646
|
+
if owned is True:
|
|
647
|
+
return "yes"
|
|
648
|
+
if owned is False:
|
|
649
|
+
return "no"
|
|
650
|
+
return "-"
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _format_cache_age(fetched_at: str) -> str:
|
|
654
|
+
parsed = _parse_timestamp(fetched_at)
|
|
655
|
+
if parsed is None:
|
|
656
|
+
return "unknown"
|
|
657
|
+
|
|
658
|
+
delta = datetime.now(UTC) - parsed
|
|
659
|
+
if delta.total_seconds() < 60:
|
|
660
|
+
return "just now"
|
|
661
|
+
total_minutes = int(delta.total_seconds() // 60)
|
|
662
|
+
days, rem_minutes = divmod(total_minutes, 60 * 24)
|
|
663
|
+
hours, minutes = divmod(rem_minutes, 60)
|
|
664
|
+
if days:
|
|
665
|
+
return f"{days}d {hours}h"
|
|
666
|
+
if hours:
|
|
667
|
+
return f"{hours}h {minutes}m"
|
|
668
|
+
return f"{minutes}m"
|
gog_cli/log.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Logging helpers and secret redaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
_BEARER_RE = re.compile(r"(Bearer\s+)\S+", re.IGNORECASE)
|
|
9
|
+
_PARAM_RE = re.compile(r"((?:access_token|refresh_token|code)=)[^&\s\"']+")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_logger(name: str) -> logging.Logger:
|
|
13
|
+
return logging.getLogger(name)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def redact(text: str) -> str:
|
|
17
|
+
text = _BEARER_RE.sub(r"\1[REDACTED]", text)
|
|
18
|
+
text = _PARAM_RE.sub(r"\1[REDACTED]", text)
|
|
19
|
+
return text
|