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/state.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Application state paths and JSON file helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from tempfile import NamedTemporaryFile
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
APP_DIR_NAME = "gog-cli"
|
|
16
|
+
|
|
17
|
+
CacheStatus = Literal["fresh", "stale"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StateError(Exception):
|
|
21
|
+
"""Base class for application state errors."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StateFileMissingError(StateError):
|
|
25
|
+
"""Raised when an expected state/cache file does not exist."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StateFileCorruptError(StateError):
|
|
29
|
+
"""Raised when a JSON state/cache file cannot be decoded."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StateFileInvalidError(StateError):
|
|
33
|
+
"""Raised when a state/cache file has an unsupported shape."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class AppRoots:
|
|
38
|
+
"""Root directories owned by the application."""
|
|
39
|
+
|
|
40
|
+
config: Path
|
|
41
|
+
cache: Path
|
|
42
|
+
state: Path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class AppPaths:
|
|
47
|
+
"""Expected application-owned files and directories."""
|
|
48
|
+
|
|
49
|
+
roots: AppRoots
|
|
50
|
+
config_file: Path
|
|
51
|
+
library_cache: Path
|
|
52
|
+
downloads_cache_dir: Path
|
|
53
|
+
session_state: Path
|
|
54
|
+
cookies_file: Path
|
|
55
|
+
schema_file: Path
|
|
56
|
+
locks_dir: Path
|
|
57
|
+
|
|
58
|
+
def download_cache(self, product_id: str) -> Path:
|
|
59
|
+
"""Return the per-game download-metadata cache path."""
|
|
60
|
+
if not product_id or "/" in product_id or "\x00" in product_id:
|
|
61
|
+
raise ValueError("product_id must be a non-empty path segment")
|
|
62
|
+
return self.downloads_cache_dir / f"{product_id}.json"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class CacheReadResult:
|
|
67
|
+
"""Read cache data plus freshness status."""
|
|
68
|
+
|
|
69
|
+
data: Mapping[str, Any]
|
|
70
|
+
status: CacheStatus
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_app_roots(env: Mapping[str, str] | None = None) -> AppRoots:
|
|
74
|
+
"""Resolve app config/cache/state roots using Linux/XDG conventions."""
|
|
75
|
+
values = os.environ if env is None else env
|
|
76
|
+
home = Path(values.get("HOME") or str(Path.home())).expanduser()
|
|
77
|
+
config_base = _base_dir(values, "XDG_CONFIG_HOME", home / ".config")
|
|
78
|
+
cache_base = _base_dir(values, "XDG_CACHE_HOME", home / ".cache")
|
|
79
|
+
state_base = _base_dir(values, "XDG_DATA_HOME", home / ".local" / "share")
|
|
80
|
+
return AppRoots(
|
|
81
|
+
config=config_base / APP_DIR_NAME,
|
|
82
|
+
cache=cache_base / APP_DIR_NAME,
|
|
83
|
+
state=state_base / APP_DIR_NAME,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_app_paths(env: Mapping[str, str] | None = None) -> AppPaths:
|
|
88
|
+
"""Resolve expected application-owned paths."""
|
|
89
|
+
roots = resolve_app_roots(env)
|
|
90
|
+
return AppPaths(
|
|
91
|
+
roots=roots,
|
|
92
|
+
config_file=roots.config / "config.toml",
|
|
93
|
+
library_cache=roots.cache / "library.json",
|
|
94
|
+
downloads_cache_dir=roots.cache / "downloads",
|
|
95
|
+
session_state=roots.state / "session.json",
|
|
96
|
+
cookies_file=roots.state / "auth" / "cookies.txt",
|
|
97
|
+
schema_file=roots.state / "schema.json",
|
|
98
|
+
locks_dir=roots.state / "locks",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_json_file(path: Path) -> Any:
|
|
103
|
+
"""Read a JSON file, raising typed errors for missing or corrupt state."""
|
|
104
|
+
try:
|
|
105
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
106
|
+
return json.load(handle)
|
|
107
|
+
except FileNotFoundError as exc:
|
|
108
|
+
raise StateFileMissingError(f"state file is missing: {path}") from exc
|
|
109
|
+
except json.JSONDecodeError as exc:
|
|
110
|
+
raise StateFileCorruptError(f"state file is corrupt JSON: {path}") from exc
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def write_json_file_atomic(path: Path, data: Any) -> None:
|
|
114
|
+
"""Write JSON atomically by replacing through a temp file in the same dir."""
|
|
115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
temp_name = ""
|
|
117
|
+
try:
|
|
118
|
+
with NamedTemporaryFile(
|
|
119
|
+
"w",
|
|
120
|
+
encoding="utf-8",
|
|
121
|
+
dir=path.parent,
|
|
122
|
+
prefix=f".{path.name}.",
|
|
123
|
+
suffix=".tmp",
|
|
124
|
+
delete=False,
|
|
125
|
+
) as handle:
|
|
126
|
+
temp_name = handle.name
|
|
127
|
+
json.dump(data, handle, indent=2, sort_keys=True)
|
|
128
|
+
handle.write("\n")
|
|
129
|
+
handle.flush()
|
|
130
|
+
os.fsync(handle.fileno())
|
|
131
|
+
os.replace(temp_name, path)
|
|
132
|
+
finally:
|
|
133
|
+
if temp_name:
|
|
134
|
+
with suppress(FileNotFoundError):
|
|
135
|
+
os.unlink(temp_name)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def read_cache_file(
|
|
139
|
+
path: Path,
|
|
140
|
+
*,
|
|
141
|
+
max_age: timedelta | None = None,
|
|
142
|
+
now: datetime | None = None,
|
|
143
|
+
) -> CacheReadResult:
|
|
144
|
+
"""Read a JSON cache file and classify it as fresh or stale."""
|
|
145
|
+
data = read_json_file(path)
|
|
146
|
+
if not isinstance(data, Mapping):
|
|
147
|
+
raise StateFileInvalidError(f"cache file must contain a JSON object: {path}")
|
|
148
|
+
return CacheReadResult(
|
|
149
|
+
data=data,
|
|
150
|
+
status=_cache_status(data, max_age=max_age, now=now),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def utc_timestamp() -> str:
|
|
155
|
+
"""Return a UTC timestamp suitable for cache metadata."""
|
|
156
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _base_dir(env: Mapping[str, str], key: str, fallback: Path) -> Path:
|
|
160
|
+
value = env.get(key)
|
|
161
|
+
if not value:
|
|
162
|
+
return fallback.expanduser()
|
|
163
|
+
return Path(value).expanduser()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _cache_status(
|
|
167
|
+
data: Mapping[str, Any],
|
|
168
|
+
*,
|
|
169
|
+
max_age: timedelta | None,
|
|
170
|
+
now: datetime | None,
|
|
171
|
+
) -> CacheStatus:
|
|
172
|
+
if max_age is None:
|
|
173
|
+
return "fresh"
|
|
174
|
+
updated_at = data.get("updated_at")
|
|
175
|
+
if not isinstance(updated_at, str):
|
|
176
|
+
return "stale"
|
|
177
|
+
updated = _parse_timestamp(updated_at)
|
|
178
|
+
if updated is None:
|
|
179
|
+
return "stale"
|
|
180
|
+
reference = now or datetime.now(UTC)
|
|
181
|
+
if reference.tzinfo is None:
|
|
182
|
+
reference = reference.replace(tzinfo=UTC)
|
|
183
|
+
return "stale" if reference - updated > max_age else "fresh"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_timestamp(value: str) -> datetime | None:
|
|
187
|
+
try:
|
|
188
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
189
|
+
except ValueError:
|
|
190
|
+
return None
|
|
191
|
+
if parsed.tzinfo is None:
|
|
192
|
+
return parsed.replace(tzinfo=UTC)
|
|
193
|
+
return parsed.astimezone(UTC)
|
gog_cli/sync.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Sync planning and stale-backup detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from gog_cli.backup import FileSpec, PlannedFile, _game_product_id, _role_dir
|
|
10
|
+
from gog_cli.layout import BackupLayout, sanitize_filename
|
|
11
|
+
|
|
12
|
+
ComparisonStatus = Literal["current", "stale", "missing", "partial", "unverified"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FileComparison:
|
|
17
|
+
source_id: str
|
|
18
|
+
role: str
|
|
19
|
+
platform: str | None
|
|
20
|
+
language: str | None
|
|
21
|
+
status: ComparisonStatus
|
|
22
|
+
stale_reason: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SyncPlan:
|
|
27
|
+
destination: Path
|
|
28
|
+
comparisons: list[FileComparison]
|
|
29
|
+
to_download: list[PlannedFile]
|
|
30
|
+
to_verify: list[PlannedFile]
|
|
31
|
+
current: list[FileComparison]
|
|
32
|
+
estimated_bytes: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def compare_file(spec: FileSpec, manifest_record: dict | None) -> FileComparison:
|
|
36
|
+
base = FileComparison(
|
|
37
|
+
source_id=spec.source_id,
|
|
38
|
+
role=spec.role,
|
|
39
|
+
platform=spec.platform,
|
|
40
|
+
language=spec.language,
|
|
41
|
+
status="missing",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if manifest_record is None:
|
|
45
|
+
return base
|
|
46
|
+
|
|
47
|
+
rec_status = manifest_record.get("status", "")
|
|
48
|
+
if rec_status == "partial":
|
|
49
|
+
return FileComparison(**{**base.__dict__, "status": "partial"})
|
|
50
|
+
if rec_status == "downloaded":
|
|
51
|
+
return FileComparison(**{**base.__dict__, "status": "unverified"})
|
|
52
|
+
|
|
53
|
+
# Check staleness
|
|
54
|
+
if manifest_record.get("source_id") != spec.source_id:
|
|
55
|
+
return FileComparison(**{**base.__dict__, "status": "stale", "stale_reason": "id_changed"})
|
|
56
|
+
if manifest_record.get("version") != spec.version:
|
|
57
|
+
return FileComparison(
|
|
58
|
+
**{**base.__dict__, "status": "stale", "stale_reason": "version_changed"}
|
|
59
|
+
)
|
|
60
|
+
if manifest_record.get("expected_size") != spec.expected_size:
|
|
61
|
+
return FileComparison(
|
|
62
|
+
**{**base.__dict__, "status": "stale", "stale_reason": "size_changed"}
|
|
63
|
+
)
|
|
64
|
+
record_md5 = manifest_record.get("expected_md5")
|
|
65
|
+
checksum = manifest_record.get("checksum")
|
|
66
|
+
if isinstance(checksum, dict):
|
|
67
|
+
record_md5 = checksum.get("value")
|
|
68
|
+
if record_md5 != spec.expected_md5:
|
|
69
|
+
return FileComparison(
|
|
70
|
+
**{**base.__dict__, "status": "stale", "stale_reason": "checksum_changed"}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return FileComparison(**{**base.__dict__, "status": "current"})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def plan_sync(
|
|
77
|
+
destination: Path,
|
|
78
|
+
games: list[dict],
|
|
79
|
+
download_specs: dict[str, list[FileSpec]],
|
|
80
|
+
manifest: dict,
|
|
81
|
+
layout: BackupLayout,
|
|
82
|
+
*,
|
|
83
|
+
platforms: list[str] | None = None,
|
|
84
|
+
languages: list[str] | None = None,
|
|
85
|
+
file_roles: list[str] | None = None,
|
|
86
|
+
) -> SyncPlan:
|
|
87
|
+
manifest_games: dict[str, dict] = {}
|
|
88
|
+
for g in manifest.get("games", []):
|
|
89
|
+
for f in g.get("files", []):
|
|
90
|
+
key = _file_key(f.get("role"), f.get("platform"), f.get("language"), f.get("source_id"))
|
|
91
|
+
manifest_games.setdefault(str(g.get("product_id", "")), {})[key] = f
|
|
92
|
+
|
|
93
|
+
comparisons: list[FileComparison] = []
|
|
94
|
+
to_download: list[PlannedFile] = []
|
|
95
|
+
to_verify: list[PlannedFile] = []
|
|
96
|
+
current: list[FileComparison] = []
|
|
97
|
+
estimated_bytes = 0
|
|
98
|
+
|
|
99
|
+
for game in games:
|
|
100
|
+
product_id = _game_product_id(game)
|
|
101
|
+
slug = sanitize_filename(game.get("slug") or product_id)
|
|
102
|
+
game_dir = layout.game_dir(slug)
|
|
103
|
+
game_manifest = manifest_games.get(product_id, {})
|
|
104
|
+
|
|
105
|
+
for spec in download_specs.get(product_id, []):
|
|
106
|
+
if platforms and spec.platform and spec.platform not in platforms:
|
|
107
|
+
continue
|
|
108
|
+
if languages and spec.language and spec.language not in languages:
|
|
109
|
+
continue
|
|
110
|
+
if file_roles and spec.role not in file_roles:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
key = _file_key(spec.role, spec.platform, spec.language, spec.source_id)
|
|
114
|
+
record = game_manifest.get(key)
|
|
115
|
+
comparison = compare_file(spec, record)
|
|
116
|
+
comparisons.append(comparison)
|
|
117
|
+
|
|
118
|
+
dest_dir = _role_dir(layout, game_dir, spec.role)
|
|
119
|
+
dest = dest_dir / sanitize_filename(spec.filename or spec.source_id)
|
|
120
|
+
|
|
121
|
+
if comparison.status in ("missing", "stale", "partial"):
|
|
122
|
+
to_download.append(PlannedFile(spec=spec, dest=dest, action="download"))
|
|
123
|
+
if spec.expected_size:
|
|
124
|
+
estimated_bytes += spec.expected_size
|
|
125
|
+
elif comparison.status == "unverified":
|
|
126
|
+
to_verify.append(PlannedFile(spec=spec, dest=dest, action="verify"))
|
|
127
|
+
elif comparison.status == "current":
|
|
128
|
+
current.append(comparison)
|
|
129
|
+
|
|
130
|
+
return SyncPlan(
|
|
131
|
+
destination=destination,
|
|
132
|
+
comparisons=comparisons,
|
|
133
|
+
to_download=to_download,
|
|
134
|
+
to_verify=to_verify,
|
|
135
|
+
current=current,
|
|
136
|
+
estimated_bytes=estimated_bytes,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _file_key(
|
|
141
|
+
role: str | None,
|
|
142
|
+
platform: str | None,
|
|
143
|
+
language: str | None,
|
|
144
|
+
source_id: str | None,
|
|
145
|
+
) -> str:
|
|
146
|
+
return f"{role}:{platform}:{language}:{source_id}"
|
|
@@ -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
|
+
[](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,25 @@
|
|
|
1
|
+
gog_cli/__init__.py,sha256=cVhMzEvfgCoD2nRTTCEETe6ct0TfruTaiJ8lddmXO_c,73
|
|
2
|
+
gog_cli/api.py,sha256=v-iEV7T_seYUWNl2AHHy-qLODewkQh7vrNIjpWf1yt8,5246
|
|
3
|
+
gog_cli/aria2c.py,sha256=DFRL4GUGE-JzSKPuU0BvazKdwCYJNtT43V4RtALRG7U,4181
|
|
4
|
+
gog_cli/auth.py,sha256=6AhtJ3v_2wlAdw0wR8KoKOCz4xaBBoym5K8iv9bfHso,7144
|
|
5
|
+
gog_cli/backup.py,sha256=MsQDM2h8_hFjCCFadMQNH5ZybblrVjvWZSlmTaOXmZU,5971
|
|
6
|
+
gog_cli/cli.py,sha256=77BtIc9rQhLffKfDsetOsKje8Q5JQDMBzy1Ad-HqgEU,18168
|
|
7
|
+
gog_cli/config.py,sha256=4nY62B0ElRV6cW1y_UcXJFNi6RZGI8nA2eBxZlu04hk,4185
|
|
8
|
+
gog_cli/downloader.py,sha256=G0cBQ4CNN4FYNZi-_k1jZGKdgcCaXOnhU6kO8F6eY_g,6919
|
|
9
|
+
gog_cli/errors.py,sha256=iUo3xAQliNJuQUzVQPiIfRxJHtm6bEmwonvrlNChR1I,1185
|
|
10
|
+
gog_cli/execution.py,sha256=WMaQAojT2grafBAzIoPFL_obXUF1snjolxRRJWTMe0o,36571
|
|
11
|
+
gog_cli/layout.py,sha256=Ea1utRw7MjcXTznI5bpi3DgIokZNj-Yv3w0xUcPXcXc,1921
|
|
12
|
+
gog_cli/listing.py,sha256=mZYUmk7geEms6e_DtPKJY-xV45CcUu9H5WWS6rbdM8g,22884
|
|
13
|
+
gog_cli/log.py,sha256=z6sEDbqS7kQYeeD6MCFx3RaFF997lA_Boges-jAHLgI,468
|
|
14
|
+
gog_cli/metadata.py,sha256=5hP16kpxt-N2rzpPWWnIUOnNdG0lkkRTMCQ9AfAcdBs,6961
|
|
15
|
+
gog_cli/output.py,sha256=r4Zou7BpAzMXdVbGtQoT7Qq9lqsdZQIJDdnLUH7gPtw,2561
|
|
16
|
+
gog_cli/prompt.py,sha256=tbWPr_9yd3-SMhQqxnx_hUM6s4pszh_6ph5-KvqPNKQ,1670
|
|
17
|
+
gog_cli/refresh.py,sha256=VTM6Gthi97pvRuZhRlPg32ixUF9xpKOzfZj_2oTDcCo,7277
|
|
18
|
+
gog_cli/state.py,sha256=X4DkVkJ4ZViH1wgLA8xHZgOABqlIwdato5zS6FpZ9VY,5904
|
|
19
|
+
gog_cli/sync.py,sha256=OS0nWNxd4LLIMe2NKDHII8oiQX1DZS72W_dKmgmwilM,4971
|
|
20
|
+
gog_cli-0.2.1.dist-info/licenses/LICENSE,sha256=6gwrSQnIro2IPRBL1bUj7r9cc7oG-WsufbDC8xHv1Lg,1077
|
|
21
|
+
gog_cli-0.2.1.dist-info/METADATA,sha256=aAlbT8u4UuTFf5m1c_5URCsmb0o7oYlhYwEj9nW12jU,6673
|
|
22
|
+
gog_cli-0.2.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
23
|
+
gog_cli-0.2.1.dist-info/entry_points.txt,sha256=R8ndz-Q46JZ2OBsfqsZN4b5DhHkFvEUwbGkc0P0rqI8,41
|
|
24
|
+
gog_cli-0.2.1.dist-info/top_level.txt,sha256=ghVgOu6AMaHYlBtW37jklNOIdZviBwVAvN23Eq1EOLw,8
|
|
25
|
+
gog_cli-0.2.1.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gog_cli
|