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