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/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