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 ADDED
@@ -0,0 +1,5 @@
1
+ """gog-cli package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.2.1"
gog_cli/api.py ADDED
@@ -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()
gog_cli/aria2c.py ADDED
@@ -0,0 +1,136 @@
1
+ """Delegated downloader via aria2c."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ from gog_cli.downloader import DownloadResult, _md5_file
14
+ from gog_cli.errors import UsageError
15
+
16
+
17
+ def find_aria2c() -> Path | None:
18
+ """Return path to aria2c binary or None if not found."""
19
+ found = shutil.which("aria2c")
20
+ return Path(found) if found else None
21
+
22
+
23
+ def check_aria2c(required: bool = True) -> Path:
24
+ """Return aria2c path or raise UsageError if not found and required=True."""
25
+ path = find_aria2c()
26
+ if path is None and required:
27
+ raise UsageError(
28
+ "aria2c is not installed or not on PATH. Install it or use --downloader direct."
29
+ )
30
+ if path is None:
31
+ raise UsageError("aria2c not found")
32
+ return path
33
+
34
+
35
+ def download_via_aria2c(
36
+ url: str,
37
+ dest: Path,
38
+ *,
39
+ headers: dict[str, str] | None = None,
40
+ expected_size: int | None = None,
41
+ expected_md5: str | None = None,
42
+ aria2c_path: Path | None = None,
43
+ logger: logging.Logger | None = None,
44
+ ) -> DownloadResult:
45
+ log = logger or logging.getLogger(__name__)
46
+
47
+ aria2_control = Path(str(dest) + ".aria2")
48
+ if dest.exists() and not aria2_control.exists():
49
+ return DownloadResult(status="skipped", path=dest, expected_size=expected_size)
50
+
51
+ binary = aria2c_path or check_aria2c()
52
+ dest.parent.mkdir(parents=True, exist_ok=True)
53
+
54
+ # Write URL to a temp file so it doesn't appear in process listings.
55
+ # The file is written with restrictive permissions before content is added.
56
+ fd, input_file = tempfile.mkstemp(prefix="gog-aria2c-", suffix=".txt")
57
+ try:
58
+ os.chmod(input_file, 0o600)
59
+ with os.fdopen(fd, "w") as fh:
60
+ fh.write(url + "\n")
61
+
62
+ cmd = [
63
+ str(binary),
64
+ "--input-file",
65
+ input_file,
66
+ "--dir",
67
+ str(dest.parent),
68
+ "--out",
69
+ dest.name,
70
+ "--auto-file-renaming=false",
71
+ "--continue=true",
72
+ "--split=4",
73
+ "--max-connection-per-server=4",
74
+ ]
75
+
76
+ # Headers appear in process args — unavoidable with aria2c's CLI interface.
77
+ if headers:
78
+ for key, value in headers.items():
79
+ cmd += ["--header", f"{key}: {value}"]
80
+
81
+ log.debug("running aria2c for %s", dest.name)
82
+ result = subprocess.run( # noqa: S603
83
+ cmd,
84
+ )
85
+
86
+ if result.returncode != 0:
87
+ return DownloadResult(
88
+ status="failed",
89
+ expected_size=expected_size,
90
+ failure_code="aria2c_error",
91
+ failure_message=f"aria2c exited with code {result.returncode}",
92
+ )
93
+
94
+ finally:
95
+ with contextlib.suppress(FileNotFoundError):
96
+ os.unlink(input_file)
97
+
98
+ if not dest.exists():
99
+ return DownloadResult(
100
+ status="failed",
101
+ expected_size=expected_size,
102
+ failure_code="aria2c_error",
103
+ failure_message="aria2c reported success but output file is missing",
104
+ )
105
+
106
+ actual_size = dest.stat().st_size
107
+
108
+ if expected_size is not None and actual_size != expected_size:
109
+ return DownloadResult(
110
+ status="failed",
111
+ path=dest,
112
+ expected_size=expected_size,
113
+ failure_code="size_mismatch",
114
+ failure_message=f"Expected {expected_size} bytes, got {actual_size}",
115
+ )
116
+
117
+ checksum_verified = False
118
+ if expected_md5 is not None:
119
+ actual_md5 = _md5_file(dest)
120
+ if actual_md5 != expected_md5.lower():
121
+ return DownloadResult(
122
+ status="failed",
123
+ path=dest,
124
+ expected_size=expected_size,
125
+ failure_code="checksum_mismatch",
126
+ failure_message="MD5 checksum did not match expected value",
127
+ )
128
+ checksum_verified = True
129
+
130
+ return DownloadResult(
131
+ status="verified",
132
+ path=dest,
133
+ bytes_downloaded=actual_size,
134
+ expected_size=expected_size,
135
+ checksum_verified=checksum_verified,
136
+ )
gog_cli/auth.py ADDED
@@ -0,0 +1,217 @@
1
+ """Auth commands and FileTokenStore implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from datetime import UTC, datetime
9
+ from urllib.parse import parse_qs, urlparse
10
+
11
+ import requests
12
+
13
+ from gog_cli import log
14
+ from gog_cli.api import _CLIENT_ID, _CLIENT_SECRET, _TOKEN_URL
15
+ from gog_cli.errors import AuthError, ExitCode, FilesystemError, UsageError
16
+ from gog_cli.state import (
17
+ AppPaths,
18
+ StateFileCorruptError,
19
+ StateFileMissingError,
20
+ read_json_file,
21
+ resolve_app_paths,
22
+ write_json_file_atomic,
23
+ )
24
+
25
+ _log = log.get_logger(__name__)
26
+
27
+ _REDIRECT_URI = "https://embed.gog.com/on_login_success?origin=client"
28
+ _USER_DATA_URL = "https://embed.gog.com/userData.json"
29
+ _LOGIN_URL = (
30
+ "https://auth.gog.com/auth"
31
+ "?client_id=46899977096215655"
32
+ "&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient"
33
+ "&response_type=code"
34
+ "&layout=client2"
35
+ )
36
+
37
+
38
+ class FileTokenStore:
39
+ """Implements TokenStore Protocol using session.json + optional OS keyring."""
40
+
41
+ def __init__(self, paths: AppPaths) -> None:
42
+ self._paths = paths
43
+ self._keyring_checked = False
44
+ self._keyring_refresh_token: str | None = None
45
+
46
+ def load_tokens(self) -> dict:
47
+ try:
48
+ tokens = read_json_file(self._paths.session_state)
49
+ except StateFileMissingError:
50
+ raise AuthError("Not logged in. Run: gog auth login") from None
51
+ except StateFileCorruptError as exc:
52
+ raise AuthError(f"Session file is corrupt: {exc}") from exc
53
+ if not self._keyring_checked:
54
+ self._keyring_refresh_token = _try_load_keyring()
55
+ self._keyring_checked = True
56
+ if self._keyring_refresh_token:
57
+ tokens["refresh_token"] = self._keyring_refresh_token
58
+ return tokens
59
+
60
+ def save_tokens(self, tokens: dict) -> None:
61
+ try:
62
+ write_json_file_atomic(self._paths.session_state, tokens)
63
+ os.chmod(self._paths.session_state, 0o600)
64
+ except OSError as exc:
65
+ raise FilesystemError(f"Failed to write session: {exc}") from exc
66
+ self._keyring_checked = True
67
+ self._keyring_refresh_token = tokens.get("refresh_token") or None
68
+ _try_save_keyring(tokens.get("refresh_token", ""))
69
+
70
+
71
+ def _try_save_keyring(refresh_token: str) -> None:
72
+ try:
73
+ import keyring # noqa: PLC0415
74
+ keyring.set_password("gog-cli", "refresh_token", refresh_token)
75
+ except Exception: # noqa: BLE001
76
+ _log.warning("keyring write failed — using file-only token storage")
77
+
78
+
79
+ def _try_load_keyring() -> str | None:
80
+ try:
81
+ import keyring # noqa: PLC0415
82
+
83
+ return keyring.get_password("gog-cli", "refresh_token")
84
+ except Exception: # noqa: BLE001
85
+ _log.warning("keyring read failed — using file token storage")
86
+ return None
87
+
88
+
89
+ def _try_delete_keyring() -> None:
90
+ try:
91
+ import keyring # noqa: PLC0415
92
+ keyring.delete_password("gog-cli", "refresh_token")
93
+ except Exception: # noqa: BLE001
94
+ _log.debug("keyring delete failed or token was already absent")
95
+
96
+
97
+ def _extract_code(pasted: str) -> str:
98
+ """Return code= value from a redirect URL, or the raw string if no URL scheme."""
99
+ pasted = pasted.strip()
100
+ if not pasted:
101
+ raise UsageError("No input provided")
102
+ if pasted.startswith("http"):
103
+ params = parse_qs(urlparse(pasted).query)
104
+ codes = params.get("code")
105
+ if not codes:
106
+ raise UsageError("No 'code' parameter found in the pasted URL")
107
+ return codes[0]
108
+ return pasted
109
+
110
+
111
+ def _exchange_code(code: str) -> dict:
112
+ try:
113
+ resp = requests.get(
114
+ _TOKEN_URL,
115
+ params={
116
+ "client_id": _CLIENT_ID,
117
+ "client_secret": _CLIENT_SECRET,
118
+ "grant_type": "authorization_code",
119
+ "code": code,
120
+ "redirect_uri": _REDIRECT_URI,
121
+ },
122
+ timeout=30,
123
+ )
124
+ resp.raise_for_status()
125
+ except requests.HTTPError as exc:
126
+ raise AuthError(f"Token exchange failed: {exc}") from exc
127
+ except (requests.ConnectionError, requests.Timeout) as exc:
128
+ raise AuthError(f"Token exchange network error: {exc}") from exc
129
+ return resp.json()
130
+
131
+
132
+ def _fetch_username(access_token: str) -> str:
133
+ try:
134
+ resp = requests.get(
135
+ _USER_DATA_URL,
136
+ headers={"Authorization": f"Bearer {access_token}"},
137
+ timeout=30,
138
+ )
139
+ resp.raise_for_status()
140
+ except (requests.HTTPError, requests.ConnectionError, requests.Timeout) as exc:
141
+ raise AuthError(f"Failed to fetch user info: {exc}") from exc
142
+ return resp.json().get("username", "")
143
+
144
+
145
+ def handle_auth_login(_args: argparse.Namespace) -> int:
146
+ paths = resolve_app_paths()
147
+ print(f"\nOpen this URL in your browser and log in:\n\n {_LOGIN_URL}\n")
148
+ print("After logging in, paste the full redirect URL (or just the code value):")
149
+ print("> ", end="", flush=True)
150
+ try:
151
+ pasted = sys.stdin.readline()
152
+ except (EOFError, KeyboardInterrupt) as exc:
153
+ raise UsageError("Login cancelled") from exc
154
+
155
+ code = _extract_code(pasted)
156
+ token_data = _exchange_code(code)
157
+
158
+ access_token = token_data["access_token"]
159
+ refresh_token = token_data["refresh_token"]
160
+ expires_in = int(token_data.get("expires_in", 3600))
161
+ user_id = str(token_data.get("user_id", ""))
162
+
163
+ expires_at = datetime.fromtimestamp(
164
+ datetime.now(tz=UTC).timestamp() + expires_in,
165
+ tz=UTC,
166
+ ).replace(microsecond=0).isoformat().replace("+00:00", "Z")
167
+
168
+ username = _fetch_username(access_token)
169
+
170
+ store = FileTokenStore(paths)
171
+ store.save_tokens(
172
+ {
173
+ "access_token": access_token,
174
+ "refresh_token": refresh_token,
175
+ "expires_at": expires_at,
176
+ "user_id": user_id,
177
+ "username": username,
178
+ }
179
+ )
180
+
181
+ print(f"Logged in as {username}.")
182
+ return ExitCode.SUCCESS
183
+
184
+
185
+ def handle_auth_status(_args: argparse.Namespace) -> int:
186
+ paths = resolve_app_paths()
187
+ store = FileTokenStore(paths)
188
+ try:
189
+ tokens = store.load_tokens()
190
+ except AuthError as exc:
191
+ print(str(exc), file=sys.stderr)
192
+ return ExitCode.AUTH
193
+
194
+ expires_at_str = tokens.get("expires_at", "")
195
+ try:
196
+ expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
197
+ except (ValueError, AttributeError):
198
+ expires_at = None
199
+
200
+ if expires_at is not None and datetime.now(tz=UTC) > expires_at:
201
+ print("Token expired. Run: gog auth login", file=sys.stderr)
202
+ return ExitCode.AUTH
203
+
204
+ username = tokens.get("username", "unknown")
205
+ print(f"Logged in as {username}. Token expires {expires_at_str}.")
206
+ return ExitCode.SUCCESS
207
+
208
+
209
+ def handle_auth_logout(_args: argparse.Namespace) -> int:
210
+ paths = resolve_app_paths()
211
+ try:
212
+ paths.session_state.unlink(missing_ok=True)
213
+ except OSError as exc:
214
+ raise FilesystemError(f"Failed to remove session: {exc}") from exc
215
+ _try_delete_keyring()
216
+ print("Logged out.")
217
+ return ExitCode.SUCCESS
gog_cli/backup.py ADDED
@@ -0,0 +1,197 @@
1
+ """Backup planning and game selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from gog_cli.errors import UsageError
11
+ from gog_cli.layout import BackupLayout, sanitize_filename
12
+
13
+ ActionType = Literal["download", "skip", "verify", "conflict"]
14
+
15
+
16
+ @dataclass
17
+ class FileSpec:
18
+ """A file to be downloaded as part of a backup."""
19
+
20
+ source_id: str
21
+ role: str
22
+ platform: str | None
23
+ language: str | None
24
+ version: str | None
25
+ expected_size: int | None
26
+ expected_md5: str | None
27
+ downlink_url: str
28
+ checksum_url: str | None
29
+ filename: str | None = None
30
+
31
+
32
+ @dataclass
33
+ class PlannedFile:
34
+ """One file in a backup plan."""
35
+
36
+ spec: FileSpec
37
+ dest: Path
38
+ action: ActionType
39
+ skip_reason: str | None = None
40
+
41
+
42
+ @dataclass
43
+ class BackupPlan:
44
+ """Full plan for a backup run."""
45
+
46
+ destination: Path
47
+ games: list[str]
48
+ planned: list[PlannedFile]
49
+ disk_required_bytes: int
50
+ disk_free_bytes: int | None = None
51
+ orphaned_local_files: list[Path] = field(default_factory=list)
52
+ warnings: list[str] = field(default_factory=list)
53
+
54
+ @property
55
+ def downloads(self) -> list[PlannedFile]:
56
+ return [p for p in self.planned if p.action == "download"]
57
+
58
+ @property
59
+ def skips(self) -> list[PlannedFile]:
60
+ return [p for p in self.planned if p.action == "skip"]
61
+
62
+
63
+ _ROLE_DIR = {
64
+ "installer": "installers",
65
+ "patch": "patches",
66
+ "extra": "extras",
67
+ "language_pack": "language-packs",
68
+ "manual": "manuals",
69
+ }
70
+
71
+
72
+ def _role_dir(layout: BackupLayout, game_dir: Path, role: str) -> Path:
73
+ subdir = _ROLE_DIR.get(role, "other")
74
+ return game_dir / subdir
75
+
76
+
77
+ def plan_backup(
78
+ destination: Path,
79
+ games: list[dict],
80
+ downloads: dict[str, list[FileSpec]],
81
+ layout: BackupLayout,
82
+ *,
83
+ platforms: list[str] | None = None,
84
+ languages: list[str] | None = None,
85
+ file_roles: list[str] | None = None,
86
+ ) -> BackupPlan:
87
+ planned: list[PlannedFile] = []
88
+ product_ids: list[str] = []
89
+ game_dirs: list[Path] = []
90
+ disk_required_bytes = 0
91
+
92
+ for game in games:
93
+ product_id = _game_product_id(game)
94
+ slug = sanitize_filename(game.get("slug") or product_id)
95
+ game_dir = layout.game_dir(slug)
96
+ product_ids.append(product_id)
97
+ game_dirs.append(game_dir)
98
+
99
+ specs = downloads.get(product_id, [])
100
+ for spec in specs:
101
+ dest_dir = _role_dir(layout, game_dir, spec.role)
102
+ dest = dest_dir / sanitize_filename(spec.filename or spec.source_id)
103
+
104
+ if platforms and spec.platform and spec.platform not in platforms:
105
+ planned.append(PlannedFile(
106
+ spec=spec, dest=dest, action="skip", skip_reason="platform_not_selected"
107
+ ))
108
+ continue
109
+ if languages and spec.language and spec.language not in languages:
110
+ planned.append(PlannedFile(
111
+ spec=spec, dest=dest, action="skip", skip_reason="language_not_selected"
112
+ ))
113
+ continue
114
+ if file_roles and spec.role not in file_roles:
115
+ planned.append(PlannedFile(
116
+ spec=spec, dest=dest, action="skip", skip_reason="role_not_selected"
117
+ ))
118
+ continue
119
+
120
+ if dest.exists():
121
+ planned.append(
122
+ PlannedFile(spec=spec, dest=dest, action="skip", skip_reason="already_exists")
123
+ )
124
+ else:
125
+ planned.append(PlannedFile(spec=spec, dest=dest, action="download"))
126
+ if spec.expected_size:
127
+ disk_required_bytes += spec.expected_size
128
+
129
+ planned_dests = {pf.dest for pf in planned}
130
+ orphaned_local_files: list[Path] = []
131
+ for game_dir in game_dirs:
132
+ if not game_dir.exists():
133
+ continue
134
+ for path in game_dir.rglob("*"):
135
+ if not path.is_file():
136
+ continue
137
+ if path.name == "manifest.json" or path.suffix == ".tmp":
138
+ continue
139
+ if path not in planned_dests:
140
+ orphaned_local_files.append(path)
141
+
142
+ disk_free_bytes: int | None = None
143
+ if destination.exists():
144
+ disk_free_bytes = shutil.disk_usage(destination).free
145
+
146
+ return BackupPlan(
147
+ destination=destination,
148
+ games=product_ids,
149
+ planned=planned,
150
+ disk_required_bytes=disk_required_bytes,
151
+ disk_free_bytes=disk_free_bytes,
152
+ orphaned_local_files=orphaned_local_files,
153
+ )
154
+
155
+
156
+ def _game_product_id(game: dict) -> str:
157
+ return str(game.get("product_id", game.get("id", "")))
158
+
159
+
160
+ def _match_game(game: dict, selector: str) -> bool:
161
+ if _game_product_id(game) == selector:
162
+ return True
163
+ if game.get("slug", "") == selector:
164
+ return True
165
+ return (game.get("title", "") or "").lower() == selector.lower()
166
+
167
+
168
+ def select_games(
169
+ library: list[dict],
170
+ *,
171
+ game_selectors: list[str] | None = None,
172
+ exclude: list[str] | None = None,
173
+ all_games: bool = False,
174
+ ) -> list[dict]:
175
+ if all_games and game_selectors:
176
+ raise UsageError("--all and --game cannot be used together")
177
+
178
+ if all_games:
179
+ selected = list(library)
180
+ elif game_selectors:
181
+ selected = []
182
+ for selector in game_selectors:
183
+ matches = [g for g in library if _match_game(g, selector)]
184
+ if not matches:
185
+ raise UsageError(f"No game found matching {selector!r}")
186
+ if len(matches) > 1:
187
+ titles = ", ".join(str(g.get("title", g.get("id"))) for g in matches)
188
+ raise UsageError(f"Selector {selector!r} matches multiple games: {titles}")
189
+ selected.append(matches[0])
190
+ else:
191
+ selected = []
192
+
193
+ if exclude:
194
+ for selector in exclude:
195
+ selected = [g for g in selected if not _match_game(g, selector)]
196
+
197
+ return selected