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/execution.py
ADDED
|
@@ -0,0 +1,1054 @@
|
|
|
1
|
+
"""Backup and sync command execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from email.message import Message
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from gog_cli import __version__, log
|
|
17
|
+
from gog_cli.api import GogApiClient
|
|
18
|
+
from gog_cli.aria2c import check_aria2c, download_via_aria2c
|
|
19
|
+
from gog_cli.auth import FileTokenStore
|
|
20
|
+
from gog_cli.backup import (
|
|
21
|
+
BackupPlan,
|
|
22
|
+
FileSpec,
|
|
23
|
+
PlannedFile,
|
|
24
|
+
_game_product_id,
|
|
25
|
+
plan_backup,
|
|
26
|
+
select_games,
|
|
27
|
+
)
|
|
28
|
+
from gog_cli.config import load_config
|
|
29
|
+
from gog_cli.downloader import Downloader, DownloadResult, fetch_checksum_xml
|
|
30
|
+
from gog_cli.errors import (
|
|
31
|
+
AuthError,
|
|
32
|
+
CacheError,
|
|
33
|
+
ExitCode,
|
|
34
|
+
FilesystemError,
|
|
35
|
+
NetworkError,
|
|
36
|
+
ParserError,
|
|
37
|
+
UsageError,
|
|
38
|
+
)
|
|
39
|
+
from gog_cli.layout import BackupLayout, sanitize_filename
|
|
40
|
+
from gog_cli.output import JsonEnvelope, OutputFormat, print_human, print_json
|
|
41
|
+
from gog_cli.prompt import is_interactive, numbered_prompt
|
|
42
|
+
from gog_cli.state import (
|
|
43
|
+
StateFileCorruptError,
|
|
44
|
+
StateFileMissingError,
|
|
45
|
+
read_json_file,
|
|
46
|
+
resolve_app_paths,
|
|
47
|
+
utc_timestamp,
|
|
48
|
+
write_json_file_atomic,
|
|
49
|
+
)
|
|
50
|
+
from gog_cli.sync import SyncPlan, plan_sync
|
|
51
|
+
|
|
52
|
+
_log = log.get_logger(__name__)
|
|
53
|
+
|
|
54
|
+
_SUPPORTED_MANIFEST_SCHEMA = 1
|
|
55
|
+
_ROLE_MAP = {
|
|
56
|
+
"installers": "installer",
|
|
57
|
+
"patches": "patch",
|
|
58
|
+
"language_packs": "language_pack",
|
|
59
|
+
"bonus_content": "extra",
|
|
60
|
+
"manuals": "manual",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ExecutionResult:
|
|
66
|
+
file: PlannedFile
|
|
67
|
+
game: dict[str, Any]
|
|
68
|
+
result: DownloadResult
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def handle_backup(args: argparse.Namespace) -> int:
|
|
72
|
+
context = _load_context(args, require_manifest=False)
|
|
73
|
+
selected = _select_games(context.library, args)
|
|
74
|
+
context.download_specs = _load_download_specs(context.paths, selected)
|
|
75
|
+
_validate_filters(context, selected)
|
|
76
|
+
plan = plan_backup(
|
|
77
|
+
context.destination,
|
|
78
|
+
selected,
|
|
79
|
+
context.download_specs,
|
|
80
|
+
context.layout,
|
|
81
|
+
platforms=context.platforms,
|
|
82
|
+
languages=context.languages,
|
|
83
|
+
file_roles=context.file_roles,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
getattr(args, "check_free_space", False)
|
|
88
|
+
and plan.disk_free_bytes is not None
|
|
89
|
+
and plan.disk_free_bytes < plan.disk_required_bytes
|
|
90
|
+
):
|
|
91
|
+
print(
|
|
92
|
+
f"Insufficient disk space: {_human_size(plan.disk_free_bytes)} free, "
|
|
93
|
+
f"{_human_size(plan.disk_required_bytes)} required.",
|
|
94
|
+
file=sys.stderr,
|
|
95
|
+
)
|
|
96
|
+
return ExitCode.FILESYSTEM
|
|
97
|
+
|
|
98
|
+
is_dry_run = args.dry_run or not args.yes
|
|
99
|
+
output_format = OutputFormat(getattr(args, "output_format", "human"))
|
|
100
|
+
|
|
101
|
+
if is_dry_run and output_format == OutputFormat.JSON:
|
|
102
|
+
_print_plan_json(plan, context, selected, args)
|
|
103
|
+
return ExitCode.SUCCESS
|
|
104
|
+
|
|
105
|
+
_print_backup_plan(plan, context, selected, args, is_dry_run=is_dry_run)
|
|
106
|
+
|
|
107
|
+
if is_dry_run:
|
|
108
|
+
return ExitCode.SUCCESS
|
|
109
|
+
|
|
110
|
+
files_to_process = [
|
|
111
|
+
file for file in plan.planned if file.action in ("download", "verify", "skip")
|
|
112
|
+
]
|
|
113
|
+
return _execute_files("backup", context, selected, files_to_process)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def handle_plan(args: argparse.Namespace) -> int:
|
|
117
|
+
positional_selectors = list(getattr(args, "selectors", []) or [])
|
|
118
|
+
if positional_selectors:
|
|
119
|
+
args.games = [*(getattr(args, "games", []) or []), *positional_selectors]
|
|
120
|
+
args.dry_run = True
|
|
121
|
+
args.yes = False
|
|
122
|
+
args.no_interactive = True
|
|
123
|
+
args.downloader = "direct"
|
|
124
|
+
return handle_backup(args)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def handle_sync(args: argparse.Namespace) -> int:
|
|
128
|
+
context = _load_context(args, require_manifest=True)
|
|
129
|
+
selected = _select_games(context.library, args)
|
|
130
|
+
context.download_specs = _load_download_specs(context.paths, selected)
|
|
131
|
+
_validate_filters(context, selected)
|
|
132
|
+
plan = plan_sync(
|
|
133
|
+
context.destination,
|
|
134
|
+
selected,
|
|
135
|
+
context.download_specs,
|
|
136
|
+
context.manifest,
|
|
137
|
+
context.layout,
|
|
138
|
+
platforms=context.platforms,
|
|
139
|
+
languages=context.languages,
|
|
140
|
+
file_roles=context.file_roles,
|
|
141
|
+
)
|
|
142
|
+
files_to_process = [*plan.to_download, *plan.to_verify]
|
|
143
|
+
_print_sync_plan(plan, len(files_to_process))
|
|
144
|
+
|
|
145
|
+
if args.dry_run:
|
|
146
|
+
return ExitCode.SUCCESS
|
|
147
|
+
if not args.yes:
|
|
148
|
+
print_human(["Dry run. Re-run with --yes to execute."])
|
|
149
|
+
return ExitCode.SUCCESS
|
|
150
|
+
|
|
151
|
+
return _execute_files("sync", context, selected, files_to_process)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class _ExecutionContext:
|
|
156
|
+
paths: Any
|
|
157
|
+
destination: Path
|
|
158
|
+
layout: BackupLayout
|
|
159
|
+
library: list[dict[str, Any]]
|
|
160
|
+
download_specs: dict[str, list[FileSpec]]
|
|
161
|
+
manifest: dict[str, Any]
|
|
162
|
+
output_format: OutputFormat
|
|
163
|
+
downloader: str
|
|
164
|
+
platforms: list[str]
|
|
165
|
+
languages: list[str]
|
|
166
|
+
file_roles: list[str]
|
|
167
|
+
client: GogApiClient
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _load_context(args: argparse.Namespace, *, require_manifest: bool) -> _ExecutionContext:
|
|
171
|
+
paths = resolve_app_paths()
|
|
172
|
+
config = load_config(paths)
|
|
173
|
+
destination = (args.destination or config.destination)
|
|
174
|
+
if destination is None:
|
|
175
|
+
raise UsageError(
|
|
176
|
+
"Backup destination is required. Use --destination or GOG_CLI_DESTINATION."
|
|
177
|
+
)
|
|
178
|
+
destination = Path(destination).expanduser()
|
|
179
|
+
if destination.exists() and not destination.is_dir():
|
|
180
|
+
raise FilesystemError(f"Backup destination is not a directory: {destination}")
|
|
181
|
+
layout = BackupLayout(destination)
|
|
182
|
+
|
|
183
|
+
library_cache = _load_library_cache(paths.library_cache)
|
|
184
|
+
library = [_normalize_game(game) for game in library_cache["games"]]
|
|
185
|
+
|
|
186
|
+
if require_manifest:
|
|
187
|
+
manifest = _read_manifest(layout.manifest_file)
|
|
188
|
+
else:
|
|
189
|
+
manifest = _read_manifest(layout.manifest_file, missing_ok=True)
|
|
190
|
+
|
|
191
|
+
downloader = args.downloader or config.downloader
|
|
192
|
+
|
|
193
|
+
return _ExecutionContext(
|
|
194
|
+
paths=paths,
|
|
195
|
+
destination=destination,
|
|
196
|
+
layout=layout,
|
|
197
|
+
library=library,
|
|
198
|
+
download_specs={},
|
|
199
|
+
manifest=manifest,
|
|
200
|
+
output_format=OutputFormat(config.output_format),
|
|
201
|
+
downloader=downloader,
|
|
202
|
+
platforms=args.platforms or config.platforms,
|
|
203
|
+
languages=args.languages or config.languages,
|
|
204
|
+
file_roles=config.file_roles,
|
|
205
|
+
client=GogApiClient(FileTokenStore(paths)),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _load_library_cache(path: Path) -> dict[str, Any]:
|
|
210
|
+
try:
|
|
211
|
+
data = read_json_file(path)
|
|
212
|
+
except StateFileMissingError:
|
|
213
|
+
raise CacheError("Purchased library cache is missing. Run `gog refresh`.") from None
|
|
214
|
+
except StateFileCorruptError as exc:
|
|
215
|
+
raise ParserError(f"Purchased library cache is corrupt: {exc}") from exc
|
|
216
|
+
if not isinstance(data, dict) or not isinstance(data.get("games"), list):
|
|
217
|
+
raise ParserError(f"Purchased library cache has unsupported shape: {path}")
|
|
218
|
+
return data
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _load_download_specs(paths: Any, library: list[dict[str, Any]]) -> dict[str, list[FileSpec]]:
|
|
222
|
+
specs: dict[str, list[FileSpec]] = {}
|
|
223
|
+
for game in library:
|
|
224
|
+
product_id = _game_product_id(game)
|
|
225
|
+
cache_path = paths.download_cache(product_id)
|
|
226
|
+
try:
|
|
227
|
+
data = read_json_file(cache_path)
|
|
228
|
+
except StateFileMissingError:
|
|
229
|
+
raise CacheError(
|
|
230
|
+
f"Download metadata cache is missing for {game.get('title', product_id)}. "
|
|
231
|
+
"Run `gog refresh`."
|
|
232
|
+
) from None
|
|
233
|
+
except StateFileCorruptError as exc:
|
|
234
|
+
raise ParserError(
|
|
235
|
+
f"Download metadata cache is corrupt for {product_id}: {exc}"
|
|
236
|
+
) from exc
|
|
237
|
+
specs[product_id] = parse_download_specs(data)
|
|
238
|
+
return specs
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def parse_download_specs(cache: dict[str, Any]) -> list[FileSpec]:
|
|
242
|
+
product = cache.get("data", cache)
|
|
243
|
+
downloads = product.get("downloads")
|
|
244
|
+
if not isinstance(downloads, dict):
|
|
245
|
+
raise ParserError("Download metadata is missing a downloads object")
|
|
246
|
+
|
|
247
|
+
specs: list[FileSpec] = []
|
|
248
|
+
for key, role in _ROLE_MAP.items():
|
|
249
|
+
entries = downloads.get(key, [])
|
|
250
|
+
if not isinstance(entries, list):
|
|
251
|
+
continue
|
|
252
|
+
for entry in entries:
|
|
253
|
+
if not isinstance(entry, dict):
|
|
254
|
+
continue
|
|
255
|
+
files = entry.get("files", [])
|
|
256
|
+
if not isinstance(files, list):
|
|
257
|
+
continue
|
|
258
|
+
for file_entry in files:
|
|
259
|
+
if not isinstance(file_entry, dict):
|
|
260
|
+
continue
|
|
261
|
+
source_id = str(file_entry.get("id") or entry.get("id") or "")
|
|
262
|
+
downlink_url = str(file_entry.get("downlink") or "")
|
|
263
|
+
if not source_id or not downlink_url:
|
|
264
|
+
continue
|
|
265
|
+
filename = _download_filename(file_entry, entry, source_id)
|
|
266
|
+
specs.append(
|
|
267
|
+
FileSpec(
|
|
268
|
+
source_id=source_id,
|
|
269
|
+
role=role,
|
|
270
|
+
platform=_optional_str(entry.get("os")),
|
|
271
|
+
language=_optional_str(entry.get("language")),
|
|
272
|
+
version=_optional_str(entry.get("version")),
|
|
273
|
+
expected_size=_optional_int(
|
|
274
|
+
file_entry.get("size", entry.get("total_size"))
|
|
275
|
+
),
|
|
276
|
+
expected_md5=None,
|
|
277
|
+
downlink_url=downlink_url,
|
|
278
|
+
checksum_url=None,
|
|
279
|
+
filename=filename,
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
if downloads and not specs:
|
|
283
|
+
raise ParserError("Download metadata did not contain any supported file entries")
|
|
284
|
+
return specs
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _select_games(library: list[dict[str, Any]], args: argparse.Namespace) -> list[dict[str, Any]]:
|
|
288
|
+
game_selectors = _game_selectors_from_args(args)
|
|
289
|
+
if args.all_games or game_selectors:
|
|
290
|
+
selected = select_games(
|
|
291
|
+
library,
|
|
292
|
+
game_selectors=game_selectors,
|
|
293
|
+
exclude=args.exclude,
|
|
294
|
+
all_games=args.all_games,
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
if args.no_interactive or not is_interactive():
|
|
298
|
+
raise UsageError("No games selected. Use --all, --game, or --games-from.")
|
|
299
|
+
labels = [
|
|
300
|
+
f"{game.get('title', '')} ({_game_product_id(game)}, {game.get('slug', '')})"
|
|
301
|
+
for game in library
|
|
302
|
+
]
|
|
303
|
+
indices = numbered_prompt(labels, "Select games to process:")
|
|
304
|
+
selected = [library[index] for index in indices]
|
|
305
|
+
if args.exclude:
|
|
306
|
+
selected = select_games(selected, exclude=args.exclude, all_games=True)
|
|
307
|
+
|
|
308
|
+
if not selected:
|
|
309
|
+
raise UsageError("No games selected after applying filters.")
|
|
310
|
+
return selected
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _game_selectors_from_args(args: argparse.Namespace) -> list[str]:
|
|
314
|
+
selectors = list(getattr(args, "games", []) or [])
|
|
315
|
+
for path in getattr(args, "games_from", []) or []:
|
|
316
|
+
selectors.extend(_read_game_selector_file(Path(path)))
|
|
317
|
+
return selectors
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _read_game_selector_file(path: Path) -> list[str]:
|
|
321
|
+
try:
|
|
322
|
+
lines = path.expanduser().read_text(encoding="utf-8").splitlines()
|
|
323
|
+
except FileNotFoundError:
|
|
324
|
+
raise UsageError(f"Game selector file does not exist: {path}") from None
|
|
325
|
+
except OSError as exc:
|
|
326
|
+
raise UsageError(f"Could not read game selector file {path}: {exc}") from exc
|
|
327
|
+
|
|
328
|
+
selectors = []
|
|
329
|
+
for line in lines:
|
|
330
|
+
stripped = line.strip()
|
|
331
|
+
if not stripped or stripped.startswith("#"):
|
|
332
|
+
continue
|
|
333
|
+
selectors.append(stripped)
|
|
334
|
+
return selectors
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _confirm_if_needed(args: argparse.Namespace, files_to_process: list[PlannedFile]) -> None:
|
|
338
|
+
if not files_to_process:
|
|
339
|
+
return
|
|
340
|
+
if args.yes:
|
|
341
|
+
return
|
|
342
|
+
raise UsageError("Refusing to modify backups without confirmation. Re-run with --yes.")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _execute_files(
|
|
346
|
+
command: str,
|
|
347
|
+
context: _ExecutionContext,
|
|
348
|
+
selected_games: list[dict[str, Any]],
|
|
349
|
+
files_to_process: list[PlannedFile],
|
|
350
|
+
) -> int:
|
|
351
|
+
session = requests.Session()
|
|
352
|
+
downloader = Downloader(session)
|
|
353
|
+
file_to_game = _map_files_to_games(context.layout, selected_games, context.download_specs)
|
|
354
|
+
results: list[ExecutionResult] = []
|
|
355
|
+
auth_failed = False
|
|
356
|
+
|
|
357
|
+
context.destination.mkdir(parents=True, exist_ok=True)
|
|
358
|
+
if context.downloader == "aria2c" and files_to_process:
|
|
359
|
+
check_aria2c()
|
|
360
|
+
|
|
361
|
+
for planned in files_to_process:
|
|
362
|
+
game = file_to_game.get(str(planned.dest), {})
|
|
363
|
+
if planned.action in {"skip", "verify"}:
|
|
364
|
+
result = _verify_existing(planned)
|
|
365
|
+
results.append(_record_and_report(context, command, game, planned, result))
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
signed_url, checksum_url = context.client.resolve_downlink_url(
|
|
370
|
+
planned.spec.downlink_url
|
|
371
|
+
)
|
|
372
|
+
except AuthError as exc:
|
|
373
|
+
print(
|
|
374
|
+
f"Authentication failed while resolving {planned.spec.source_id}: {exc}",
|
|
375
|
+
file=sys.stderr,
|
|
376
|
+
)
|
|
377
|
+
auth_failed = True
|
|
378
|
+
break
|
|
379
|
+
except NetworkError as exc:
|
|
380
|
+
result = DownloadResult(
|
|
381
|
+
status="failed",
|
|
382
|
+
path=planned.dest,
|
|
383
|
+
expected_size=planned.spec.expected_size,
|
|
384
|
+
failure_code="resolve_failed",
|
|
385
|
+
failure_message=str(exc),
|
|
386
|
+
)
|
|
387
|
+
results.append(_record_and_report(context, command, game, planned, result))
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
_apply_header_filename(session, signed_url, planned, context.layout, game)
|
|
391
|
+
expected_md5, expected_size = _resolve_checksum(session, checksum_url, planned.spec)
|
|
392
|
+
planned.spec.expected_md5 = expected_md5
|
|
393
|
+
planned.spec.expected_size = expected_size
|
|
394
|
+
result = _download(
|
|
395
|
+
context.downloader,
|
|
396
|
+
signed_url,
|
|
397
|
+
planned.dest,
|
|
398
|
+
expected_size,
|
|
399
|
+
expected_md5,
|
|
400
|
+
downloader,
|
|
401
|
+
)
|
|
402
|
+
signed_url = ""
|
|
403
|
+
results.append(_record_and_report(context, command, game, planned, result))
|
|
404
|
+
|
|
405
|
+
if context.output_format == OutputFormat.JSON:
|
|
406
|
+
print_json(JsonEnvelope(command=command, data=[_result_to_json(item) for item in results]))
|
|
407
|
+
else:
|
|
408
|
+
_print_execution_summary(results, auth_failed=auth_failed)
|
|
409
|
+
|
|
410
|
+
if auth_failed:
|
|
411
|
+
return ExitCode.AUTH
|
|
412
|
+
if not results:
|
|
413
|
+
return ExitCode.SUCCESS
|
|
414
|
+
failed = [item for item in results if item.result.status in {"failed", "partial"}]
|
|
415
|
+
if failed:
|
|
416
|
+
return ExitCode.FAILURE
|
|
417
|
+
return ExitCode.SUCCESS
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _verify_existing(planned: PlannedFile) -> DownloadResult:
|
|
421
|
+
if not planned.dest.exists():
|
|
422
|
+
return DownloadResult(
|
|
423
|
+
status="failed",
|
|
424
|
+
path=planned.dest,
|
|
425
|
+
expected_size=planned.spec.expected_size,
|
|
426
|
+
failure_code="missing_file",
|
|
427
|
+
failure_message="Expected file is missing",
|
|
428
|
+
)
|
|
429
|
+
if (
|
|
430
|
+
planned.spec.expected_size is not None
|
|
431
|
+
and planned.dest.stat().st_size != planned.spec.expected_size
|
|
432
|
+
):
|
|
433
|
+
return DownloadResult(
|
|
434
|
+
status="failed",
|
|
435
|
+
path=planned.dest,
|
|
436
|
+
expected_size=planned.spec.expected_size,
|
|
437
|
+
failure_code="size_mismatch",
|
|
438
|
+
failure_message=(
|
|
439
|
+
f"Expected {planned.spec.expected_size} bytes, got {planned.dest.stat().st_size}"
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
if (
|
|
443
|
+
planned.spec.expected_md5 is not None
|
|
444
|
+
and _md5_file(planned.dest) != planned.spec.expected_md5.lower()
|
|
445
|
+
):
|
|
446
|
+
return DownloadResult(
|
|
447
|
+
status="failed",
|
|
448
|
+
path=planned.dest,
|
|
449
|
+
expected_size=planned.spec.expected_size,
|
|
450
|
+
failure_code="checksum_mismatch",
|
|
451
|
+
failure_message="MD5 checksum did not match expected value",
|
|
452
|
+
)
|
|
453
|
+
return DownloadResult(
|
|
454
|
+
status="verified",
|
|
455
|
+
path=planned.dest,
|
|
456
|
+
bytes_downloaded=planned.dest.stat().st_size,
|
|
457
|
+
expected_size=planned.spec.expected_size,
|
|
458
|
+
checksum_verified=planned.spec.expected_md5 is not None,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _download(
|
|
463
|
+
downloader_name: str,
|
|
464
|
+
signed_url: str,
|
|
465
|
+
dest: Path,
|
|
466
|
+
expected_size: int | None,
|
|
467
|
+
expected_md5: str | None,
|
|
468
|
+
downloader: Downloader,
|
|
469
|
+
) -> DownloadResult:
|
|
470
|
+
if downloader_name == "aria2c":
|
|
471
|
+
return download_via_aria2c(
|
|
472
|
+
signed_url,
|
|
473
|
+
dest,
|
|
474
|
+
expected_size=expected_size,
|
|
475
|
+
expected_md5=expected_md5,
|
|
476
|
+
)
|
|
477
|
+
return downloader.download(
|
|
478
|
+
signed_url,
|
|
479
|
+
dest,
|
|
480
|
+
expected_size=expected_size,
|
|
481
|
+
expected_md5=expected_md5,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _record_and_report(
|
|
486
|
+
context: _ExecutionContext,
|
|
487
|
+
command: str,
|
|
488
|
+
game: dict[str, Any],
|
|
489
|
+
planned: PlannedFile,
|
|
490
|
+
result: DownloadResult,
|
|
491
|
+
) -> ExecutionResult:
|
|
492
|
+
item = ExecutionResult(file=planned, game=game, result=result)
|
|
493
|
+
_update_manifest(context.manifest, context.layout, game, planned, result)
|
|
494
|
+
write_json_file_atomic(context.layout.manifest_file, context.manifest)
|
|
495
|
+
if context.output_format == OutputFormat.HUMAN:
|
|
496
|
+
title = game.get("title", _game_product_id(game))
|
|
497
|
+
line = f"{result.status} {title} / {planned.spec.role} / {planned.spec.platform or '-'}"
|
|
498
|
+
if result.status in {"failed", "partial"} and result.failure_message:
|
|
499
|
+
line += f" — {result.failure_code}: {result.failure_message}"
|
|
500
|
+
print(line)
|
|
501
|
+
_log.debug("%s recorded %s for %s", command, result.status, planned.spec.source_id)
|
|
502
|
+
return item
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _update_manifest(
|
|
506
|
+
manifest: dict[str, Any],
|
|
507
|
+
layout: BackupLayout,
|
|
508
|
+
game: dict[str, Any],
|
|
509
|
+
planned: PlannedFile,
|
|
510
|
+
result: DownloadResult,
|
|
511
|
+
) -> None:
|
|
512
|
+
now = utc_timestamp()
|
|
513
|
+
manifest.setdefault("schema_version", _SUPPORTED_MANIFEST_SCHEMA)
|
|
514
|
+
manifest.setdefault("created_at", now)
|
|
515
|
+
manifest["updated_at"] = now
|
|
516
|
+
manifest.setdefault("tool", {"name": "gog-cli", "version": __version__})
|
|
517
|
+
manifest.setdefault("backup_root_marker", f"gog-cli-backup:{uuid4()}")
|
|
518
|
+
games = manifest.setdefault("games", [])
|
|
519
|
+
|
|
520
|
+
product_id = _game_product_id(game)
|
|
521
|
+
game_record = next((g for g in games if str(g.get("product_id")) == product_id), None)
|
|
522
|
+
if game_record is None:
|
|
523
|
+
slug = sanitize_filename(str(game.get("slug") or product_id))
|
|
524
|
+
game_record = {
|
|
525
|
+
"product_id": product_id,
|
|
526
|
+
"title": game.get("title", ""),
|
|
527
|
+
"slug": game.get("slug", ""),
|
|
528
|
+
"directory": f"games/{slug}",
|
|
529
|
+
"files": [],
|
|
530
|
+
}
|
|
531
|
+
games.append(game_record)
|
|
532
|
+
|
|
533
|
+
game_record["last_backed_up_at"] = now
|
|
534
|
+
game_record["status"] = _game_status_from_files(game_record.get("files", []))
|
|
535
|
+
files = game_record.setdefault("files", [])
|
|
536
|
+
|
|
537
|
+
relative_path = _relative_to_root(planned.dest, layout.root)
|
|
538
|
+
temp_relative_path = (
|
|
539
|
+
_relative_to_root(result.temp_path, layout.root) if result.temp_path is not None else None
|
|
540
|
+
)
|
|
541
|
+
file_id = _file_id(planned.spec)
|
|
542
|
+
file_status = "verified" if result.status == "skipped" else result.status
|
|
543
|
+
file_record = {
|
|
544
|
+
"file_id": file_id,
|
|
545
|
+
"role": planned.spec.role,
|
|
546
|
+
"source_id": planned.spec.source_id,
|
|
547
|
+
"name": planned.dest.name,
|
|
548
|
+
"relative_path": relative_path,
|
|
549
|
+
"temp_relative_path": temp_relative_path,
|
|
550
|
+
"size_bytes": result.expected_size or planned.spec.expected_size,
|
|
551
|
+
"expected_size": result.expected_size or planned.spec.expected_size,
|
|
552
|
+
"expected_md5": planned.spec.expected_md5,
|
|
553
|
+
"checksum": _checksum_record(planned.spec.expected_md5),
|
|
554
|
+
"version": planned.spec.version,
|
|
555
|
+
"build_id": None,
|
|
556
|
+
"platform": planned.spec.platform,
|
|
557
|
+
"language": planned.spec.language,
|
|
558
|
+
"status": file_status,
|
|
559
|
+
"download_started_at": now if file_status in {"downloaded", "verified"} else None,
|
|
560
|
+
"downloaded_at": now if file_status in {"downloaded", "verified"} else None,
|
|
561
|
+
"verified_at": now if file_status == "verified" else None,
|
|
562
|
+
"source_metadata_updated_at": None,
|
|
563
|
+
"failure": _failure_record(result),
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
existing = next((f for f in files if f.get("file_id") == file_id), None)
|
|
567
|
+
if existing is None:
|
|
568
|
+
files.append(file_record)
|
|
569
|
+
else:
|
|
570
|
+
existing.update(file_record)
|
|
571
|
+
game_record["status"] = _game_status_from_files(files)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _read_manifest(path: Path, *, missing_ok: bool = False) -> dict[str, Any]:
|
|
575
|
+
try:
|
|
576
|
+
data = read_json_file(path)
|
|
577
|
+
except StateFileMissingError:
|
|
578
|
+
if missing_ok:
|
|
579
|
+
return _new_manifest()
|
|
580
|
+
raise FilesystemError("Backup manifest is missing. Run `gog backup` first.") from None
|
|
581
|
+
except StateFileCorruptError as exc:
|
|
582
|
+
raise ParserError(f"Backup manifest is corrupt: {exc}") from exc
|
|
583
|
+
if not isinstance(data, dict):
|
|
584
|
+
raise ParserError(f"Backup manifest has unsupported shape: {path}")
|
|
585
|
+
if data.get("schema_version") != _SUPPORTED_MANIFEST_SCHEMA:
|
|
586
|
+
raise ParserError(f"Unsupported backup manifest schema: {data.get('schema_version')!r}")
|
|
587
|
+
if not isinstance(data.get("games", []), list):
|
|
588
|
+
raise ParserError(f"Backup manifest games field is invalid: {path}")
|
|
589
|
+
return data
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _new_manifest() -> dict[str, Any]:
|
|
593
|
+
now = utc_timestamp()
|
|
594
|
+
return {
|
|
595
|
+
"schema_version": _SUPPORTED_MANIFEST_SCHEMA,
|
|
596
|
+
"created_at": now,
|
|
597
|
+
"updated_at": now,
|
|
598
|
+
"tool": {"name": "gog-cli", "version": __version__},
|
|
599
|
+
"backup_root_marker": f"gog-cli-backup:{uuid4()}",
|
|
600
|
+
"games": [],
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _map_files_to_games(
|
|
605
|
+
layout: BackupLayout,
|
|
606
|
+
selected_games: list[dict[str, Any]],
|
|
607
|
+
download_specs: dict[str, list[FileSpec]],
|
|
608
|
+
) -> dict[str, dict[str, Any]]:
|
|
609
|
+
mapping: dict[str, dict[str, Any]] = {}
|
|
610
|
+
for game in selected_games:
|
|
611
|
+
product_id = _game_product_id(game)
|
|
612
|
+
slug = sanitize_filename(str(game.get("slug") or product_id))
|
|
613
|
+
game_dir = layout.game_dir(slug)
|
|
614
|
+
for spec in download_specs.get(product_id, []):
|
|
615
|
+
dest = (
|
|
616
|
+
game_dir
|
|
617
|
+
/ _role_subdir(spec.role)
|
|
618
|
+
/ sanitize_filename(spec.filename or spec.source_id)
|
|
619
|
+
)
|
|
620
|
+
mapping[str(dest)] = game
|
|
621
|
+
return mapping
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _role_subdir(role: str) -> str:
|
|
625
|
+
return {
|
|
626
|
+
"installer": "installers",
|
|
627
|
+
"patch": "patches",
|
|
628
|
+
"extra": "extras",
|
|
629
|
+
"language_pack": "language-packs",
|
|
630
|
+
"manual": "manuals",
|
|
631
|
+
}.get(role, "other")
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _resolve_checksum(
|
|
635
|
+
session: requests.Session,
|
|
636
|
+
checksum_url: str,
|
|
637
|
+
spec: FileSpec,
|
|
638
|
+
) -> tuple[str | None, int | None]:
|
|
639
|
+
expected_md5 = spec.expected_md5
|
|
640
|
+
expected_size = spec.expected_size
|
|
641
|
+
if checksum_url:
|
|
642
|
+
checksum_md5, checksum_size = fetch_checksum_xml(session, checksum_url)
|
|
643
|
+
expected_md5 = checksum_md5 or expected_md5
|
|
644
|
+
expected_size = checksum_size or expected_size
|
|
645
|
+
return expected_md5, expected_size
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _apply_header_filename(
|
|
649
|
+
session: requests.Session,
|
|
650
|
+
signed_url: str,
|
|
651
|
+
planned: PlannedFile,
|
|
652
|
+
layout: BackupLayout,
|
|
653
|
+
game: dict[str, Any],
|
|
654
|
+
) -> None:
|
|
655
|
+
if planned.spec.filename:
|
|
656
|
+
return
|
|
657
|
+
filename = _filename_from_headers(session, signed_url)
|
|
658
|
+
if not filename:
|
|
659
|
+
return
|
|
660
|
+
planned.spec.filename = filename
|
|
661
|
+
product_id = _game_product_id(game)
|
|
662
|
+
slug = sanitize_filename(str(game.get("slug") or product_id))
|
|
663
|
+
planned.dest = (
|
|
664
|
+
layout.game_dir(slug)
|
|
665
|
+
/ _role_subdir(planned.spec.role)
|
|
666
|
+
/ sanitize_filename(filename)
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _filename_from_headers(session: requests.Session, signed_url: str) -> str | None:
|
|
671
|
+
try:
|
|
672
|
+
response = session.head(signed_url, allow_redirects=True, timeout=15)
|
|
673
|
+
response.raise_for_status()
|
|
674
|
+
except requests.RequestException:
|
|
675
|
+
return None
|
|
676
|
+
header = response.headers.get("Content-Disposition", "")
|
|
677
|
+
if not header:
|
|
678
|
+
return None
|
|
679
|
+
message = Message()
|
|
680
|
+
message["content-disposition"] = header
|
|
681
|
+
filename = message.get_filename()
|
|
682
|
+
if not filename:
|
|
683
|
+
return None
|
|
684
|
+
return Path(filename).name
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _human_size(n: int | None) -> str:
|
|
688
|
+
if n is None:
|
|
689
|
+
return "?"
|
|
690
|
+
units = ("B", "KB", "MB", "GB", "TB")
|
|
691
|
+
x = float(n)
|
|
692
|
+
for unit in units[:-1]:
|
|
693
|
+
if x < 1024:
|
|
694
|
+
if unit == "B":
|
|
695
|
+
return f"{int(x)} {unit}"
|
|
696
|
+
return f"{x:.1f} {unit}"
|
|
697
|
+
x /= 1024
|
|
698
|
+
return f"{x:.2f} {units[-1]}"
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _group_planned_by_game(
|
|
702
|
+
plan: BackupPlan,
|
|
703
|
+
selected: list[dict[str, Any]],
|
|
704
|
+
) -> list[tuple[dict[str, Any], list[PlannedFile]]]:
|
|
705
|
+
games_dir = BackupLayout(plan.destination).games_dir
|
|
706
|
+
slug_to_game = {
|
|
707
|
+
sanitize_filename(g.get("slug") or _game_product_id(g)): g
|
|
708
|
+
for g in selected
|
|
709
|
+
}
|
|
710
|
+
slug_to_files: dict[str, list[PlannedFile]] = {s: [] for s in slug_to_game}
|
|
711
|
+
for pf in plan.planned:
|
|
712
|
+
try:
|
|
713
|
+
slug = pf.dest.relative_to(games_dir).parts[0]
|
|
714
|
+
if slug in slug_to_files:
|
|
715
|
+
slug_to_files[slug].append(pf)
|
|
716
|
+
except (ValueError, IndexError):
|
|
717
|
+
pass
|
|
718
|
+
return [(slug_to_game[s], slug_to_files[s]) for s in slug_to_game]
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _print_backup_plan(
|
|
722
|
+
plan: BackupPlan,
|
|
723
|
+
context: _ExecutionContext,
|
|
724
|
+
selected: list[dict[str, Any]],
|
|
725
|
+
args: argparse.Namespace,
|
|
726
|
+
*,
|
|
727
|
+
is_dry_run: bool = False,
|
|
728
|
+
) -> None:
|
|
729
|
+
show_storage = getattr(args, "storage", False) or getattr(args, "check_free_space", False)
|
|
730
|
+
show_summary_only = getattr(args, "summary", False)
|
|
731
|
+
changed_only = getattr(args, "changed_only", False)
|
|
732
|
+
explain_skips = getattr(args, "explain_skips", False)
|
|
733
|
+
sep = "─" * 72
|
|
734
|
+
|
|
735
|
+
print_human([f"Backup plan — {plan.destination}"])
|
|
736
|
+
|
|
737
|
+
platforms_label = ",".join(context.platforms) if context.platforms else "all"
|
|
738
|
+
languages_label = ",".join(context.languages) if context.languages else "all"
|
|
739
|
+
roles_label = ",".join(context.file_roles) if context.file_roles else "all"
|
|
740
|
+
print_human([
|
|
741
|
+
f"Policy: platforms={platforms_label} languages={languages_label} roles={roles_label}"
|
|
742
|
+
])
|
|
743
|
+
|
|
744
|
+
groups = _group_planned_by_game(plan, selected)
|
|
745
|
+
complete_games = sum(
|
|
746
|
+
1 for _, files in groups
|
|
747
|
+
if files and all(pf.skip_reason == "already_exists" for pf in files)
|
|
748
|
+
)
|
|
749
|
+
games_needing_downloads = sum(
|
|
750
|
+
1 for _, files in groups if any(pf.action == "download" for pf in files)
|
|
751
|
+
)
|
|
752
|
+
games_missing_locally = sum(
|
|
753
|
+
1 for _, files in groups
|
|
754
|
+
if files and all(pf.action == "download" for pf in files)
|
|
755
|
+
)
|
|
756
|
+
print_human([
|
|
757
|
+
f"Scope: {len(context.library)} owned | {len(selected)} selected | "
|
|
758
|
+
f"{complete_games} complete | {games_needing_downloads} need downloads | "
|
|
759
|
+
f"{games_missing_locally} missing locally"
|
|
760
|
+
])
|
|
761
|
+
print_human([""])
|
|
762
|
+
n_dl = len(plan.downloads)
|
|
763
|
+
size_est = _human_size(plan.disk_required_bytes)
|
|
764
|
+
print_human([f"Downloads: {n_dl} file(s) • {size_est} estimated"])
|
|
765
|
+
|
|
766
|
+
already_present = sum(1 for pf in plan.skips if pf.skip_reason == "already_exists")
|
|
767
|
+
n_orphaned = len(plan.orphaned_local_files)
|
|
768
|
+
print_human([f"Local state: {already_present} already present • {n_orphaned} orphaned"])
|
|
769
|
+
|
|
770
|
+
skip_counts: dict[str, int] = {}
|
|
771
|
+
for pf in plan.skips:
|
|
772
|
+
if pf.skip_reason and pf.skip_reason != "already_exists":
|
|
773
|
+
skip_counts[pf.skip_reason] = skip_counts.get(pf.skip_reason, 0) + 1
|
|
774
|
+
if skip_counts:
|
|
775
|
+
parts = [f"{v} {k.replace('_', '-')}" for k, v in sorted(skip_counts.items())]
|
|
776
|
+
print_human([f"Filtered out: {' | '.join(parts)}"])
|
|
777
|
+
|
|
778
|
+
if show_storage:
|
|
779
|
+
free_b = plan.disk_free_bytes
|
|
780
|
+
free_label = _human_size(free_b) if free_b is not None else "unknown"
|
|
781
|
+
req_label = _human_size(plan.disk_required_bytes)
|
|
782
|
+
enough = plan.disk_free_bytes is None or plan.disk_free_bytes >= plan.disk_required_bytes
|
|
783
|
+
status = "OK" if enough else "INSUFFICIENT"
|
|
784
|
+
print_human([f"Disk: required={req_label} • free={free_label} • {status}"])
|
|
785
|
+
|
|
786
|
+
if not show_summary_only:
|
|
787
|
+
print_human(["", sep])
|
|
788
|
+
for game, files in groups:
|
|
789
|
+
title = game.get("title", "")
|
|
790
|
+
slug = game.get("slug", "")
|
|
791
|
+
is_complete = bool(files) and all(pf.skip_reason == "already_exists" for pf in files)
|
|
792
|
+
|
|
793
|
+
if changed_only and is_complete:
|
|
794
|
+
continue
|
|
795
|
+
|
|
796
|
+
header = f"{slug} — {title}"
|
|
797
|
+
if is_complete:
|
|
798
|
+
header += " (complete)"
|
|
799
|
+
print(header)
|
|
800
|
+
|
|
801
|
+
for pf in files:
|
|
802
|
+
name = pf.spec.filename or pf.spec.source_id
|
|
803
|
+
role = pf.spec.role
|
|
804
|
+
platform = pf.spec.platform or "-"
|
|
805
|
+
|
|
806
|
+
if pf.action == "download":
|
|
807
|
+
size = _human_size(pf.spec.expected_size)
|
|
808
|
+
print(f" + {name:<50} {role:<12} {platform:<10} {size}")
|
|
809
|
+
elif pf.skip_reason == "already_exists":
|
|
810
|
+
print(f" = {name:<50} {role:<12} {platform:<10} (present)")
|
|
811
|
+
else:
|
|
812
|
+
reason = f" [{pf.skip_reason}]" if explain_skips else ""
|
|
813
|
+
print(f" - {name:<50} {role:<12} {platform:<10}{reason}")
|
|
814
|
+
|
|
815
|
+
print_human([sep])
|
|
816
|
+
|
|
817
|
+
if is_dry_run:
|
|
818
|
+
print_human([
|
|
819
|
+
"",
|
|
820
|
+
"Dry run — no files were downloaded. Re-run with --yes to execute.",
|
|
821
|
+
])
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _print_plan_json(
|
|
825
|
+
plan: BackupPlan,
|
|
826
|
+
context: _ExecutionContext,
|
|
827
|
+
selected: list[dict[str, Any]],
|
|
828
|
+
args: argparse.Namespace,
|
|
829
|
+
) -> None:
|
|
830
|
+
groups = _group_planned_by_game(plan, selected)
|
|
831
|
+
complete_games = sum(
|
|
832
|
+
1 for _, files in groups
|
|
833
|
+
if files and all(pf.skip_reason == "already_exists" for pf in files)
|
|
834
|
+
)
|
|
835
|
+
games_needing_downloads = sum(
|
|
836
|
+
1 for _, files in groups if any(pf.action == "download" for pf in files)
|
|
837
|
+
)
|
|
838
|
+
games_missing_locally = sum(
|
|
839
|
+
1 for _, files in groups
|
|
840
|
+
if files and all(pf.action == "download" for pf in files)
|
|
841
|
+
)
|
|
842
|
+
already_present = sum(1 for pf in plan.skips if pf.skip_reason == "already_exists")
|
|
843
|
+
scope = "all" if getattr(args, "all_games", False) else "selected"
|
|
844
|
+
|
|
845
|
+
actions_by_game = []
|
|
846
|
+
for game, files in groups:
|
|
847
|
+
game_downloads = [
|
|
848
|
+
{
|
|
849
|
+
"action": "download",
|
|
850
|
+
"source_id": pf.spec.source_id,
|
|
851
|
+
"filename": pf.spec.filename or pf.spec.source_id,
|
|
852
|
+
"role": pf.spec.role,
|
|
853
|
+
"platform": pf.spec.platform,
|
|
854
|
+
"language": pf.spec.language,
|
|
855
|
+
"size_bytes": pf.spec.expected_size,
|
|
856
|
+
}
|
|
857
|
+
for pf in files
|
|
858
|
+
if pf.action == "download"
|
|
859
|
+
]
|
|
860
|
+
if game_downloads:
|
|
861
|
+
actions_by_game.append({
|
|
862
|
+
"game_id": _game_product_id(game),
|
|
863
|
+
"slug": game.get("slug", ""),
|
|
864
|
+
"title": game.get("title", ""),
|
|
865
|
+
"actions": game_downloads,
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
skipped = [
|
|
869
|
+
{
|
|
870
|
+
"game_id": _game_product_id(game),
|
|
871
|
+
"slug": game.get("slug", ""),
|
|
872
|
+
"filename": pf.spec.filename or pf.spec.source_id,
|
|
873
|
+
"reason": pf.skip_reason,
|
|
874
|
+
"platform": pf.spec.platform,
|
|
875
|
+
}
|
|
876
|
+
for game, files in groups
|
|
877
|
+
for pf in files
|
|
878
|
+
if pf.skip_reason and pf.skip_reason != "already_exists"
|
|
879
|
+
]
|
|
880
|
+
|
|
881
|
+
data = {
|
|
882
|
+
"target_directory": str(plan.destination),
|
|
883
|
+
"mode": "dry_run",
|
|
884
|
+
"scope": scope,
|
|
885
|
+
"summary": {
|
|
886
|
+
"owned_games": len(context.library),
|
|
887
|
+
"selected_games": len(selected),
|
|
888
|
+
"complete_games": complete_games,
|
|
889
|
+
"games_needing_updates": games_needing_downloads,
|
|
890
|
+
"games_missing_locally": games_missing_locally,
|
|
891
|
+
"already_present_files": already_present,
|
|
892
|
+
"new_files": len(plan.downloads),
|
|
893
|
+
"total_download_files": len(plan.downloads),
|
|
894
|
+
"total_download_bytes": plan.disk_required_bytes,
|
|
895
|
+
"orphaned_local_files": len(plan.orphaned_local_files),
|
|
896
|
+
},
|
|
897
|
+
"disk": {
|
|
898
|
+
"free_bytes": plan.disk_free_bytes,
|
|
899
|
+
"required_bytes": plan.disk_required_bytes,
|
|
900
|
+
"enough_space": (
|
|
901
|
+
plan.disk_free_bytes is None or plan.disk_free_bytes >= plan.disk_required_bytes
|
|
902
|
+
),
|
|
903
|
+
},
|
|
904
|
+
"actions": actions_by_game,
|
|
905
|
+
"skipped": skipped,
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
print_json(JsonEnvelope(command="backup plan", data=data))
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _print_sync_plan(plan: SyncPlan, files_to_process: int) -> None:
|
|
912
|
+
print_human(
|
|
913
|
+
[
|
|
914
|
+
f"Plan: {len(plan.to_download)} files to download, {len(plan.to_verify)} to verify.",
|
|
915
|
+
f"{len(plan.current)} files current. {files_to_process} files need work.",
|
|
916
|
+
f"Estimated bytes: {plan.estimated_bytes}.",
|
|
917
|
+
]
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _print_execution_summary(results: list[ExecutionResult], *, auth_failed: bool) -> None:
|
|
922
|
+
failed = sum(1 for item in results if item.result.status in {"failed", "partial"})
|
|
923
|
+
succeeded = len(results) - failed
|
|
924
|
+
print(f"Summary: {succeeded} succeeded, {failed} failed.")
|
|
925
|
+
if auth_failed:
|
|
926
|
+
print("Stopped because authentication failed.", file=sys.stderr)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _result_to_json(item: ExecutionResult) -> dict[str, Any]:
|
|
930
|
+
return {
|
|
931
|
+
"product_id": _game_product_id(item.game),
|
|
932
|
+
"title": item.game.get("title", ""),
|
|
933
|
+
"source_id": item.file.spec.source_id,
|
|
934
|
+
"name": item.file.dest.name,
|
|
935
|
+
"role": item.file.spec.role,
|
|
936
|
+
"platform": item.file.spec.platform,
|
|
937
|
+
"language": item.file.spec.language,
|
|
938
|
+
"status": item.result.status,
|
|
939
|
+
"path": str(item.result.path or item.file.dest),
|
|
940
|
+
"failure_code": item.result.failure_code,
|
|
941
|
+
"failure_message": item.result.failure_message,
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _normalize_game(game: dict[str, Any]) -> dict[str, Any]:
|
|
946
|
+
normalized = dict(game)
|
|
947
|
+
normalized["id"] = _game_product_id(game)
|
|
948
|
+
normalized["product_id"] = _game_product_id(game)
|
|
949
|
+
return normalized
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _optional_str(value: Any) -> str | None:
|
|
953
|
+
if value is None:
|
|
954
|
+
return None
|
|
955
|
+
return str(value)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _optional_int(value: Any) -> int | None:
|
|
959
|
+
if value is None:
|
|
960
|
+
return None
|
|
961
|
+
try:
|
|
962
|
+
return int(value)
|
|
963
|
+
except (TypeError, ValueError):
|
|
964
|
+
return None
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _download_filename(
|
|
968
|
+
file_entry: dict[str, Any],
|
|
969
|
+
entry: dict[str, Any],
|
|
970
|
+
_source_id: str,
|
|
971
|
+
) -> str | None:
|
|
972
|
+
for value in (
|
|
973
|
+
file_entry.get("name"),
|
|
974
|
+
file_entry.get("filename"),
|
|
975
|
+
file_entry.get("title"),
|
|
976
|
+
entry.get("filename"),
|
|
977
|
+
entry.get("name"),
|
|
978
|
+
):
|
|
979
|
+
if isinstance(value, str) and value.strip():
|
|
980
|
+
return Path(value.strip()).name
|
|
981
|
+
return None
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _relative_to_root(path: Path, root: Path) -> str:
|
|
985
|
+
try:
|
|
986
|
+
return path.relative_to(root).as_posix()
|
|
987
|
+
except ValueError:
|
|
988
|
+
return path.as_posix()
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _failure_record(result: DownloadResult) -> dict[str, str | None] | None:
|
|
992
|
+
if result.failure_code is None and result.failure_message is None:
|
|
993
|
+
return None
|
|
994
|
+
return {"code": result.failure_code, "message": result.failure_message}
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _checksum_record(expected_md5: str | None) -> dict[str, str] | None:
|
|
998
|
+
if not expected_md5:
|
|
999
|
+
return None
|
|
1000
|
+
return {"algorithm": "md5", "value": expected_md5}
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _file_id(spec: FileSpec) -> str:
|
|
1004
|
+
return ":".join(
|
|
1005
|
+
[
|
|
1006
|
+
spec.role,
|
|
1007
|
+
spec.platform or "",
|
|
1008
|
+
spec.language or "",
|
|
1009
|
+
spec.source_id,
|
|
1010
|
+
]
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _game_status_from_files(files: list[dict[str, Any]]) -> str:
|
|
1015
|
+
statuses = {file.get("status") for file in files if isinstance(file, dict)}
|
|
1016
|
+
if not statuses:
|
|
1017
|
+
return "missing"
|
|
1018
|
+
if "failed" in statuses:
|
|
1019
|
+
return "error"
|
|
1020
|
+
if "partial" in statuses:
|
|
1021
|
+
return "partial"
|
|
1022
|
+
if "stale" in statuses:
|
|
1023
|
+
return "stale"
|
|
1024
|
+
if "downloaded" in statuses:
|
|
1025
|
+
return "unverified"
|
|
1026
|
+
if statuses <= {"verified"}:
|
|
1027
|
+
return "current"
|
|
1028
|
+
return "missing"
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _md5_file(path: Path) -> str:
|
|
1032
|
+
h = hashlib.md5() # noqa: S324 - MD5 is used for GOG file integrity metadata.
|
|
1033
|
+
with path.open("rb") as fh:
|
|
1034
|
+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
1035
|
+
h.update(chunk)
|
|
1036
|
+
return h.hexdigest()
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _validate_filters(context: _ExecutionContext, selected: list[dict[str, Any]]) -> None:
|
|
1040
|
+
specs = [
|
|
1041
|
+
spec
|
|
1042
|
+
for game in selected
|
|
1043
|
+
for spec in context.download_specs.get(_game_product_id(game), [])
|
|
1044
|
+
]
|
|
1045
|
+
if context.platforms:
|
|
1046
|
+
available = {spec.platform for spec in specs if spec.platform}
|
|
1047
|
+
missing = sorted(set(context.platforms) - available)
|
|
1048
|
+
if missing:
|
|
1049
|
+
raise UsageError(f"Unknown platform filter: {', '.join(missing)}")
|
|
1050
|
+
if context.languages:
|
|
1051
|
+
available = {spec.language for spec in specs if spec.language}
|
|
1052
|
+
missing = sorted(set(context.languages) - available)
|
|
1053
|
+
if missing:
|
|
1054
|
+
raise UsageError(f"Unknown language filter: {', '.join(missing)}")
|