Repository navigation
Expand file tree
/
Copy pathsyncengine.py
More file actions
156 lines (130 loc) · 6.11 KB
/
Copy pathsyncengine.py
File metadata and controls
156 lines (130 loc) · 6.11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
import shutil
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Optional
from models import SaveEntry, Config
import re
def sanitize_filename(name: str) -> str:
# Replace invalid characters with underscore or strip them
return re.sub(r'[<>:"/\\|?*™]', '_', name)
class SyncEngine:
def __init__(self, config: Config):
self.config = config
self.backup_dir = config.backup_dir
self.backup_dir.mkdir(exist_ok=True, parents=True)
def sync(self, source: SaveEntry, destination: SaveEntry):
"""
Sync source save to destination.
Special behavior:
- When copying from Citron -> Ryujinx we exclude ExtraData0/ExtraData1 files.
- When the destination is a Ryujinx slot folder we back up and copy into *all* numeric
sibling slots under the same Ryujinx folder (so both '0' and '1' are updated).
"""
print(f"[SYNC] {source.game_name}: {source.source} → {destination.source}")
exclude_extra = (source.source == 'citron' and destination.source == 'ryujinx')
# If destination is a Ryujinx per-slot path ( .../save/<fid>/<slot> ), copy into all
# numeric sibling slots we find under the same <fid> directory.
try:
if destination.source == 'ryujinx' and destination.path.name.isdigit():
slot_parent = destination.path.parent
slot_dirs = [p for p in slot_parent.iterdir() if p.is_dir() and p.name.isdigit()]
# If no numeric siblings found, fall back to the single destination path
if not slot_dirs:
slot_dirs = [destination.path]
# Back up each existing slot before replacing
for sd in slot_dirs:
sd_entry = SaveEntry(destination.title_id, destination.game_name, destination.source,
destination.folder_id, sd, destination.modified_time, destination.hash)
self._backup(sd_entry)
# Copy source into each discovered slot
for sd in slot_dirs:
self._copy_save(source.path, sd, exclude_extra)
return
except Exception:
# Fall back to single-slot behavior on unexpected errors
pass
# Default single-destination behavior
self._backup(destination)
self._copy_save(source.path, destination.path, exclude_extra)
def _backup(self, save: SaveEntry):
# Don't attempt to back up a destination that doesn't yet exist or is empty
if not save.path.exists():
print(f" ℹ️ No existing destination to back up at {save.path}; skipping backup.")
return
# Collect files once to avoid a race where the directory changes between checks
files = [f for f in save.path.rglob("*") if f.is_file()]
if not files:
print(f" ℹ️ No files found to back up in {save.path}; skipping backup.")
return
safe_name = sanitize_filename(save.game_name)
title_folder = self.backup_dir / f"{save.title_id}-{safe_name}"
title_folder.mkdir(exist_ok=True, parents=True)
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
zip_path = title_folder / f"saveBackup_{timestamp}.zip"
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for file in files:
arcname = file.relative_to(save.path)
zipf.write(file, arcname)
print(f" ✔ Backed up {len(files)} file(s) to {zip_path}")
self._enforce_backup_limit(title_folder, self.config.max_backups)
def _enforce_backup_limit(self, folder: Path, max_versions: int):
zips = sorted(folder.glob("saveBackup_*.zip"), key=lambda p: p.stat().st_mtime, reverse=True)
for old_zip in zips[max_versions:]:
old_zip.unlink()
def _is_safe_destination(self, dst: Path) -> bool:
try:
resolved = dst.resolve()
except Exception:
return False
# Refuse obvious unsafe targets
if str(resolved) in ('/', str(Path.home())):
return False
# Ensure destination is inside one of the known emulator bases
bases = [self.config.ryujinx_base.resolve(), self.config.citron_base.resolve()]
for base in bases:
try:
if resolved.is_relative_to(base):
return True
except Exception:
continue
return False
def _copy_save(self, src: Path, dst: Path, exclude_extra: bool = False):
# Basic validation
if not src.exists() or not src.is_dir():
raise FileNotFoundError(f"Source save folder does not exist: {src}")
if not self._is_safe_destination(dst):
raise RuntimeError(f"Refusing to write outside known emulator roots: {dst}")
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
tmp = dst.parent / f".sync_tmp_{dst.name}_{timestamp}"
old = dst.parent / f".sync_old_{dst.name}_{timestamp}"
# Ensure clean temp
if tmp.exists():
shutil.rmtree(tmp)
# Prepare ignore rule when we must not copy ExtraData files
ignore = None
if exclude_extra:
ignore = shutil.ignore_patterns("ExtraData0", "ExtraData1")
# Copy to temporary location first (apply ignore if requested)
if ignore:
shutil.copytree(src, tmp, ignore=ignore)
else:
shutil.copytree(src, tmp)
# Move existing dst aside (if present)
if dst.exists():
try:
dst.rename(old)
except Exception:
shutil.move(str(dst), str(old))
# Promote tmp -> dst
try:
tmp.rename(dst)
except Exception:
shutil.move(str(tmp), str(dst))
# Cleanup old backup-of-destination
if old.exists():
try:
shutil.rmtree(old)
except Exception:
print(f" ⚠️ Warning: failed to remove old destination {old}")
print(f" ✔ Save copied to {dst}")