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/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)}")