gog-cli 0.2.1__tar.gz

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.
Files changed (44) hide show
  1. gog_cli-0.2.1/LICENSE +21 -0
  2. gog_cli-0.2.1/PKG-INFO +193 -0
  3. gog_cli-0.2.1/README.md +153 -0
  4. gog_cli-0.2.1/pyproject.toml +49 -0
  5. gog_cli-0.2.1/setup.cfg +4 -0
  6. gog_cli-0.2.1/src/gog_cli/__init__.py +5 -0
  7. gog_cli-0.2.1/src/gog_cli/api.py +143 -0
  8. gog_cli-0.2.1/src/gog_cli/aria2c.py +136 -0
  9. gog_cli-0.2.1/src/gog_cli/auth.py +217 -0
  10. gog_cli-0.2.1/src/gog_cli/backup.py +197 -0
  11. gog_cli-0.2.1/src/gog_cli/cli.py +550 -0
  12. gog_cli-0.2.1/src/gog_cli/config.py +120 -0
  13. gog_cli-0.2.1/src/gog_cli/downloader.py +196 -0
  14. gog_cli-0.2.1/src/gog_cli/errors.py +54 -0
  15. gog_cli-0.2.1/src/gog_cli/execution.py +1054 -0
  16. gog_cli-0.2.1/src/gog_cli/layout.py +72 -0
  17. gog_cli-0.2.1/src/gog_cli/listing.py +668 -0
  18. gog_cli-0.2.1/src/gog_cli/log.py +19 -0
  19. gog_cli-0.2.1/src/gog_cli/metadata.py +212 -0
  20. gog_cli-0.2.1/src/gog_cli/output.py +99 -0
  21. gog_cli-0.2.1/src/gog_cli/prompt.py +57 -0
  22. gog_cli-0.2.1/src/gog_cli/refresh.py +231 -0
  23. gog_cli-0.2.1/src/gog_cli/state.py +193 -0
  24. gog_cli-0.2.1/src/gog_cli/sync.py +146 -0
  25. gog_cli-0.2.1/src/gog_cli.egg-info/PKG-INFO +193 -0
  26. gog_cli-0.2.1/src/gog_cli.egg-info/SOURCES.txt +42 -0
  27. gog_cli-0.2.1/src/gog_cli.egg-info/dependency_links.txt +1 -0
  28. gog_cli-0.2.1/src/gog_cli.egg-info/entry_points.txt +2 -0
  29. gog_cli-0.2.1/src/gog_cli.egg-info/requires.txt +6 -0
  30. gog_cli-0.2.1/src/gog_cli.egg-info/top_level.txt +1 -0
  31. gog_cli-0.2.1/tests/test_api.py +311 -0
  32. gog_cli-0.2.1/tests/test_aria2c.py +215 -0
  33. gog_cli-0.2.1/tests/test_auth.py +342 -0
  34. gog_cli-0.2.1/tests/test_backup.py +316 -0
  35. gog_cli-0.2.1/tests/test_cli.py +2303 -0
  36. gog_cli-0.2.1/tests/test_config.py +160 -0
  37. gog_cli-0.2.1/tests/test_downloader.py +192 -0
  38. gog_cli-0.2.1/tests/test_errors.py +82 -0
  39. gog_cli-0.2.1/tests/test_layout.py +96 -0
  40. gog_cli-0.2.1/tests/test_output.py +124 -0
  41. gog_cli-0.2.1/tests/test_prompt.py +78 -0
  42. gog_cli-0.2.1/tests/test_refresh.py +456 -0
  43. gog_cli-0.2.1/tests/test_state.py +118 -0
  44. gog_cli-0.2.1/tests/test_sync.py +248 -0
gog_cli-0.2.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gog-cli contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
gog_cli-0.2.1/PKG-INFO ADDED
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: gog-cli
3
+ Version: 0.2.1
4
+ Summary: CLI tool for backing up DRM-free GOG games.
5
+ Author: gog-cli contributors
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 gog-cli contributors
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/aleksandarristic/gog-cli
29
+ Project-URL: Repository, https://github.com/aleksandarristic/gog-cli
30
+ Project-URL: Bug Tracker, https://github.com/aleksandarristic/gog-cli/issues
31
+ Requires-Python: >=3.12
32
+ Description-Content-Type: text/markdown
33
+ License-File: LICENSE
34
+ Requires-Dist: requests>=2.32
35
+ Provides-Extra: dev
36
+ Requires-Dist: pytest>=8; extra == "dev"
37
+ Requires-Dist: responses>=0.25; extra == "dev"
38
+ Requires-Dist: ruff>=0.4; extra == "dev"
39
+ Dynamic: license-file
40
+
41
+ # gog-cli
42
+
43
+ [![CI](https://github.com/aleksandarristic/gog-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/aleksandarristic/gog-cli/actions/workflows/ci.yml)
44
+
45
+ `gog` is a Python CLI for backing up a user's owned DRM-free GOG game library.
46
+
47
+ It is focused on safe, scriptable workflows:
48
+
49
+ - list owned games with filtering and fuzzy search
50
+ - plan and execute backups to a local directory
51
+ - preserve metadata needed to audit and restore backups
52
+ - download installers and related files with resumable behavior
53
+ - verify downloaded files when checksums are available
54
+
55
+ ## Install
56
+
57
+ Requires Python 3.12 or newer.
58
+
59
+ ```sh
60
+ pip install git+https://github.com/aleksandarristic/gog-cli.git
61
+ ```
62
+
63
+ ## Development
64
+
65
+ ```sh
66
+ python3 -m venv .venv
67
+ . .venv/bin/activate
68
+ python -m pip install -e ".[dev]"
69
+ python -m pytest
70
+ ```
71
+
72
+ Run the CLI locally:
73
+
74
+ ```sh
75
+ gog --help
76
+ gog list
77
+ gog plan --destination /path/to/backups --all --summary
78
+ gog backup --destination /path/to/backups --games-from games.txt --dry-run
79
+ ```
80
+
81
+ ## Basic Workflow
82
+
83
+ ```sh
84
+ gog auth login
85
+ gog refresh
86
+ gog list purchased
87
+ gog plan --destination /path/to/backups --all --storage --check-free-space
88
+ gog backup --destination /path/to/backups --all --yes
89
+ gog list backed-up --destination /path/to/backups
90
+ gog sync --destination /path/to/backups --all --yes
91
+ ```
92
+
93
+ `gog refresh` updates the local purchased-library and download-metadata caches.
94
+ It does not download game installers. Run it before browsing or filtering newly
95
+ added library metadata.
96
+
97
+ ## Browsing Purchased Games
98
+
99
+ `gog list purchased` reads the local cache written by `gog refresh`; it does not
100
+ contact GOG. Human output includes ID, title, release year, genre/category, and
101
+ platforms when those fields are available. JSON output also includes scriptable
102
+ metadata such as `owned`, `release_date`, `genres`, and `is_installable`.
103
+
104
+ Examples:
105
+
106
+ ```sh
107
+ gog list purchased
108
+ gog list purchased --format json
109
+ gog list purchased --search witcher
110
+ gog list purchased --search "baldurs gate"
111
+ gog list purchased --platform windows
112
+ gog list purchased --platform linux --search ftl
113
+ gog list purchased --year 1998..2005
114
+ gog list purchased --year 2010..2020 --include-unknown-year
115
+ gog list purchased --genre strategy
116
+ gog list purchased --genre arcade,rts
117
+ gog list purchased --genre strategy --include-unknown-genre
118
+ gog list purchased --search "baldurs gate" --platform linux --format json
119
+ ```
120
+
121
+ Year filters omit games with unknown years by default; use
122
+ `--include-unknown-year` to keep them. Genre filters similarly omit unknown
123
+ genres by default; use `--include-unknown-genre` to keep those rows.
124
+
125
+ ## Planning Backups
126
+
127
+ `gog plan` shows the same dry-run plan as `gog backup --dry-run` without
128
+ downloading files or creating backup directories. Use it before long backup runs
129
+ to estimate size, inspect filters, and check destination free space.
130
+
131
+ Examples:
132
+
133
+ ```sh
134
+ gog plan --destination /path/to/backups --all
135
+ gog plan --destination /path/to/backups --all --summary
136
+ gog plan --destination /path/to/backups --all --storage
137
+ gog plan --destination /path/to/backups --all --check-free-space
138
+ gog plan --destination /path/to/backups --all --format json
139
+ gog plan --destination /path/to/backups cyberpunk_2077
140
+ ```
141
+
142
+ Platform and language filters can reduce backup size:
143
+
144
+ ```sh
145
+ gog plan --destination /path/to/backups --all --platform linux --storage
146
+ gog plan --destination /path/to/backups --all --platform windows --language en --storage
147
+ ```
148
+
149
+ ## Selecting Games
150
+
151
+ Game selectors can be product IDs, slugs, or exact titles. Commands that select
152
+ games accept repeated `--game` flags:
153
+
154
+ ```sh
155
+ gog plan --destination /path/to/backups --game witcher_3 --game cyberpunk_2077
156
+ gog backup --destination /path/to/backups --game 123456789 --yes
157
+ ```
158
+
159
+ For larger curated lists, put selectors in a UTF-8 text file and pass
160
+ `--games-from`. Blank lines and lines whose first non-whitespace character is
161
+ `#` are ignored.
162
+
163
+ Example `games.txt`:
164
+
165
+ ```text
166
+ # first NAS batch
167
+ witcher_3
168
+ cyberpunk_2077
169
+ 123456789
170
+ ```
171
+
172
+ Use the selector file in plan, backup, or sync workflows:
173
+
174
+ ```sh
175
+ gog plan --destination /path/to/backups --games-from games.txt --storage
176
+ gog backup --destination /path/to/backups --games-from games.txt --downloader aria2c --yes
177
+ gog sync --destination /path/to/backups --games-from games.txt --dry-run
178
+ ```
179
+
180
+ `--games-from` is repeatable and combines with repeated `--game` flags. Do not
181
+ combine explicit game selectors with `--all`.
182
+
183
+ ## Downloading
184
+
185
+ `gog backup` defaults to the built-in direct downloader. To use `aria2c`, install
186
+ `aria2c` and pass `--downloader aria2c` on an executing backup run:
187
+
188
+ ```sh
189
+ gog backup --destination /path/to/backups --games-from games.txt --downloader aria2c --yes
190
+ ```
191
+
192
+ Without `--yes`, backup and sync commands print a dry-run plan and exit without
193
+ downloading or modifying backup files.
@@ -0,0 +1,153 @@
1
+ # gog-cli
2
+
3
+ [![CI](https://github.com/aleksandarristic/gog-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/aleksandarristic/gog-cli/actions/workflows/ci.yml)
4
+
5
+ `gog` is a Python CLI for backing up a user's owned DRM-free GOG game library.
6
+
7
+ It is focused on safe, scriptable workflows:
8
+
9
+ - list owned games with filtering and fuzzy search
10
+ - plan and execute backups to a local directory
11
+ - preserve metadata needed to audit and restore backups
12
+ - download installers and related files with resumable behavior
13
+ - verify downloaded files when checksums are available
14
+
15
+ ## Install
16
+
17
+ Requires Python 3.12 or newer.
18
+
19
+ ```sh
20
+ pip install git+https://github.com/aleksandarristic/gog-cli.git
21
+ ```
22
+
23
+ ## Development
24
+
25
+ ```sh
26
+ python3 -m venv .venv
27
+ . .venv/bin/activate
28
+ python -m pip install -e ".[dev]"
29
+ python -m pytest
30
+ ```
31
+
32
+ Run the CLI locally:
33
+
34
+ ```sh
35
+ gog --help
36
+ gog list
37
+ gog plan --destination /path/to/backups --all --summary
38
+ gog backup --destination /path/to/backups --games-from games.txt --dry-run
39
+ ```
40
+
41
+ ## Basic Workflow
42
+
43
+ ```sh
44
+ gog auth login
45
+ gog refresh
46
+ gog list purchased
47
+ gog plan --destination /path/to/backups --all --storage --check-free-space
48
+ gog backup --destination /path/to/backups --all --yes
49
+ gog list backed-up --destination /path/to/backups
50
+ gog sync --destination /path/to/backups --all --yes
51
+ ```
52
+
53
+ `gog refresh` updates the local purchased-library and download-metadata caches.
54
+ It does not download game installers. Run it before browsing or filtering newly
55
+ added library metadata.
56
+
57
+ ## Browsing Purchased Games
58
+
59
+ `gog list purchased` reads the local cache written by `gog refresh`; it does not
60
+ contact GOG. Human output includes ID, title, release year, genre/category, and
61
+ platforms when those fields are available. JSON output also includes scriptable
62
+ metadata such as `owned`, `release_date`, `genres`, and `is_installable`.
63
+
64
+ Examples:
65
+
66
+ ```sh
67
+ gog list purchased
68
+ gog list purchased --format json
69
+ gog list purchased --search witcher
70
+ gog list purchased --search "baldurs gate"
71
+ gog list purchased --platform windows
72
+ gog list purchased --platform linux --search ftl
73
+ gog list purchased --year 1998..2005
74
+ gog list purchased --year 2010..2020 --include-unknown-year
75
+ gog list purchased --genre strategy
76
+ gog list purchased --genre arcade,rts
77
+ gog list purchased --genre strategy --include-unknown-genre
78
+ gog list purchased --search "baldurs gate" --platform linux --format json
79
+ ```
80
+
81
+ Year filters omit games with unknown years by default; use
82
+ `--include-unknown-year` to keep them. Genre filters similarly omit unknown
83
+ genres by default; use `--include-unknown-genre` to keep those rows.
84
+
85
+ ## Planning Backups
86
+
87
+ `gog plan` shows the same dry-run plan as `gog backup --dry-run` without
88
+ downloading files or creating backup directories. Use it before long backup runs
89
+ to estimate size, inspect filters, and check destination free space.
90
+
91
+ Examples:
92
+
93
+ ```sh
94
+ gog plan --destination /path/to/backups --all
95
+ gog plan --destination /path/to/backups --all --summary
96
+ gog plan --destination /path/to/backups --all --storage
97
+ gog plan --destination /path/to/backups --all --check-free-space
98
+ gog plan --destination /path/to/backups --all --format json
99
+ gog plan --destination /path/to/backups cyberpunk_2077
100
+ ```
101
+
102
+ Platform and language filters can reduce backup size:
103
+
104
+ ```sh
105
+ gog plan --destination /path/to/backups --all --platform linux --storage
106
+ gog plan --destination /path/to/backups --all --platform windows --language en --storage
107
+ ```
108
+
109
+ ## Selecting Games
110
+
111
+ Game selectors can be product IDs, slugs, or exact titles. Commands that select
112
+ games accept repeated `--game` flags:
113
+
114
+ ```sh
115
+ gog plan --destination /path/to/backups --game witcher_3 --game cyberpunk_2077
116
+ gog backup --destination /path/to/backups --game 123456789 --yes
117
+ ```
118
+
119
+ For larger curated lists, put selectors in a UTF-8 text file and pass
120
+ `--games-from`. Blank lines and lines whose first non-whitespace character is
121
+ `#` are ignored.
122
+
123
+ Example `games.txt`:
124
+
125
+ ```text
126
+ # first NAS batch
127
+ witcher_3
128
+ cyberpunk_2077
129
+ 123456789
130
+ ```
131
+
132
+ Use the selector file in plan, backup, or sync workflows:
133
+
134
+ ```sh
135
+ gog plan --destination /path/to/backups --games-from games.txt --storage
136
+ gog backup --destination /path/to/backups --games-from games.txt --downloader aria2c --yes
137
+ gog sync --destination /path/to/backups --games-from games.txt --dry-run
138
+ ```
139
+
140
+ `--games-from` is repeatable and combines with repeated `--game` flags. Do not
141
+ combine explicit game selectors with `--all`.
142
+
143
+ ## Downloading
144
+
145
+ `gog backup` defaults to the built-in direct downloader. To use `aria2c`, install
146
+ `aria2c` and pass `--downloader aria2c` on an executing backup run:
147
+
148
+ ```sh
149
+ gog backup --destination /path/to/backups --games-from games.txt --downloader aria2c --yes
150
+ ```
151
+
152
+ Without `--yes`, backup and sync commands print a dry-run plan and exit without
153
+ downloading or modifying backup files.
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gog-cli"
7
+ version = "0.2.1"
8
+ description = "CLI tool for backing up DRM-free GOG games."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { file = "LICENSE" }
12
+ authors = [
13
+ { name = "gog-cli contributors" }
14
+ ]
15
+
16
+ dependencies = [
17
+ "requests>=2.32",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/aleksandarristic/gog-cli"
22
+ Repository = "https://github.com/aleksandarristic/gog-cli"
23
+ "Bug Tracker" = "https://github.com/aleksandarristic/gog-cli/issues"
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=8",
28
+ "responses>=0.25",
29
+ "ruff>=0.4",
30
+ ]
31
+
32
+ [project.scripts]
33
+ gog = "gog_cli.cli:main"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+ pythonpath = ["src"]
41
+
42
+ [tool.ruff]
43
+ line-length = 100
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "W", "F", "I", "N", "B", "UP", "SIM", "S", "PT"]
47
+
48
+ [tool.ruff.lint.per-file-ignores]
49
+ "tests/*" = ["S101", "S105", "S107", "S108"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """gog-cli package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.2.1"
@@ -0,0 +1,143 @@
1
+ """GOG API client and TokenStore protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Protocol
7
+
8
+ import requests
9
+
10
+ from gog_cli import log
11
+ from gog_cli.errors import AuthError, NetworkError
12
+
13
+ _log = log.get_logger(__name__)
14
+
15
+ _CLIENT_ID = "46899977096215655"
16
+ _CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9" # noqa: S105 - Public GOG Galaxy OAuth client credential.
17
+ _TOKEN_URL = "https://auth.gog.com/token" # noqa: S105 - URL constant, not a secret.
18
+
19
+ _OWNED_GAMES_URL = "https://embed.gog.com/user/data/games"
20
+ _LIBRARY_URL = "https://embed.gog.com/account/getFilteredProducts"
21
+ _PRODUCT_URL = "https://api.gog.com/products/{product_id}"
22
+ # Unofficial public catalog search endpoint — no authentication required.
23
+ _CATALOG_SEARCH_URL = "https://catalog.gog.com/v1/catalog"
24
+
25
+
26
+ class TokenStore(Protocol):
27
+ def load_tokens(self) -> dict:
28
+ """Return stored tokens: {"access_token": str, "refresh_token": str, "expires_at": str}"""
29
+ ...
30
+
31
+ def save_tokens(self, tokens: dict) -> None:
32
+ """Persist updated tokens after a refresh."""
33
+ ...
34
+
35
+
36
+ class GogApiClient:
37
+ def __init__(self, token_store: TokenStore) -> None:
38
+ self._token_store = token_store
39
+ self._session = requests.Session()
40
+
41
+ def _access_token(self) -> str:
42
+ return self._token_store.load_tokens()["access_token"]
43
+
44
+ def _auth_headers(self) -> dict[str, str]:
45
+ return {"Authorization": f"Bearer {self._access_token()}"}
46
+
47
+ def _refresh_tokens(self) -> None:
48
+ tokens = self._token_store.load_tokens()
49
+ try:
50
+ resp = self._session.get(
51
+ _TOKEN_URL,
52
+ params={
53
+ "client_id": _CLIENT_ID,
54
+ "client_secret": _CLIENT_SECRET,
55
+ "grant_type": "refresh_token",
56
+ "refresh_token": tokens["refresh_token"],
57
+ },
58
+ timeout=30,
59
+ )
60
+ resp.raise_for_status()
61
+ except requests.HTTPError as exc:
62
+ raise AuthError(f"token refresh failed: {exc}") from exc
63
+ except (requests.ConnectionError, requests.Timeout) as exc:
64
+ raise AuthError(f"token refresh network error: {exc}") from exc
65
+
66
+ data = resp.json()
67
+ expires_at = datetime.fromtimestamp(
68
+ datetime.now(tz=UTC).timestamp() + data["expires_in"],
69
+ tz=UTC,
70
+ ).replace(microsecond=0).isoformat().replace("+00:00", "Z")
71
+ self._token_store.save_tokens(
72
+ {
73
+ **tokens,
74
+ "access_token": data["access_token"],
75
+ "refresh_token": data["refresh_token"],
76
+ "expires_at": expires_at,
77
+ }
78
+ )
79
+
80
+ def _get(self, url: str, **kwargs: object) -> requests.Response:
81
+ try:
82
+ resp = self._session.get(url, headers=self._auth_headers(), timeout=30, **kwargs)
83
+ except (requests.ConnectionError, requests.Timeout, ConnectionError) as exc:
84
+ raise NetworkError(f"network error: {exc}") from exc
85
+
86
+ if resp.status_code == 401:
87
+ self._refresh_tokens()
88
+ try:
89
+ resp = self._session.get(
90
+ url, headers=self._auth_headers(), timeout=30, **kwargs
91
+ )
92
+ except (requests.ConnectionError, requests.Timeout, ConnectionError) as exc:
93
+ raise NetworkError(f"network error: {exc}") from exc
94
+ if resp.status_code == 401:
95
+ raise AuthError("authentication failed after token refresh")
96
+
97
+ if not resp.ok:
98
+ raise NetworkError(f"HTTP {resp.status_code}: {log.redact(url)}")
99
+
100
+ return resp
101
+
102
+ def get_owned_ids(self) -> list[int]:
103
+ resp = self._get(_OWNED_GAMES_URL)
104
+ return resp.json()["owned"]
105
+
106
+ def get_library_page(self, page: int) -> dict:
107
+ resp = self._get(_LIBRARY_URL, params={"mediaType": 1, "page": page})
108
+ return resp.json()
109
+
110
+ def get_product_downloads(self, product_id: int) -> dict:
111
+ url = _PRODUCT_URL.format(product_id=product_id)
112
+ resp = self._get(url, params={"expand": "downloads"})
113
+ return resp.json()
114
+
115
+ def resolve_downlink_url(self, downlink_url: str) -> tuple[str, str]:
116
+ resp = self._get(downlink_url)
117
+ data = resp.json()
118
+ return data["downlink"], data.get("checksum", "")
119
+
120
+
121
+ def search_catalog(query: str, *, page: int = 1) -> dict:
122
+ """Search the public GOG catalog without authentication.
123
+
124
+ Uses an unofficial reverse-engineered endpoint; no stability guarantees.
125
+ """
126
+ try:
127
+ resp = requests.get(
128
+ _CATALOG_SEARCH_URL,
129
+ params={
130
+ "search": query,
131
+ "limit": 48,
132
+ "page": page,
133
+ "order": "desc:relevance",
134
+ "productType": "in:game",
135
+ },
136
+ timeout=30,
137
+ )
138
+ resp.raise_for_status()
139
+ except requests.HTTPError as exc:
140
+ raise NetworkError(f"catalog search failed: {exc}") from exc
141
+ except (requests.ConnectionError, requests.Timeout, ConnectionError) as exc:
142
+ raise NetworkError(f"catalog search network error: {exc}") from exc
143
+ return resp.json()