mirror of
https://github.com/rommapp/romm.git
synced 2026-04-23 06:54:40 +00:00
426 lines
14 KiB
Python
426 lines
14 KiB
Python
"""Background task for Push-Pull sync mode.
|
|
|
|
Connects to devices via SSH/SFTP, scans their save directories,
|
|
and performs bidirectional sync operations.
|
|
"""
|
|
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from anyio import Path as AnyioPath
|
|
from anyio import open_file
|
|
|
|
from config import ENABLE_SYNC_PUSH_PULL, SYNC_PUSH_PULL_CRON
|
|
from handler.database import (
|
|
db_device_handler,
|
|
db_device_save_sync_handler,
|
|
db_platform_handler,
|
|
db_save_handler,
|
|
db_sync_session_handler,
|
|
)
|
|
from handler.filesystem import fs_asset_handler
|
|
from handler.sync.comparison import compare_save_state
|
|
from handler.sync.ssh_handler import ssh_sync_handler
|
|
from logger.formatter import highlight as hl
|
|
from logger.logger import log
|
|
from models.device import Device, SyncMode
|
|
from models.sync_session import SyncSessionStatus
|
|
from tasks.tasks import PeriodicTask, TaskType
|
|
|
|
|
|
async def run_push_pull_sync(
|
|
device_id: str | None = None,
|
|
session_id: int | None = None,
|
|
force: bool = False,
|
|
) -> dict:
|
|
"""Execute push-pull sync for one or all push_pull devices."""
|
|
if not ENABLE_SYNC_PUSH_PULL and not force:
|
|
log.info("Push-pull sync not enabled, skipping")
|
|
return {"status": "disabled"}
|
|
|
|
if device_id:
|
|
device = db_device_handler.get_device_by_id(device_id)
|
|
if not device:
|
|
return {"status": "error", "message": f"Device {device_id} not found"}
|
|
devices = [device]
|
|
else:
|
|
devices = list(
|
|
db_device_handler.get_all_devices_by_sync_mode(SyncMode.PUSH_PULL)
|
|
)
|
|
|
|
if not devices:
|
|
log.info("No push_pull devices found")
|
|
return {"status": "no_devices"}
|
|
|
|
results = []
|
|
for device in devices:
|
|
if not device.sync_enabled:
|
|
continue
|
|
result = await _sync_device(device, session_id=session_id)
|
|
results.append(result)
|
|
|
|
return {"status": "completed", "device_results": results}
|
|
|
|
|
|
async def _sync_device(device: Device, session_id: int | None = None) -> dict:
|
|
"""Perform push-pull sync for a single device."""
|
|
sync_config = device.sync_config or {}
|
|
if not sync_config.get("ssh_host"):
|
|
log.warning(f"Push-pull device {device.id} has no ssh_host configured")
|
|
return {"device_id": device.id, "status": "error", "message": "No ssh_host"}
|
|
|
|
from endpoints.sockets.sync import (
|
|
emit_sync_completed,
|
|
emit_sync_conflict,
|
|
emit_sync_error,
|
|
emit_sync_progress,
|
|
emit_sync_started,
|
|
)
|
|
|
|
if session_id:
|
|
sync_session = db_sync_session_handler.get_session(
|
|
session_id=session_id, user_id=device.user_id
|
|
)
|
|
if not sync_session:
|
|
log.warning(
|
|
f"Push-pull: session {session_id} not found, creating new session"
|
|
)
|
|
sync_session = db_sync_session_handler.create_session(
|
|
device_id=device.id, user_id=device.user_id
|
|
)
|
|
else:
|
|
sync_session = db_sync_session_handler.create_session(
|
|
device_id=device.id, user_id=device.user_id
|
|
)
|
|
|
|
await emit_sync_started(
|
|
user_id=device.user_id,
|
|
device_id=device.id,
|
|
session_id=sync_session.id,
|
|
sync_mode="push_pull",
|
|
)
|
|
|
|
try:
|
|
conn = await ssh_sync_handler.connect(sync_config, device_id=device.id)
|
|
except Exception as e:
|
|
log.error(f"Push-pull: failed to connect to device {device.id}: {e}")
|
|
db_sync_session_handler.fail_session(
|
|
session_id=sync_session.id, error_message=str(e)
|
|
)
|
|
await emit_sync_error(
|
|
user_id=device.user_id,
|
|
device_id=device.id,
|
|
session_id=sync_session.id,
|
|
error_message=str(e),
|
|
)
|
|
return {"device_id": device.id, "status": "connection_failed", "error": str(e)}
|
|
|
|
completed = 0
|
|
failed = 0
|
|
|
|
try:
|
|
save_directories = sync_config.get("save_directories", [])
|
|
if not save_directories:
|
|
log.warning(
|
|
f"Push-pull device {device.id} has no save_directories configured"
|
|
)
|
|
db_sync_session_handler.complete_session(session_id=sync_session.id)
|
|
return {"device_id": device.id, "status": "no_directories"}
|
|
|
|
remote_saves = await ssh_sync_handler.list_remote_saves(conn, save_directories)
|
|
log.info(
|
|
f"Push-pull: found {len(remote_saves)} remote saves on device {device.id}"
|
|
)
|
|
|
|
db_sync_session_handler.update_session(
|
|
session_id=sync_session.id,
|
|
data={
|
|
"status": SyncSessionStatus.IN_PROGRESS,
|
|
"operations_planned": len(remote_saves),
|
|
},
|
|
)
|
|
|
|
operations_planned = len(remote_saves)
|
|
|
|
for remote_save in remote_saves:
|
|
try:
|
|
action = await _process_remote_save(device, conn, remote_save)
|
|
if action == "conflict":
|
|
await emit_sync_conflict(
|
|
user_id=device.user_id,
|
|
device_id=device.id,
|
|
session_id=sync_session.id,
|
|
file_name=remote_save.file_name,
|
|
rom_id=0,
|
|
reason=f"Conflict detected for {remote_save.file_name}",
|
|
)
|
|
if action != "skipped":
|
|
completed += 1
|
|
except Exception:
|
|
log.error(
|
|
f"Push-pull: failed to process {remote_save.file_name} "
|
|
f"on device {device.id}",
|
|
exc_info=True,
|
|
)
|
|
failed += 1
|
|
|
|
await emit_sync_progress(
|
|
user_id=device.user_id,
|
|
device_id=device.id,
|
|
session_id=sync_session.id,
|
|
operations_completed=completed + failed,
|
|
operations_planned=operations_planned,
|
|
current_file=remote_save.file_name,
|
|
)
|
|
|
|
push_count = await _push_missing_saves(
|
|
device, conn, remote_saves, save_directories
|
|
)
|
|
completed += push_count
|
|
|
|
except Exception as e:
|
|
log.error(f"Push-pull sync failed for device {device.id}: {e}", exc_info=True)
|
|
db_sync_session_handler.fail_session(
|
|
session_id=sync_session.id, error_message=str(e)
|
|
)
|
|
await emit_sync_error(
|
|
user_id=device.user_id,
|
|
device_id=device.id,
|
|
session_id=sync_session.id,
|
|
error_message=str(e),
|
|
)
|
|
return {"device_id": device.id, "status": "failed", "error": str(e)}
|
|
finally:
|
|
conn.close()
|
|
|
|
db_sync_session_handler.complete_session(
|
|
session_id=sync_session.id,
|
|
operations_completed=completed,
|
|
operations_failed=failed,
|
|
)
|
|
db_device_handler.update_last_seen(device_id=device.id, user_id=device.user_id)
|
|
|
|
await emit_sync_completed(
|
|
user_id=device.user_id,
|
|
device_id=device.id,
|
|
session_id=sync_session.id,
|
|
operations_completed=completed,
|
|
operations_failed=failed,
|
|
)
|
|
|
|
log.info(
|
|
f"Push-pull sync for device {device.id}: "
|
|
f"{completed} completed, {failed} failed"
|
|
)
|
|
return {
|
|
"device_id": device.id,
|
|
"status": "completed",
|
|
"completed": completed,
|
|
"failed": failed,
|
|
}
|
|
|
|
|
|
async def _process_remote_save(
|
|
device: Device,
|
|
conn,
|
|
remote_save,
|
|
) -> str:
|
|
"""Process a single remote save file. Returns action taken."""
|
|
# Look up platform
|
|
platform = db_platform_handler.get_platform_by_fs_slug(remote_save.platform_slug)
|
|
if not platform:
|
|
log.debug(f"Unknown platform slug: {remote_save.platform_slug}")
|
|
return "skipped"
|
|
|
|
# Find matching server save
|
|
saves = db_save_handler.get_saves(user_id=device.user_id, platform_id=platform.id)
|
|
matched_save = None
|
|
for save in saves:
|
|
if save.file_name == remote_save.file_name:
|
|
matched_save = save
|
|
break
|
|
|
|
if not matched_save:
|
|
log.info(
|
|
f"Push-pull: remote save {hl(remote_save.file_name)} "
|
|
f"on platform {remote_save.platform_slug} - no matching server save, skipping"
|
|
)
|
|
return "skipped"
|
|
|
|
# Compare with existing save
|
|
device_sync = db_device_save_sync_handler.get_sync(
|
|
device_id=device.id, save_id=matched_save.id
|
|
)
|
|
|
|
# Download remote file to get its hash
|
|
local_path, remote_hash = await ssh_sync_handler.download_save(
|
|
conn, remote_save.path
|
|
)
|
|
|
|
try:
|
|
result = compare_save_state(
|
|
client_hash=remote_hash,
|
|
client_updated_at=remote_save.mtime,
|
|
server_hash=matched_save.content_hash,
|
|
server_updated_at=matched_save.updated_at,
|
|
device_last_synced_at=device_sync.last_synced_at if device_sync else None,
|
|
)
|
|
|
|
if result.action == "no_op":
|
|
# Update sync tracking even for no-ops
|
|
db_device_save_sync_handler.upsert_sync(
|
|
device_id=device.id,
|
|
save_id=matched_save.id,
|
|
synced_at=datetime.now(timezone.utc),
|
|
)
|
|
return "no_op"
|
|
|
|
if result.action == "upload":
|
|
# Remote is newer - pull to server
|
|
log.info(
|
|
f"Push-pull: pulling {hl(remote_save.file_name)} from device {device.id}"
|
|
)
|
|
async with await open_file(local_path, "rb") as f:
|
|
file_data = await f.read()
|
|
await fs_asset_handler.write_file(
|
|
file=file_data,
|
|
path=matched_save.file_path,
|
|
filename=matched_save.file_name,
|
|
)
|
|
db_save_handler.update_save(
|
|
matched_save.id,
|
|
{
|
|
"file_size_bytes": remote_save.file_size,
|
|
"content_hash": remote_hash,
|
|
},
|
|
)
|
|
db_device_save_sync_handler.upsert_sync(
|
|
device_id=device.id,
|
|
save_id=matched_save.id,
|
|
synced_at=datetime.now(timezone.utc),
|
|
)
|
|
return "pulled"
|
|
|
|
if result.action == "download":
|
|
# Server is newer - push to device
|
|
log.info(
|
|
f"Push-pull: pushing {hl(matched_save.file_name)} to device {device.id}"
|
|
)
|
|
server_file_path = f"{matched_save.file_path}/{matched_save.file_name}"
|
|
server_full_path = fs_asset_handler.validate_path(server_file_path)
|
|
await ssh_sync_handler.upload_save(
|
|
conn, str(server_full_path), remote_save.path
|
|
)
|
|
db_device_save_sync_handler.upsert_sync(
|
|
device_id=device.id,
|
|
save_id=matched_save.id,
|
|
synced_at=datetime.now(timezone.utc),
|
|
)
|
|
return "pushed"
|
|
|
|
if result.action == "conflict":
|
|
log.warning(
|
|
f"Push-pull: conflict for {remote_save.file_name} "
|
|
f"on device {device.id}: {result.reason}"
|
|
)
|
|
return "conflict"
|
|
|
|
finally:
|
|
if await AnyioPath(local_path).exists():
|
|
os.unlink(local_path)
|
|
|
|
return "skipped"
|
|
|
|
|
|
async def _push_missing_saves(
|
|
device: Device,
|
|
conn,
|
|
remote_saves,
|
|
save_directories: list[dict],
|
|
) -> int:
|
|
"""Push server saves that are missing from the device."""
|
|
pushed = 0
|
|
|
|
# Build set of remote filenames per platform
|
|
remote_files: dict[str, set[str]] = {}
|
|
for rs in remote_saves:
|
|
remote_files.setdefault(rs.platform_slug, set()).add(rs.file_name)
|
|
|
|
# Build path lookup from save_directories config
|
|
platform_paths: dict[str, str] = {}
|
|
for dir_config in save_directories:
|
|
platform_paths[dir_config["platform_slug"]] = dir_config["path"]
|
|
|
|
# Check server saves for each configured platform
|
|
for dir_config in save_directories:
|
|
platform_slug = dir_config["platform_slug"]
|
|
platform = db_platform_handler.get_platform_by_fs_slug(platform_slug)
|
|
if not platform:
|
|
continue
|
|
|
|
server_saves = db_save_handler.get_saves(
|
|
user_id=device.user_id, platform_id=platform.id
|
|
)
|
|
|
|
remote_set = remote_files.get(platform_slug, set())
|
|
remote_dir = platform_paths.get(platform_slug, "")
|
|
|
|
for save in server_saves:
|
|
if save.file_name in remote_set:
|
|
continue
|
|
|
|
# Check if device has synced this before (intentional delete)
|
|
device_sync = db_device_save_sync_handler.get_sync(
|
|
device_id=device.id, save_id=save.id
|
|
)
|
|
if device_sync and device_sync.is_untracked:
|
|
continue
|
|
|
|
# Push to device
|
|
if remote_dir:
|
|
try:
|
|
server_file_path = f"{save.file_path}/{save.file_name}"
|
|
server_full_path = fs_asset_handler.validate_path(server_file_path)
|
|
remote_path = f"{remote_dir}/{save.file_name}"
|
|
await ssh_sync_handler.upload_save(
|
|
conn, str(server_full_path), remote_path
|
|
)
|
|
db_device_save_sync_handler.upsert_sync(
|
|
device_id=device.id,
|
|
save_id=save.id,
|
|
synced_at=datetime.now(timezone.utc),
|
|
)
|
|
pushed += 1
|
|
log.info(
|
|
f"Push-pull: pushed missing save {hl(save.file_name)} "
|
|
f"to device {device.id}"
|
|
)
|
|
except Exception:
|
|
log.error(
|
|
f"Push-pull: failed to push {save.file_name} to device {device.id}",
|
|
exc_info=True,
|
|
)
|
|
|
|
return pushed
|
|
|
|
|
|
class SyncPushPullTask(PeriodicTask):
|
|
"""Periodic task to run push-pull sync for all configured devices."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(
|
|
title="Push-Pull Sync",
|
|
description="Sync saves with devices via SSH/SFTP",
|
|
task_type=TaskType.SYNC,
|
|
enabled=ENABLE_SYNC_PUSH_PULL,
|
|
cron_string=SYNC_PUSH_PULL_CRON,
|
|
func="tasks.sync_push_pull_task.run_push_pull_sync",
|
|
)
|
|
|
|
async def run(self, *args: Any, **kwargs: Any) -> Any:
|
|
return await run_push_pull_sync(**kwargs)
|
|
|
|
|
|
sync_push_pull_task = SyncPushPullTask()
|