feat(saves): add slot-based save sync with content hash deduplication

- Add device registration and save synchronization
- Implement slot-based save organization with datetime tagging
- Add conflict detection for multi-device sync scenarios
- Add content hash computation for save deduplication
- Support ZIP inner-file hashing for consistent deduplication
- Add confirm_download endpoint for sync state management
- Add overwrite parameter to bypass conflict checks
This commit is contained in:
nendo
2026-01-31 21:57:22 +09:00
parent b420af7578
commit a236123e4f
8 changed files with 1868 additions and 134 deletions

View File

@@ -1,9 +1,12 @@
import asyncio
import enum
import hashlib
import zipfile
from typing import Any
import socketio # type: ignore
from config import ASSETS_BASE_PATH
from config.config_manager import config_manager as cm
from endpoints.responses.rom import SimpleRomSchema
from handler.database import db_platform_handler, db_rom_handler
@@ -817,11 +820,41 @@ async def scan_rom(
return Rom(**rom_attrs)
async def _scan_asset(file_name: str, asset_path: str):
def _compute_file_hash(file_path: str) -> str:
hash_obj = hashlib.md5(usedforsecurity=False)
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
hash_obj.update(chunk)
return hash_obj.hexdigest()
def _compute_zip_hash(zip_path: str) -> str:
with zipfile.ZipFile(zip_path, "r") as zf:
file_hashes = []
for name in sorted(zf.namelist()):
if not name.endswith("/"):
content = zf.read(name)
file_hash = hashlib.md5(content, usedforsecurity=False).hexdigest()
file_hashes.append(f"{name}:{file_hash}")
combined = "\n".join(file_hashes)
return hashlib.md5(combined.encode(), usedforsecurity=False).hexdigest()
def _compute_content_hash(file_path: str) -> str | None:
try:
if zipfile.is_zipfile(file_path):
return _compute_zip_hash(file_path)
return _compute_file_hash(file_path)
except Exception as e:
log.debug(f"Failed to compute content hash for {file_path}: {e}")
return None
async def _scan_asset(file_name: str, asset_path: str, compute_hash: bool = False):
file_path = f"{asset_path}/{file_name}"
file_size = await fs_asset_handler.get_file_size(file_path)
return {
result = {
"file_path": asset_path,
"file_name": file_name,
"file_name_no_tags": fs_asset_handler.get_file_name_with_no_tags(file_name),
@@ -830,6 +863,12 @@ async def _scan_asset(file_name: str, asset_path: str):
"file_size_bytes": file_size,
}
if compute_hash:
absolute_path = f"{ASSETS_BASE_PATH}/{file_path}"
result["content_hash"] = _compute_content_hash(absolute_path)
return result
async def scan_save(
file_name: str,
@@ -841,7 +880,7 @@ async def scan_save(
saves_path = fs_asset_handler.build_saves_file_path(
user=user, platform_fs_slug=platform_fs_slug, rom_id=rom_id, emulator=emulator
)
scanned_asset = await _scan_asset(file_name, saves_path)
scanned_asset = await _scan_asset(file_name, saves_path, compute_hash=True)
return Save(**scanned_asset)