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/__init__.py
ADDED
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
|