From 92a3c0619661f3a265bb3130de44487915571ad2 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Fri, 20 Mar 2026 21:52:45 -0400 Subject: [PATCH] bot generated save sync --- .env-dev | 4 + app/transitions.go | 2 + internal/fileutil/fileutil.go | 20 + romm/endpoints.go | 7 +- romm/saves.go | 29 +- romm/sync.go | 81 +++ sync/execute.go | 254 +++++++++ sync/flow.go | 891 ++++++++------------------------ sync/flow_test.go | 509 +++--------------- sync/models.go | 5 + sync/scan.go | 133 +++++ taskfile.yml | 18 + tools/save-sync-dry-run/main.go | 44 +- ui/save_conflict.go | 3 + ui/save_sync.go | 23 +- 15 files changed, 851 insertions(+), 1172 deletions(-) create mode 100644 romm/sync.go create mode 100644 sync/execute.go create mode 100644 sync/scan.go diff --git a/.env-dev b/.env-dev index 5bbd0267..dc83e4dd 100644 --- a/.env-dev +++ b/.env-dev @@ -12,3 +12,7 @@ ENVIRONMENT=DEV CFW= BASE_PATH= + +DEVICE_IP_ADDRESS= +PRIVATE_KEY_PATH= +SSH_PASSWORD= diff --git a/app/transitions.go b/app/transitions.go index 19936b10..94fddac1 100644 --- a/app/transitions.go +++ b/app/transitions.go @@ -283,6 +283,7 @@ func transitionSaveSync(ctx *transitionContext, result any) (router.Screen, any) Items: conflicts, AllItems: r.Items, ConflictIndices: r.ConflictIndices, + SessionID: r.SessionID, } } @@ -303,6 +304,7 @@ func transitionSaveConflict(ctx *transitionContext, result any) (router.Screen, Config: ctx.state.Config, Host: ctx.state.Host, ResolvedItems: r.AllItems, + SessionID: r.SessionID, } } diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go index 95d831f4..822d553f 100644 --- a/internal/fileutil/fileutil.go +++ b/internal/fileutil/fileutil.go @@ -3,6 +3,7 @@ package fileutil import ( "archive/zip" "bufio" + "crypto/md5" "crypto/sha1" "fmt" "hash/crc32" @@ -305,6 +306,25 @@ func FilterHiddenDirectories(entries []os.DirEntry) []os.DirEntry { return result } +// ComputeMD5 computes the MD5 hash of a file and returns it as a lowercase hex string. +// This matches the server's content hash algorithm (8192-byte chunks, md5, hex output). +func ComputeMD5(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + hash := md5.New() + buffer := make([]byte, 8192) + + if _, err := io.CopyBuffer(hash, file, buffer); err != nil { + return "", fmt.Errorf("failed to compute hash: %w", err) + } + + return fmt.Sprintf("%x", hash.Sum(nil)), nil +} + // ComputeCRC32 computes the CRC32 hash of a file and returns it as an uppercase hex string func ComputeCRC32(filePath string) (string, error) { file, err := os.Open(filePath) diff --git a/romm/endpoints.go b/romm/endpoints.go index 5bc97b96..c6fb3298 100644 --- a/romm/endpoints.go +++ b/romm/endpoints.go @@ -28,8 +28,9 @@ const ( endpointSaveByID = "/api/saves/%d" endpointSaveSummary = "/api/saves/summary" endpointSaveContent = "/api/saves/%d/content" - endpointSaveDownloaded = "/api/saves/%d/downloaded" + endpointDevices = "/api/devices" + endpointDeviceByID = "/api/devices/%s" - endpointDevices = "/api/devices" - endpointDeviceByID = "/api/devices/%s" + endpointSyncNegotiate = "/api/sync/negotiate" + endpointSyncSessionComplete = "/api/sync/sessions/%d/complete" ) diff --git a/romm/saves.go b/romm/saves.go index 4291deba..81626484 100644 --- a/romm/saves.go +++ b/romm/saves.go @@ -87,6 +87,7 @@ type UploadSaveQuery struct { Overwrite bool `qs:"overwrite,omitempty"` Autocleanup bool `qs:"autocleanup,omitempty"` AutocleanupLimit int `qs:"autocleanup_limit,omitempty"` + SessionID int `qs:"session_id,omitempty"` } func (uq UploadSaveQuery) Valid() bool { @@ -96,16 +97,13 @@ func (uq UploadSaveQuery) Valid() bool { type SaveContentQuery struct { DeviceID string `qs:"device_id,omitempty"` Optimistic bool `qs:"optimistic,omitempty"` + SessionID int `qs:"session_id,omitempty"` } func (scq SaveContentQuery) Valid() bool { return scq.DeviceID != "" } -type SaveDeviceBody struct { - DeviceID string `json:"device_id"` -} - type SaveSummaryQuery struct { RomID int `qs:"rom_id"` } @@ -124,26 +122,18 @@ func (c *Client) DownloadSave(downloadPath string) ([]byte, error) { return c.doRequestRaw("GET", downloadPath, nil) } -func (c *Client) DownloadSaveByID(saveID int, deviceID string, optimistic bool) ([]byte, error) { +func (c *Client) DownloadSaveByID(saveID int, deviceID string, optimistic bool, sessionID ...int) ([]byte, error) { path := fmt.Sprintf(endpointSaveContent, saveID) query := SaveContentQuery{ DeviceID: deviceID, Optimistic: optimistic, } + if len(sessionID) > 0 { + query.SessionID = sessionID[0] + } return c.doRequestRawWithQuery("GET", path, query) } -func (c *Client) ConfirmSaveDownloaded(saveID int, deviceID string) error { - path := fmt.Sprintf(endpointSaveDownloaded, saveID) - body := SaveDeviceBody{DeviceID: deviceID} - return c.doRequest("POST", path, nil, body, nil) -} - -// MarkDeviceSynced confirms this device has the latest save state. -// Used after both uploads and downloads. -func (c *Client) MarkDeviceSynced(saveID int, deviceID string) error { - return c.ConfirmSaveDownloaded(saveID, deviceID) -} func (c *Client) GetSaveSummary(romID int) (SaveSummary, error) { var summary SaveSummary @@ -187,13 +177,6 @@ func (c *Client) UpdateSave(saveID int, savePath string) (Save, error) { return res, nil } -func (c *Client) UploadSave(romID int, savePath string, emulator string) (Save, error) { - return c.UploadSaveWithQuery(UploadSaveQuery{ - RomID: romID, - Emulator: emulator, - }, savePath) -} - func (c *Client) UploadSaveWithQuery(query UploadSaveQuery, savePath string) (Save, error) { file, err := os.Open(savePath) if err != nil { diff --git a/romm/sync.go b/romm/sync.go new file mode 100644 index 00000000..90ab4e26 --- /dev/null +++ b/romm/sync.go @@ -0,0 +1,81 @@ +package romm + +import ( + "fmt" + "time" +) + +// ClientSaveState represents the state of a single save file on the client device. +type ClientSaveState struct { + RomID int `json:"rom_id"` + FileName string `json:"file_name"` + Slot string `json:"slot,omitempty"` + Emulator string `json:"emulator,omitempty"` + ContentHash string `json:"content_hash,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + FileSizeBytes int64 `json:"file_size_bytes"` +} + +// SyncNegotiatePayload is the request body for POST /api/sync/negotiate. +type SyncNegotiatePayload struct { + DeviceID string `json:"device_id"` + Saves []ClientSaveState `json:"saves"` +} + +// SyncOperationSchema describes a single sync operation returned by the server. +type SyncOperationSchema struct { + Action string `json:"action"` // "upload", "download", "conflict", "no_op" + RomID int `json:"rom_id"` + SaveID *int `json:"save_id"` // nil for new uploads + FileName string `json:"file_name"` + Slot *string `json:"slot,omitempty"` + Emulator string `json:"emulator,omitempty"` + Reason string `json:"reason"` + ServerUpdatedAt *time.Time `json:"server_updated_at,omitempty"` + ServerContentHash *string `json:"server_content_hash,omitempty"` +} + +// SyncNegotiateResponse is the response from POST /api/sync/negotiate. +type SyncNegotiateResponse struct { + SessionID int `json:"session_id"` + Operations []SyncOperationSchema `json:"operations"` + TotalUpload int `json:"total_upload"` + TotalDownload int `json:"total_download"` + TotalConflict int `json:"total_conflict"` + TotalNoOp int `json:"total_no_op"` +} + +// SyncCompletePayload is the request body for POST /api/sync/sessions/{id}/complete. +type SyncCompletePayload struct { + OperationsCompleted int `json:"operations_completed"` + OperationsFailed int `json:"operations_failed"` +} + +// SyncSessionSchema is the response from sync session endpoints. +type SyncSessionSchema struct { + ID int `json:"id"` + DeviceID string `json:"device_id"` + UserID int `json:"user_id"` + Status string `json:"status"` + InitiatedAt time.Time `json:"initiated_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + OperationsPlanned int `json:"operations_planned"` + OperationsCompleted int `json:"operations_completed"` + OperationsFailed int `json:"operations_failed"` + ErrorMessage *string `json:"error_message,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Negotiate sends the client's save state to the server and receives sync operations. +func (c *Client) Negotiate(payload SyncNegotiatePayload) (SyncNegotiateResponse, error) { + var resp SyncNegotiateResponse + err := c.doRequest("POST", endpointSyncNegotiate, nil, payload, &resp) + return resp, err +} + +// CompleteSession marks a sync session as completed. +func (c *Client) CompleteSession(sessionID int, payload SyncCompletePayload) error { + path := fmt.Sprintf(endpointSyncSessionComplete, sessionID) + return c.doRequest("POST", path, nil, payload, nil) +} diff --git a/sync/execute.go b/sync/execute.go new file mode 100644 index 00000000..64293320 --- /dev/null +++ b/sync/execute.go @@ -0,0 +1,254 @@ +package sync + +import ( + "fmt" + "grout/cfw" + "grout/internal" + "grout/internal/fileutil" + "grout/romm" + "os" + "path/filepath" + "strings" + "time" + + gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool" +) + +func ExecuteActions(client *romm.Client, config *internal.Config, deviceID string, items []SyncItem, sessionID int, progressFn func(current, total int)) SyncReport { + logger := gaba.GetLogger() + report := SyncReport{} + + actionable := 0 + for _, item := range items { + if item.Action != ActionSkip && item.Action != ActionConflict { + actionable++ + } + } + + logger.Debug("Executing sync actions", "total", len(items), "actionable", actionable) + + current := 0 + for i := range items { + item := &items[i] + + switch item.Action { + case ActionUpload: + current++ + if progressFn != nil { + progressFn(current, actionable) + } + if upload(client, deviceID, sessionID, item) { + item.Success = true + report.Uploaded++ + } else { + report.Errors++ + } + + case ActionDownload: + current++ + if progressFn != nil { + progressFn(current, actionable) + } + if download(client, config, deviceID, sessionID, item) { + item.Success = true + report.Downloaded++ + } else { + report.Errors++ + } + + case ActionConflict: + report.Conflicts++ + + default: + report.Skipped++ + } + } + + report.Items = items + logger.Debug("Sync execution complete", "uploaded", report.Uploaded, "downloaded", report.Downloaded, "skipped", report.Skipped, "errors", report.Errors) + return report +} + +func upload(client *romm.Client, deviceID string, sessionID int, item *SyncItem) bool { + logger := gaba.GetLogger() + + logger.Debug("Uploading save", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName, "file", item.LocalSave.FilePath) + + slot := "default" + if item.TargetSlot != "" { + slot = item.TargetSlot + } else if item.RemoteSave != nil && item.RemoteSave.Slot != nil { + slot = *item.RemoteSave.Slot + } + + emulator := filepath.Base(item.LocalSave.EmulatorDir) + if emulator == "." || emulator == "" { + emulator = "unknown" + } + + query := romm.UploadSaveQuery{ + RomID: item.LocalSave.RomID, + DeviceID: deviceID, + Emulator: emulator, + Slot: slot, + Overwrite: item.ForceOverwrite || item.RemoteSave != nil, + SessionID: sessionID, + } + + uploadedSave, err := client.UploadSaveWithQuery(query, item.LocalSave.FilePath) + if err != nil { + logger.Error("Failed to upload save", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName, "error", err) + return false + } + + // Truncate to second precision — the server returns UpdatedAt without + // sub-second precision on subsequent fetches, so local mtime must match. + t := uploadedSave.UpdatedAt.Truncate(time.Second) + if err := os.Chtimes(item.LocalSave.FilePath, t, t); err != nil { + logger.Warn("Failed to set save file mtime after upload", "path", item.LocalSave.FilePath, "error", err) + } + + logger.Debug("Upload successful", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName) + return true +} + +func download(client *romm.Client, config *internal.Config, deviceID string, sessionID int, item *SyncItem) bool { + logger := gaba.GetLogger() + + if item.RemoteSave == nil { + logger.Error("No remote save to download", "romID", item.LocalSave.RomID) + return false + } + + logger.Debug("Downloading save", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName, "saveID", item.RemoteSave.ID) + + if item.LocalSave.FilePath != "" { + if info, err := os.Stat(item.LocalSave.FilePath); err == nil { + backupDir := filepath.Join(filepath.Dir(item.LocalSave.FilePath), ".backup") + ext := filepath.Ext(item.LocalSave.FileName) + base := strings.TrimSuffix(item.LocalSave.FileName, ext) + timestamp := info.ModTime().Format("2006-01-02 15-04-05") + backupPath := filepath.Join(backupDir, fmt.Sprintf("%s [%s]%s", base, timestamp, ext)) + + if err := os.MkdirAll(backupDir, 0755); err != nil { + logger.Warn("Failed to create backup directory", "path", backupDir, "error", err) + } else if err := fileutil.CopyFile(item.LocalSave.FilePath, backupPath); err != nil { + logger.Warn("Failed to backup save before download", "path", item.LocalSave.FilePath, "error", err) + } else { + logger.Debug("Backed up save before download", "backup", backupPath) + if config != nil && config.SaveBackupLimit > 0 { + cleanupBackups(backupDir, base, config.SaveBackupLimit) + } + } + } + } + + data, err := client.DownloadSaveByID(item.RemoteSave.ID, deviceID, true, sessionID) + if err != nil { + logger.Error("Failed to download save", "romID", item.LocalSave.RomID, "saveID", item.RemoteSave.ID, "error", err) + return false + } + + savePath := item.LocalSave.FilePath + if savePath == "" { + saveDir := resolveSaveDirectory(item.LocalSave.FSSlug, config) + if saveDir != "" { + fileName := item.RemoteSave.FileName + if item.LocalSave.RomFileName != "" { + romNameNoExt := strings.TrimSuffix(item.LocalSave.RomFileName, filepath.Ext(item.LocalSave.RomFileName)) + fileName = romNameNoExt + "." + item.RemoteSave.FileExtension + } + savePath = filepath.Join(saveDir, fileName) + } + } + if savePath == "" { + logger.Error("Could not determine save path", "romID", item.LocalSave.RomID, "fsSlug", item.LocalSave.FSSlug) + return false + } + + if err := os.MkdirAll(filepath.Dir(savePath), 0755); err != nil { + logger.Error("Failed to create save directory", "path", filepath.Dir(savePath), "error", err) + return false + } + + if err := os.WriteFile(savePath, data, 0644); err != nil { + logger.Error("Failed to write save file", "path", savePath, "error", err) + return false + } + + t := item.RemoteSave.UpdatedAt.Truncate(time.Second) + if err := os.Chtimes(savePath, t, t); err != nil { + logger.Warn("Failed to set save file mtime", "path", savePath, "error", err) + } + + logger.Debug("Download successful", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName, "path", savePath) + return true +} + +func cleanupBackups(backupDir string, baseName string, limit int) { + if limit <= 0 { + return + } + + logger := gaba.GetLogger() + entries, err := os.ReadDir(backupDir) + if err != nil { + return + } + + type backupFile struct { + name string + modTime int64 + } + var backups []backupFile + for _, e := range entries { + if e.IsDir() || !strings.HasPrefix(e.Name(), baseName+" [") { + continue + } + info, err := e.Info() + if err != nil { + continue + } + backups = append(backups, backupFile{name: e.Name(), modTime: info.ModTime().UnixNano()}) + } + + if len(backups) <= limit { + return + } + + // Sort oldest first + for i := 0; i < len(backups)-1; i++ { + for j := i + 1; j < len(backups); j++ { + if backups[j].modTime < backups[i].modTime { + backups[i], backups[j] = backups[j], backups[i] + } + } + } + + for i := 0; i < len(backups)-limit; i++ { + path := filepath.Join(backupDir, backups[i].name) + if err := os.Remove(path); err != nil { + logger.Warn("Failed to remove old backup", "path", path, "error", err) + } else { + logger.Debug("Removed old backup", "path", path) + } + } +} + +func resolveSaveDirectory(fsSlug string, config *internal.Config) string { + if config != nil && config.SaveDirectoryMappings != nil { + if mapped, ok := config.SaveDirectoryMappings[fsSlug]; ok && mapped != "" { + baseSavePath := cfw.BaseSavePath() + if baseSavePath != "" { + return filepath.Join(baseSavePath, mapped) + } + } + } + + effectiveFSSlug := fsSlug + if config != nil { + effectiveFSSlug = config.ResolveFSSlug(fsSlug) + } + + return cfw.GetSaveDirectory(effectiveFSSlug) +} diff --git a/sync/flow.go b/sync/flow.go index 1a04d19f..edbf07eb 100644 --- a/sync/flow.go +++ b/sync/flow.go @@ -12,758 +12,315 @@ import ( "path/filepath" "sort" "strings" - gosync "sync" "time" gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool" ) -const maxConcurrentRequests = 8 - -func ResolveSaveSync(client *romm.Client, config *internal.Config, deviceID string) ([]SyncItem, error) { +func ResolveSaveSync(client *romm.Client, config *internal.Config, deviceID string) (SyncResult, error) { logger := gaba.GetLogger() logger.Debug("Starting save sync resolve", "deviceID", deviceID) localSaves := ScanSaves(config) logger.Debug("Scanned local saves", "count", len(localSaves)) - remoteSaves, err := FetchRemoteSaves(client, localSaves, deviceID) - if err != nil { - return nil, fmt.Errorf("failed to fetch remote saves: %w", err) - } - logger.Debug("Fetched remote saves", "count", len(remoteSaves)) - - newSaves := LocalSavesWithoutRemote(localSaves, remoteSaves) - logger.Debug("Local saves without remote", "count", len(newSaves)) - - var allItems []SyncItem - allItems = append(allItems, NewSaveUploadActions(newSaves, config)...) - allItems = append(allItems, DetermineActions(localSaves, remoteSaves, deviceID, config)...) + // Build client save states for negotiate + var clientStates []romm.ClientSaveState + for _, ls := range localSaves { + info, err := os.Stat(ls.FilePath) + if err != nil { + logger.Warn("Cannot stat local save, skipping", "path", ls.FilePath, "error", err) + continue + } - remoteOnly, err := DiscoverRemoteSaves(client, config, localSaves, deviceID) - if err != nil { - return nil, fmt.Errorf("failed to discover remote saves: %w", err) - } - allItems = append(allItems, remoteOnly...) + state := romm.ClientSaveState{ + RomID: ls.RomID, + FileName: ls.FileName, + UpdatedAt: info.ModTime().Truncate(time.Second), + FileSizeBytes: info.Size(), + } - logger.Debug("Total sync items resolved", "count", len(allItems)) - return allItems, nil -} + if config != nil { + state.Slot = config.GetSlotPreference(ls.RomID) + } -func ExecuteSaveSync(client *romm.Client, config *internal.Config, deviceID string, items []SyncItem, progressFn func(current, total int)) SyncReport { - report := ExecuteActions(client, config, deviceID, items, progressFn) + emulator := filepath.Base(ls.EmulatorDir) + if emulator != "." && emulator != "" { + state.Emulator = emulator + } - cm := cache.GetCacheManager() - if cm != nil { - for _, item := range report.Items { - if item.Action == ActionSkip || item.Action == ActionConflict || !item.Success { - continue - } - fileName := item.LocalSave.FileName - if fileName == "" && item.RemoteSave != nil { - fileName = item.RemoteSave.FileName - } - record := cache.SaveSyncRecord{ - RomID: item.LocalSave.RomID, - RomName: item.LocalSave.RomName, - Action: item.Action.String(), - DeviceID: deviceID, - FileName: fileName, - } - if item.RemoteSave != nil { - record.SaveID = item.RemoteSave.ID - } - cm.RecordSaveSync(record) + if hash, err := fileutil.ComputeMD5(ls.FilePath); err == nil { + state.ContentHash = hash } + + clientStates = append(clientStates, state) } - return report -} + logger.Debug("Built client save states", "count", len(clientStates)) -func RegisterDevice(client *romm.Client, name string) (romm.Device, error) { - return client.RegisterDevice(romm.RegisterDeviceRequest{ - Name: name, - Platform: string(cfw.GetCFW()), - Client: "grout", - ClientVersion: version.Get().Version, + // Negotiate with server + resp, err := client.Negotiate(romm.SyncNegotiatePayload{ + DeviceID: deviceID, + Saves: clientStates, }) -} - -func ScanSaves(config *internal.Config) []LocalSave { - logger := gaba.GetLogger() - currentCFW := cfw.GetCFW() - - baseSavePath := cfw.BaseSavePath() - if baseSavePath == "" { - logger.Error("No save path for current CFW") - return nil + if err != nil { + return SyncResult{}, fmt.Errorf("negotiate failed: %w", err) } - emulatorMap := cfw.EmulatorFolderMap(currentCFW) - if emulatorMap == nil { - logger.Error("No emulator folder map for current CFW") - return nil - } + logger.Debug("Negotiate response", + "uploads", resp.TotalUpload, + "downloads", resp.TotalDownload, + "conflicts", resp.TotalConflict, + "no_ops", resp.TotalNoOp, + ) - cm := cache.GetCacheManager() - if cm == nil { - logger.Error("Cache manager not available for save scan") - return nil + // Build a lookup of local saves by (rom_id, file_name) + type localKey struct { + romID int + fileName string + } + localByKey := make(map[localKey]LocalSave) + for _, ls := range localSaves { + localByKey[localKey{ls.RomID, ls.FileName}] = ls } - var saves []LocalSave - - logger.Debug("Starting save scan", "baseSavePath", baseSavePath, "platformCount", len(emulatorMap)) - - for fsSlug, emulatorDirs := range emulatorMap { - rommFSSlug := fsSlug - if config != nil { - rommFSSlug = config.ResolveRommFSSlug(fsSlug) - } + // Resolve local ROMs for download path resolution + scan := cfw.ScanRoms(config) + resolvedRoms := ResolveLocalRoms(scan) - for _, emuDir := range emulatorDirs { - saveDir := filepath.Join(baseSavePath, emuDir) + cm := cache.GetCacheManager() - if _, err := os.Stat(saveDir); os.IsNotExist(err) { + // Map operations to SyncItems + var allItems []SyncItem + // Track download operations by rom_id for slot filtering + type downloadOp struct { + op romm.SyncOperationSchema + index int // index in allItems where this will be placed + } + downloadsByRom := make(map[int][]downloadOp) + + for _, op := range resp.Operations { + switch op.Action { + case "upload": + ls, ok := localByKey[localKey{op.RomID, op.FileName}] + if !ok { + logger.Warn("Negotiate returned upload for unknown local save", "romID", op.RomID, "fileName", op.FileName) continue } - - entries, err := os.ReadDir(saveDir) - if err != nil { - logger.Error("Could not read save directory", "path", saveDir, "error", err) - continue + targetSlot := "default" + if config != nil { + targetSlot = config.GetSlotPreference(ls.RomID) } - - saveFileCount := 0 - for _, entry := range entries { - if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { - continue - } - - ext := strings.ToLower(filepath.Ext(entry.Name())) - if !ValidSaveExtensions[ext] { - continue - } - - saveFileCount++ - nameNoExt := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) - - rom, err := cm.GetRomByFSLookup(rommFSSlug, nameNoExt) - if err != nil { - logger.Debug("No cache match for save file", "file", entry.Name(), "fsSlug", rommFSSlug, "nameNoExt", nameNoExt) - continue - } - - logger.Debug("Matched save to ROM", "file", entry.Name(), "romID", rom.ID, "romName", rom.Name) - - saves = append(saves, LocalSave{ - RomID: rom.ID, - RomName: rom.Name, - FSSlug: rommFSSlug, - FileName: entry.Name(), - FilePath: filepath.Join(saveDir, entry.Name()), - EmulatorDir: emuDir, - }) + allItems = append(allItems, SyncItem{ + LocalSave: ls, + RemoteSave: buildRemoteSaveStub(op), + TargetSlot: targetSlot, + Action: ActionUpload, + }) + + case "download": + // Build LocalSave from local match or ROM resolution + ls, ok := localByKey[localKey{op.RomID, op.FileName}] + if !ok { + // Remote-only save — resolve from ROM cache + ls = resolveLocalSaveForDownload(op, resolvedRoms, cm, config) } - if saveFileCount > 0 { - logger.Debug("Scanned emulator directory", "path", saveDir, "saveFiles", saveFileCount) + idx := len(allItems) + allItems = append(allItems, SyncItem{ + LocalSave: ls, + RemoteSave: buildRemoteSaveStub(op), + Action: ActionDownload, + }) + downloadsByRom[op.RomID] = append(downloadsByRom[op.RomID], downloadOp{op: op, index: idx}) + + case "conflict": + ls, ok := localByKey[localKey{op.RomID, op.FileName}] + if !ok { + logger.Warn("Negotiate returned conflict for unknown local save", "romID", op.RomID, "fileName", op.FileName) + continue } + allItems = append(allItems, SyncItem{ + LocalSave: ls, + RemoteSave: buildRemoteSaveStub(op), + Action: ActionConflict, + }) + + case "no_op": + // Skip — nothing to do } } - logger.Debug("Completed save scan", "matched", len(saves)) - return saves -} - -// FetchRemoteSaves fetches saves with device_id for each ROM that has a local save. -// This returns full save data including device_syncs for conflict detection. -func FetchRemoteSaves(client *romm.Client, localSaves []LocalSave, deviceID string) (map[int][]romm.Save, error) { - logger := gaba.GetLogger() - - seen := make(map[int]bool) - for _, ls := range localSaves { - seen[ls.RomID] = true - } - - romIDs := make([]int, 0, len(seen)) - for id := range seen { - romIDs = append(romIDs, id) - } - - logger.Debug("Fetching remote saves", "romCount", len(romIDs)) - - type fetchResult struct { - romID int - saves []romm.Save - err error - } - - results := make(chan fetchResult, len(romIDs)) - sem := make(chan struct{}, maxConcurrentRequests) - var wg gosync.WaitGroup - - for _, romID := range romIDs { - wg.Add(1) - go func(id int) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - - saves, err := client.GetSaves(romm.SaveQuery{RomID: id, DeviceID: deviceID}) - results <- fetchResult{romID: id, saves: saves, err: err} - }(romID) - } - - go func() { - wg.Wait() - close(results) - }() - - result := make(map[int][]romm.Save) - for r := range results { - if r.err != nil { - return nil, fmt.Errorf("rom %d: %w", r.romID, r.err) - } - if len(r.saves) > 0 { - result[r.romID] = r.saves - logger.Debug("Fetched remote saves", "romID", r.romID, "count", len(r.saves)) - } - } - - return result, nil -} - -func LocalSavesWithoutRemote(localSaves []LocalSave, remoteSaves map[int][]romm.Save) []LocalSave { - var filtered []LocalSave - for _, ls := range localSaves { - if _, ok := remoteSaves[ls.RomID]; !ok { - filtered = append(filtered, ls) + // Handle multi-slot downloads: if a ROM has downloads from multiple slots, + // filter to the preferred slot or flag for UI selection + for romID, ops := range downloadsByRom { + if len(ops) <= 1 { + continue } - } - return filtered -} -func NewSaveUploadActions(saves []LocalSave, config *internal.Config) []SyncItem { - var items []SyncItem - for _, ls := range saves { - targetSlot := "default" - if config != nil { - targetSlot = config.GetSlotPreference(ls.RomID) + // Collect distinct slots + slotSet := make(map[string]bool) + for _, dop := range ops { + slot := "default" + if dop.op.Slot != nil { + slot = *dop.op.Slot + } + slotSet[slot] = true } - items = append(items, SyncItem{ - LocalSave: ls, - TargetSlot: targetSlot, - Action: ActionUpload, - }) - } - return items -} - -func DetermineActions(localSaves []LocalSave, remoteSaves map[int][]romm.Save, deviceID string, config *internal.Config) []SyncItem { - logger := gaba.GetLogger() - var items []SyncItem - for _, ls := range localSaves { - saves, ok := remoteSaves[ls.RomID] - if !ok { + if len(slotSet) <= 1 { continue } + // Multiple slots — check if user has a preference preferredSlot := "default" if config != nil { - preferredSlot = config.GetSlotPreference(ls.RomID) + preferredSlot = config.GetSlotPreference(romID) } - remoteSave := selectSaveForSync(saves, preferredSlot) - - // Check if the selected save is actually in the preferred slot or a fallback - remoteSlot := "default" - if remoteSave != nil && remoteSave.Slot != nil { - remoteSlot = *remoteSave.Slot + // Find the operation matching the preferred slot + preferredIdx := -1 + for _, dop := range ops { + slot := "default" + if dop.op.Slot != nil { + slot = *dop.op.Slot + } + if slot == preferredSlot { + preferredIdx = dop.index + break + } } - var action SyncAction - if remoteSave != nil && remoteSlot != preferredSlot { - // Fallback save from a different slot — don't compare against it. - // Upload to populate the preferred slot instead. - action = ActionUpload - remoteSave = nil + if preferredIdx >= 0 { + // Keep only the preferred slot, mark others as skip + for _, dop := range ops { + if dop.index != preferredIdx { + allItems[dop.index].Action = ActionSkip + } + } } else { - action = determineAction(remoteSave, &ls, deviceID) - } - - logger.Debug("Determined sync action", - "romID", ls.RomID, - "romName", ls.RomName, - "action", action.String(), - "preferredSlot", preferredSlot, - "remoteSlot", remoteSlot, - ) - - items = append(items, SyncItem{ - LocalSave: ls, - RemoteSave: remoteSave, - TargetSlot: preferredSlot, - Action: action, - }) - } - - return items -} - -func determineAction(remoteSave *romm.Save, localSave *LocalSave, deviceID string) SyncAction { - logger := gaba.GetLogger() - - if remoteSave == nil { - logger.Debug("No remote save found, will upload", "romID", localSave.RomID) - return ActionUpload - } - - localInfo, err := os.Stat(localSave.FilePath) - if err != nil { - logger.Debug("Cannot stat local save, will download", "path", localSave.FilePath, "error", err) - return ActionDownload - } - // Truncate all times to second precision — the server may drop sub-second - // precision between the upload response and subsequent fetches. - localMtime := localInfo.ModTime().Truncate(time.Second) - remoteUpdatedAt := remoteSave.UpdatedAt.Truncate(time.Second) - - for _, ds := range remoteSave.DeviceSyncs { - if ds.DeviceID == deviceID { - lastSyncedAt := ds.LastSyncedAt.Truncate(time.Second) - localChanged := localMtime.After(lastSyncedAt) - remoteChanged := remoteUpdatedAt.After(lastSyncedAt) - - if localChanged && remoteChanged { - logger.Debug("Both local and remote changed since last sync, conflict", - "romID", localSave.RomID, "localMtime", localMtime, "remoteUpdatedAt", remoteUpdatedAt, "lastSyncedAt", lastSyncedAt) - return ActionConflict + // No preference match — keep the first one and flag available slots for UI + var sortedSlots []string + for slot := range slotSet { + sortedSlots = append(sortedSlots, slot) } + sort.Strings(sortedSlots) - if ds.IsCurrent { - if localMtime.After(remoteUpdatedAt) { - logger.Debug("Device current, local newer, will upload", - "romID", localSave.RomID, "localMtime", localMtime, "remoteUpdatedAt", remoteUpdatedAt) - return ActionUpload - } - logger.Debug("Device current, local not newer, skipping", - "romID", localSave.RomID, "localMtime", localMtime, "remoteUpdatedAt", remoteUpdatedAt) - return ActionSkip + // Keep only the first download, mark others as skip + kept := ops[0].index + allItems[kept].AvailableSlots = sortedSlots + for _, dop := range ops[1:] { + allItems[dop.index].Action = ActionSkip } - logger.Debug("Device in sync list but not current, will download", - "romID", localSave.RomID, "deviceID", deviceID) - return ActionDownload } } - if localMtime.After(remoteUpdatedAt) { - logger.Debug("Device not tracked, local newer, will upload", - "romID", localSave.RomID, "localMtime", localMtime, "remoteUpdatedAt", remoteUpdatedAt) - return ActionUpload - } - if !localMtime.Before(remoteUpdatedAt) { - logger.Debug("Device not tracked, mtime matches remote, skipping", - "romID", localSave.RomID, "localMtime", localMtime, "remoteUpdatedAt", remoteUpdatedAt) - return ActionSkip - } - logger.Debug("Device not tracked, local older, will download", - "romID", localSave.RomID, "localMtime", localMtime, "remoteUpdatedAt", remoteUpdatedAt) - return ActionDownload -} - -// SelectSaveForSlot picks the latest save from the given slot. -// Falls back to the most recently updated save if the slot has no saves. -func SelectSaveForSlot(saves []romm.Save, preferredSlot string) *romm.Save { - return selectSaveForSync(saves, preferredSlot) -} - -// selectSaveForSync picks the latest save from the preferred slot. -// Falls back to the most recently updated save if the preferred slot has no saves. -func selectSaveForSync(saves []romm.Save, preferredSlot string) *romm.Save { - if len(saves) == 0 { - return nil - } - - // Find the latest save in the preferred slot - var best *romm.Save - for i, s := range saves { - slotName := "default" - if s.Slot != nil { - slotName = *s.Slot - } - if slotName != preferredSlot { - continue - } - if best == nil || s.UpdatedAt.After(best.UpdatedAt) { - best = &saves[i] - } - } - if best != nil { - return best - } - - // Fallback: latest save across all slots - best = &saves[0] - for i := 1; i < len(saves); i++ { - if saves[i].UpdatedAt.After(best.UpdatedAt) { - best = &saves[i] - } - } - return best + logger.Debug("Total sync items resolved", "count", len(allItems)) + return SyncResult{Items: allItems, SessionID: resp.SessionID}, nil } -func ExecuteActions(client *romm.Client, config *internal.Config, deviceID string, items []SyncItem, progressFn func(current, total int)) SyncReport { - logger := gaba.GetLogger() - report := SyncReport{} +func ExecuteSaveSync(client *romm.Client, config *internal.Config, deviceID string, items []SyncItem, sessionID int, progressFn func(current, total int)) SyncReport { + report := ExecuteActions(client, config, deviceID, items, sessionID, progressFn) - actionable := 0 - for _, item := range items { - if item.Action != ActionSkip && item.Action != ActionConflict { - actionable++ - } - } - - logger.Debug("Executing sync actions", "total", len(items), "actionable", actionable) - - current := 0 - for i := range items { - item := &items[i] - - switch item.Action { - case ActionUpload: - current++ - if progressFn != nil { - progressFn(current, actionable) - } - if upload(client, deviceID, item) { - item.Success = true - report.Uploaded++ - } else { - report.Errors++ + cm := cache.GetCacheManager() + if cm != nil { + for _, item := range report.Items { + if item.Action == ActionSkip || item.Action == ActionConflict || !item.Success { + continue } - - case ActionDownload: - current++ - if progressFn != nil { - progressFn(current, actionable) + fileName := item.LocalSave.FileName + if fileName == "" && item.RemoteSave != nil { + fileName = item.RemoteSave.FileName } - if download(client, config, deviceID, item) { - item.Success = true - report.Downloaded++ - } else { - report.Errors++ + record := cache.SaveSyncRecord{ + RomID: item.LocalSave.RomID, + RomName: item.LocalSave.RomName, + Action: item.Action.String(), + DeviceID: deviceID, + FileName: fileName, } - - case ActionConflict: - report.Conflicts++ - - default: - report.Skipped++ - } - } - - report.Items = items - logger.Debug("Sync execution complete", "uploaded", report.Uploaded, "downloaded", report.Downloaded, "skipped", report.Skipped, "errors", report.Errors) - return report -} - -func upload(client *romm.Client, deviceID string, item *SyncItem) bool { - logger := gaba.GetLogger() - - logger.Debug("Uploading save", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName, "file", item.LocalSave.FilePath) - - slot := "default" - if item.TargetSlot != "" { - slot = item.TargetSlot - } else if item.RemoteSave != nil && item.RemoteSave.Slot != nil { - slot = *item.RemoteSave.Slot - } - - emulator := filepath.Base(item.LocalSave.EmulatorDir) - if emulator == "." || emulator == "" { - emulator = "unknown" - } - - query := romm.UploadSaveQuery{ - RomID: item.LocalSave.RomID, - DeviceID: deviceID, - Emulator: emulator, - Slot: slot, - Overwrite: item.ForceOverwrite || item.RemoteSave != nil, - } - - uploadedSave, err := client.UploadSaveWithQuery(query, item.LocalSave.FilePath) - if err != nil { - logger.Error("Failed to upload save", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName, "error", err) - return false - } - - // Truncate to second precision — the server returns UpdatedAt without - // sub-second precision on subsequent fetches, so local mtime must match. - t := uploadedSave.UpdatedAt.Truncate(time.Second) - if err := os.Chtimes(item.LocalSave.FilePath, t, t); err != nil { - logger.Warn("Failed to set save file mtime after upload", "path", item.LocalSave.FilePath, "error", err) - } - - if err := client.MarkDeviceSynced(uploadedSave.ID, deviceID); err != nil { - logger.Warn("Failed to confirm upload sync state", "saveID", uploadedSave.ID, "error", err) - } - - logger.Debug("Upload successful", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName) - return true -} - -func download(client *romm.Client, config *internal.Config, deviceID string, item *SyncItem) bool { - logger := gaba.GetLogger() - - if item.RemoteSave == nil { - logger.Error("No remote save to download", "romID", item.LocalSave.RomID) - return false - } - - logger.Debug("Downloading save", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName, "saveID", item.RemoteSave.ID) - - if item.LocalSave.FilePath != "" { - if info, err := os.Stat(item.LocalSave.FilePath); err == nil { - backupDir := filepath.Join(filepath.Dir(item.LocalSave.FilePath), ".backup") - ext := filepath.Ext(item.LocalSave.FileName) - base := strings.TrimSuffix(item.LocalSave.FileName, ext) - timestamp := info.ModTime().Format("2006-01-02 15-04-05") - backupPath := filepath.Join(backupDir, fmt.Sprintf("%s [%s]%s", base, timestamp, ext)) - - if err := os.MkdirAll(backupDir, 0755); err != nil { - logger.Warn("Failed to create backup directory", "path", backupDir, "error", err) - } else if err := fileutil.CopyFile(item.LocalSave.FilePath, backupPath); err != nil { - logger.Warn("Failed to backup save before download", "path", item.LocalSave.FilePath, "error", err) - } else { - logger.Debug("Backed up save before download", "backup", backupPath) - if config != nil && config.SaveBackupLimit > 0 { - cleanupBackups(backupDir, base, config.SaveBackupLimit) - } + if item.RemoteSave != nil { + record.SaveID = item.RemoteSave.ID } + cm.RecordSaveSync(record) } } - data, err := client.DownloadSaveByID(item.RemoteSave.ID, deviceID, true) - if err != nil { - logger.Error("Failed to download save", "romID", item.LocalSave.RomID, "saveID", item.RemoteSave.ID, "error", err) - return false - } - - savePath := item.LocalSave.FilePath - if savePath == "" { - saveDir := ResolveSaveDirectory(item.LocalSave.FSSlug, config) - if saveDir != "" { - fileName := item.RemoteSave.FileName - if item.LocalSave.RomFileName != "" { - romNameNoExt := strings.TrimSuffix(item.LocalSave.RomFileName, filepath.Ext(item.LocalSave.RomFileName)) - fileName = romNameNoExt + "." + item.RemoteSave.FileExtension - } - savePath = filepath.Join(saveDir, fileName) + // Complete the sync session + if sessionID > 0 { + completed := report.Uploaded + report.Downloaded + failed := report.Errors + if err := client.CompleteSession(sessionID, romm.SyncCompletePayload{ + OperationsCompleted: completed, + OperationsFailed: failed, + }); err != nil { + gaba.GetLogger().Warn("Failed to complete sync session", "sessionID", sessionID, "error", err) } } - if savePath == "" { - logger.Error("Could not determine save path", "romID", item.LocalSave.RomID, "fsSlug", item.LocalSave.FSSlug) - return false - } - - if err := os.MkdirAll(filepath.Dir(savePath), 0755); err != nil { - logger.Error("Failed to create save directory", "path", filepath.Dir(savePath), "error", err) - return false - } - if err := os.WriteFile(savePath, data, 0644); err != nil { - logger.Error("Failed to write save file", "path", savePath, "error", err) - return false - } - - t := item.RemoteSave.UpdatedAt.Truncate(time.Second) - if err := os.Chtimes(savePath, t, t); err != nil { - logger.Warn("Failed to set save file mtime", "path", savePath, "error", err) - } - - if err := client.MarkDeviceSynced(item.RemoteSave.ID, deviceID); err != nil { - logger.Warn("Failed to confirm save download", "saveID", item.RemoteSave.ID, "error", err) - } - - logger.Debug("Download successful", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName, "path", savePath) - return true + return report } -func DiscoverRemoteSaves(client *romm.Client, config *internal.Config, localSaves []LocalSave, deviceID string) ([]SyncItem, error) { - logger := gaba.GetLogger() - - scan := cfw.ScanRoms(config) - resolved := ResolveLocalRoms(scan) - if len(resolved) == 0 { - return nil, nil - } - - coveredRomIDs := make(map[int]bool) - for _, ls := range localSaves { - coveredRomIDs[ls.RomID] = true - } - - var uncoveredRomIDs []int - for romID := range resolved { - if !coveredRomIDs[romID] { - uncoveredRomIDs = append(uncoveredRomIDs, romID) - } - } - - if len(uncoveredRomIDs) == 0 { - logger.Debug("All local ROMs already have local saves") - return nil, nil - } - - logger.Debug("Checking remote saves for ROMs without local saves", "count", len(uncoveredRomIDs)) - - type discoverResult struct { - romID int - saves []romm.Save - err error - } - - results := make(chan discoverResult, len(uncoveredRomIDs)) - sem := make(chan struct{}, maxConcurrentRequests) - var wg gosync.WaitGroup - - for _, romID := range uncoveredRomIDs { - wg.Add(1) - go func(id int) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - - saves, err := client.GetSaves(romm.SaveQuery{RomID: id, DeviceID: deviceID}) - results <- discoverResult{romID: id, saves: saves, err: err} - }(romID) - } - - go func() { - wg.Wait() - close(results) - }() - - var items []SyncItem - for r := range results { - if r.err != nil { - return nil, fmt.Errorf("rom %d: %w", r.romID, r.err) - } - - preferredSlot := "default" - if config != nil { - preferredSlot = config.GetSlotPreference(r.romID) - } - remoteSave := selectSaveForSync(r.saves, preferredSlot) - if remoteSave == nil { - continue - } - - rom := resolved[r.romID] - logger.Debug("Found remote save for ROM without local save", - "romID", r.romID, "romName", rom.RomName, "saveFile", remoteSave.FileName) - - item := SyncItem{ - LocalSave: LocalSave{ - RomID: r.romID, - RomName: rom.RomName, - FSSlug: rom.FSSlug, - RomFileName: rom.FileName, - }, - RemoteSave: remoteSave, - TargetSlot: preferredSlot, - Action: ActionDownload, - } - - // Detect multiple distinct slots for first-time downloads - slotSet := make(map[string]bool) - for _, save := range r.saves { - slot := "default" - if save.Slot != nil { - slot = *save.Slot - } - slotSet[slot] = true - } - if len(slotSet) > 1 { - for slot := range slotSet { - item.AvailableSlots = append(item.AvailableSlots, slot) - } - sort.Strings(item.AvailableSlots) - item.AllRemoteSaves = r.saves - } - - items = append(items, item) - } - - logger.Debug("Remote-only saves to download", "count", len(items)) - return items, nil +func RegisterDevice(client *romm.Client, name string) (romm.Device, error) { + return client.RegisterDevice(romm.RegisterDeviceRequest{ + Name: name, + Platform: string(cfw.GetCFW()), + Client: "grout", + ClientVersion: version.Get().Version, + }) } -func cleanupBackups(backupDir string, baseName string, limit int) { - if limit <= 0 { - return +// buildRemoteSaveStub creates a romm.Save from a negotiate operation for use in SyncItem. +func buildRemoteSaveStub(op romm.SyncOperationSchema) *romm.Save { + if op.SaveID == nil && op.ServerUpdatedAt == nil { + return nil } - logger := gaba.GetLogger() - entries, err := os.ReadDir(backupDir) - if err != nil { - return + save := &romm.Save{ + RomID: op.RomID, + FileName: op.FileName, + Emulator: op.Emulator, } - // Collect backup files for this game (matching base name prefix) - type backupFile struct { - name string - modTime int64 + if op.SaveID != nil { + save.ID = *op.SaveID } - var backups []backupFile - for _, e := range entries { - if e.IsDir() || !strings.HasPrefix(e.Name(), baseName+" [") { - continue - } - info, err := e.Info() - if err != nil { - continue - } - backups = append(backups, backupFile{name: e.Name(), modTime: info.ModTime().UnixNano()}) + if op.Slot != nil { + save.Slot = op.Slot } - - if len(backups) <= limit { - return + if op.ServerUpdatedAt != nil { + save.UpdatedAt = *op.ServerUpdatedAt } - // Sort oldest first - sort.Slice(backups, func(i, j int) bool { - return backups[i].modTime < backups[j].modTime - }) - - // Remove oldest until we're at the limit - for i := 0; i < len(backups)-limit; i++ { - path := filepath.Join(backupDir, backups[i].name) - if err := os.Remove(path); err != nil { - logger.Warn("Failed to remove old backup", "path", path, "error", err) - } else { - logger.Debug("Removed old backup", "path", path) - } + // Derive file extension from file name + ext := filepath.Ext(op.FileName) + if ext != "" { + save.FileExtension = strings.TrimPrefix(ext, ".") } + + return save } -func ResolveSaveDirectory(fsSlug string, config *internal.Config) string { - if config != nil && config.SaveDirectoryMappings != nil { - if mapped, ok := config.SaveDirectoryMappings[fsSlug]; ok && mapped != "" { - baseSavePath := cfw.BaseSavePath() - if baseSavePath != "" { - return filepath.Join(baseSavePath, mapped) - } +// resolveLocalSaveForDownload builds a LocalSave for a download-only operation +// (remote save exists but no local save file). +func resolveLocalSaveForDownload(op romm.SyncOperationSchema, resolvedRoms map[int]cfw.LocalRomFile, cm *cache.Manager, config *internal.Config) LocalSave { + ls := LocalSave{ + RomID: op.RomID, + FileName: op.FileName, + } + + // Try to get ROM info from resolved local ROMs + if rom, ok := resolvedRoms[op.RomID]; ok { + ls.RomName = rom.RomName + ls.FSSlug = rom.FSSlug + ls.RomFileName = rom.FileName + } else if cm != nil { + // Fallback: try cache lookup by ROM ID + if roms, err := cm.GetGamesByIDs([]int{op.RomID}); err == nil && len(roms) > 0 { + ls.RomName = roms[0].Name + ls.FSSlug = roms[0].PlatformFSSlug } } - effectiveFSSlug := fsSlug - if config != nil { - effectiveFSSlug = config.ResolveFSSlug(fsSlug) - } - - return cfw.GetSaveDirectory(effectiveFSSlug) + return ls } diff --git a/sync/flow_test.go b/sync/flow_test.go index 37ce79db..33578e71 100644 --- a/sync/flow_test.go +++ b/sync/flow_test.go @@ -1,363 +1,95 @@ package sync import ( - "grout/internal" "grout/romm" - "os" - "path/filepath" "testing" "time" ) -func makeTempSave(t *testing.T, mtime time.Time) string { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, "test.sav") - if err := os.WriteFile(path, []byte("save"), 0644); err != nil { - t.Fatal(err) - } - if err := os.Chtimes(path, mtime, mtime); err != nil { - t.Fatal(err) - } - return path -} - func ptrStr(s string) *string { return &s } +func ptrInt(i int) *int { return &i } +func ptrTime(t time.Time) *time.Time { return &t } -// --- determineAction tests --- - -func TestDetermineAction_NoRemoteSave(t *testing.T) { - now := time.Now() - path := makeTempSave(t, now) - ls := &LocalSave{RomID: 1, FilePath: path} - - action := determineAction(nil, ls, "device-1") - - if action != ActionUpload { - t.Errorf("expected ActionUpload, got %s", action) - } -} - -func TestDetermineAction_LocalFileUnreadable(t *testing.T) { - ls := &LocalSave{RomID: 1, FilePath: "/nonexistent/path/save.sav"} - remote := &romm.Save{ID: 10, UpdatedAt: time.Now()} - - action := determineAction(remote, ls, "device-1") - - if action != ActionDownload { - t.Errorf("expected ActionDownload, got %s", action) - } -} - -func TestDetermineAction_DeviceCurrent_BothChanged(t *testing.T) { - lastSync := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - localMtime := lastSync.Add(1 * time.Hour) - remoteUpdated := lastSync.Add(2 * time.Hour) - - path := makeTempSave(t, localMtime) - ls := &LocalSave{RomID: 1, FilePath: path} - remote := &romm.Save{ - ID: 10, - UpdatedAt: remoteUpdated, - DeviceSyncs: []romm.DeviceSaveSync{ - {DeviceID: "device-1", IsCurrent: true, LastSyncedAt: lastSync}, - }, - } - - action := determineAction(remote, ls, "device-1") - - if action != ActionConflict { - t.Errorf("expected ActionConflict, got %s", action) - } -} - -func TestDetermineAction_DeviceCurrent_LocalNewer(t *testing.T) { - remoteUpdated := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - lastSync := remoteUpdated // remote hasn't changed since last sync - localMtime := remoteUpdated.Add(1 * time.Hour) - - path := makeTempSave(t, localMtime) - ls := &LocalSave{RomID: 1, FilePath: path} - remote := &romm.Save{ - ID: 10, - UpdatedAt: remoteUpdated, - DeviceSyncs: []romm.DeviceSaveSync{ - {DeviceID: "device-1", IsCurrent: true, LastSyncedAt: lastSync}, - }, - } - - action := determineAction(remote, ls, "device-1") - - if action != ActionUpload { - t.Errorf("expected ActionUpload, got %s", action) - } -} - -func TestDetermineAction_DeviceCurrent_RemoteNewerOrEqual(t *testing.T) { - lastSync := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - remoteUpdated := lastSync.Add(1 * time.Hour) - localMtime := lastSync.Add(-1 * time.Hour) // local older than both - - path := makeTempSave(t, localMtime) - ls := &LocalSave{RomID: 1, FilePath: path} - remote := &romm.Save{ - ID: 10, - UpdatedAt: remoteUpdated, - DeviceSyncs: []romm.DeviceSaveSync{ - {DeviceID: "device-1", IsCurrent: true, LastSyncedAt: lastSync}, - }, - } - - action := determineAction(remote, ls, "device-1") - - if action != ActionSkip { - t.Errorf("expected ActionSkip, got %s", action) - } -} - -func TestDetermineAction_DeviceTrackedNotCurrent_BothChanged(t *testing.T) { - lastSync := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - localMtime := lastSync.Add(1 * time.Hour) - remoteUpdated := lastSync.Add(2 * time.Hour) +// --- buildRemoteSaveStub tests --- - path := makeTempSave(t, localMtime) - ls := &LocalSave{RomID: 1, FilePath: path} - remote := &romm.Save{ - ID: 10, - UpdatedAt: remoteUpdated, - DeviceSyncs: []romm.DeviceSaveSync{ - {DeviceID: "device-1", IsCurrent: false, LastSyncedAt: lastSync}, - }, +func TestBuildRemoteSaveStub_NilWhenNoIDOrTimestamp(t *testing.T) { + op := romm.SyncOperationSchema{ + Action: "upload", + RomID: 1, + FileName: "test.sav", } - - action := determineAction(remote, ls, "device-1") - - if action != ActionConflict { - t.Errorf("expected ActionConflict, got %s", action) - } -} - -func TestDetermineAction_DeviceTrackedNotCurrent_OnlyRemoteChanged(t *testing.T) { - lastSync := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - localMtime := lastSync.Add(-1 * time.Hour) - remoteUpdated := lastSync.Add(2 * time.Hour) - - path := makeTempSave(t, localMtime) - ls := &LocalSave{RomID: 1, FilePath: path} - remote := &romm.Save{ - ID: 10, - UpdatedAt: remoteUpdated, - DeviceSyncs: []romm.DeviceSaveSync{ - {DeviceID: "device-1", IsCurrent: false, LastSyncedAt: lastSync}, - }, - } - - action := determineAction(remote, ls, "device-1") - - if action != ActionDownload { - t.Errorf("expected ActionDownload, got %s", action) - } -} - -func TestDetermineAction_DeviceNotTracked_LocalNewer(t *testing.T) { - remoteUpdated := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - localMtime := remoteUpdated.Add(1 * time.Hour) - - path := makeTempSave(t, localMtime) - ls := &LocalSave{RomID: 1, FilePath: path} - remote := &romm.Save{ - ID: 10, - UpdatedAt: remoteUpdated, - DeviceSyncs: []romm.DeviceSaveSync{}, // empty - device not tracked - } - - action := determineAction(remote, ls, "device-1") - - if action != ActionUpload { - t.Errorf("expected ActionUpload, got %s", action) - } -} - -func TestDetermineAction_DeviceNotTracked_SameMtime(t *testing.T) { - mtime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - - path := makeTempSave(t, mtime) - ls := &LocalSave{RomID: 1, FilePath: path} - remote := &romm.Save{ - ID: 10, - UpdatedAt: mtime, - DeviceSyncs: []romm.DeviceSaveSync{}, - } - - action := determineAction(remote, ls, "device-1") - - if action != ActionSkip { - t.Errorf("expected ActionSkip, got %s", action) - } -} - -func TestDetermineAction_DeviceNotTracked_LocalOlder(t *testing.T) { - remoteUpdated := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) - localMtime := remoteUpdated.Add(-1 * time.Hour) - - path := makeTempSave(t, localMtime) - ls := &LocalSave{RomID: 1, FilePath: path} - remote := &romm.Save{ - ID: 10, - UpdatedAt: remoteUpdated, - DeviceSyncs: []romm.DeviceSaveSync{}, - } - - action := determineAction(remote, ls, "device-1") - - if action != ActionDownload { - t.Errorf("expected ActionDownload, got %s", action) - } -} - -func TestDetermineAction_OtherDeviceCurrent(t *testing.T) { - remoteUpdated := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) - localMtime := remoteUpdated.Add(-1 * time.Hour) - - path := makeTempSave(t, localMtime) - ls := &LocalSave{RomID: 1, FilePath: path} - remote := &romm.Save{ - ID: 10, - UpdatedAt: remoteUpdated, - DeviceSyncs: []romm.DeviceSaveSync{ - {DeviceID: "other-device", IsCurrent: true, LastSyncedAt: remoteUpdated}, - }, - } - - action := determineAction(remote, ls, "device-1") - - if action != ActionDownload { - t.Errorf("expected ActionDownload, got %s", action) - } -} - -// --- DetermineActions tests --- - -func TestDetermineActions_SkipsSavesWithoutRemote(t *testing.T) { - now := time.Now() - path := makeTempSave(t, now) - localSaves := []LocalSave{ - {RomID: 1, FilePath: path}, - {RomID: 2, FilePath: path}, - } - remoteSaves := map[int][]romm.Save{} // no remote saves - - items := DetermineActions(localSaves, remoteSaves, "device-1", nil) - - if len(items) != 0 { - t.Errorf("expected 0 items, got %d", len(items)) - } -} - -func TestDetermineActions_EmptyInputs(t *testing.T) { - items := DetermineActions(nil, nil, "device-1", nil) - if len(items) != 0 { - t.Errorf("expected 0 items, got %d", len(items)) + result := buildRemoteSaveStub(op) + if result != nil { + t.Error("expected nil when no save_id or server_updated_at") } } -func TestDetermineActions_ReturnsCorrectActions(t *testing.T) { - remoteUpdated := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - - // ROM 1: local newer, no device tracking → upload - uploadPath := makeTempSave(t, remoteUpdated.Add(1*time.Hour)) - // ROM 2: local older, no device tracking → download - downloadPath := makeTempSave(t, remoteUpdated.Add(-1*time.Hour)) - - localSaves := []LocalSave{ - {RomID: 1, RomName: "Mario", FilePath: uploadPath}, - {RomID: 2, RomName: "Zelda", FilePath: downloadPath}, - } - - remoteSaves := map[int][]romm.Save{ - 1: {{ID: 10, RomID: 1, UpdatedAt: remoteUpdated, Slot: ptrStr("default")}}, - 2: {{ID: 20, RomID: 2, UpdatedAt: remoteUpdated, Slot: ptrStr("default")}}, - } - - items := DetermineActions(localSaves, remoteSaves, "device-1", nil) - - if len(items) != 2 { - t.Fatalf("expected 2 items, got %d", len(items)) +func TestBuildRemoteSaveStub_WithSaveID(t *testing.T) { + op := romm.SyncOperationSchema{ + Action: "download", + RomID: 1, + SaveID: ptrInt(42), + FileName: "test.sav", + Slot: ptrStr("quicksave"), + Emulator: "mgba", } - - actionsByRom := map[int]SyncAction{} - for _, item := range items { - actionsByRom[item.LocalSave.RomID] = item.Action - } - - if actionsByRom[1] != ActionUpload { - t.Errorf("ROM 1: expected ActionUpload, got %s", actionsByRom[1]) + result := buildRemoteSaveStub(op) + if result == nil { + t.Fatal("expected non-nil result") } - if actionsByRom[2] != ActionDownload { - t.Errorf("ROM 2: expected ActionDownload, got %s", actionsByRom[2]) + if result.ID != 42 { + t.Errorf("expected ID 42, got %d", result.ID) } -} - -func TestDetermineActions_PopulatesRemoteSave(t *testing.T) { - remoteUpdated := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - path := makeTempSave(t, remoteUpdated.Add(1*time.Hour)) - - localSaves := []LocalSave{ - {RomID: 1, RomName: "Mario", FilePath: path}, + if result.RomID != 1 { + t.Errorf("expected RomID 1, got %d", result.RomID) } - remoteSaves := map[int][]romm.Save{ - 1: {{ID: 10, RomID: 1, UpdatedAt: remoteUpdated, Slot: ptrStr("default")}}, + if result.FileName != "test.sav" { + t.Errorf("expected FileName test.sav, got %s", result.FileName) } - - items := DetermineActions(localSaves, remoteSaves, "device-1", nil) - - if len(items) != 1 { - t.Fatalf("expected 1 item, got %d", len(items)) + if result.Slot == nil || *result.Slot != "quicksave" { + t.Errorf("expected Slot quicksave, got %v", result.Slot) } - if items[0].RemoteSave == nil { - t.Fatal("expected RemoteSave to be set") + if result.Emulator != "mgba" { + t.Errorf("expected Emulator mgba, got %s", result.Emulator) } - if items[0].RemoteSave.ID != 10 { - t.Errorf("expected RemoteSave.ID 10, got %d", items[0].RemoteSave.ID) + if result.FileExtension != "sav" { + t.Errorf("expected FileExtension sav, got %s", result.FileExtension) } } -func TestDetermineActions_NoRemoteSavesForRom(t *testing.T) { - path := makeTempSave(t, time.Now()) - localSaves := []LocalSave{ - {RomID: 1, FilePath: path}, +func TestBuildRemoteSaveStub_WithServerUpdatedAt(t *testing.T) { + ts := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) + op := romm.SyncOperationSchema{ + Action: "download", + RomID: 1, + FileName: "game.srm", + ServerUpdatedAt: ptrTime(ts), } - remoteSaves := map[int][]romm.Save{ - 1: {}, // empty saves list - } - - items := DetermineActions(localSaves, remoteSaves, "device-1", nil) - - if len(items) != 1 { - t.Fatalf("expected 1 item, got %d", len(items)) + result := buildRemoteSaveStub(op) + if result == nil { + t.Fatal("expected non-nil result") } - // No remote save (empty list) → upload - if items[0].Action != ActionUpload { - t.Errorf("expected ActionUpload for empty saves, got %s", items[0].Action) + if !result.UpdatedAt.Equal(ts) { + t.Errorf("expected UpdatedAt %v, got %v", ts, result.UpdatedAt) } } -// --- selectSaveForSync tests --- +// --- SelectSaveForSlot tests --- -func TestSelectSaveForSync_EmptySaves(t *testing.T) { - result := selectSaveForSync(nil, "default") +func TestSelectSaveForSlot_EmptySaves(t *testing.T) { + result := SelectSaveForSlot(nil, "default") if result != nil { t.Errorf("expected nil for empty saves, got %+v", result) } } -func TestSelectSaveForSync_ReturnsDefaultSlot(t *testing.T) { +func TestSelectSaveForSlot_ReturnsDefaultSlot(t *testing.T) { saves := []romm.Save{ {ID: 42, Slot: ptrStr("default"), UpdatedAt: time.Now()}, {ID: 99, Slot: ptrStr("slot2"), UpdatedAt: time.Now()}, } - result := selectSaveForSync(saves, "default") + result := SelectSaveForSlot(saves, "default") if result == nil { t.Fatal("expected non-nil result") } @@ -366,13 +98,13 @@ func TestSelectSaveForSync_ReturnsDefaultSlot(t *testing.T) { } } -func TestSelectSaveForSync_ReturnsPreferredSlot(t *testing.T) { +func TestSelectSaveForSlot_ReturnsPreferredSlot(t *testing.T) { saves := []romm.Save{ {ID: 42, Slot: ptrStr("default"), UpdatedAt: time.Now()}, {ID: 99, Slot: ptrStr("quicksave"), UpdatedAt: time.Now()}, } - result := selectSaveForSync(saves, "quicksave") + result := SelectSaveForSlot(saves, "quicksave") if result == nil { t.Fatal("expected non-nil result") } @@ -381,7 +113,7 @@ func TestSelectSaveForSync_ReturnsPreferredSlot(t *testing.T) { } } -func TestSelectSaveForSync_PicksLatestInSlot(t *testing.T) { +func TestSelectSaveForSlot_PicksLatestInSlot(t *testing.T) { older := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) newer := older.Add(1 * time.Hour) @@ -390,7 +122,7 @@ func TestSelectSaveForSync_PicksLatestInSlot(t *testing.T) { {ID: 20, Slot: ptrStr("default"), UpdatedAt: newer}, } - result := selectSaveForSync(saves, "default") + result := SelectSaveForSlot(saves, "default") if result == nil { t.Fatal("expected non-nil result") } @@ -399,12 +131,12 @@ func TestSelectSaveForSync_PicksLatestInSlot(t *testing.T) { } } -func TestSelectSaveForSync_FallsBackToLatest(t *testing.T) { +func TestSelectSaveForSlot_FallsBackToLatest(t *testing.T) { saves := []romm.Save{ {ID: 42, Slot: ptrStr("default"), UpdatedAt: time.Now()}, } - result := selectSaveForSync(saves, "nonexistent") + result := SelectSaveForSlot(saves, "nonexistent") if result == nil { t.Fatal("expected non-nil result") } @@ -413,12 +145,12 @@ func TestSelectSaveForSync_FallsBackToLatest(t *testing.T) { } } -func TestSelectSaveForSync_NilSlotTreatedAsDefault(t *testing.T) { +func TestSelectSaveForSlot_NilSlotTreatedAsDefault(t *testing.T) { saves := []romm.Save{ {ID: 42, Slot: nil, UpdatedAt: time.Now()}, } - result := selectSaveForSync(saves, "default") + result := SelectSaveForSlot(saves, "default") if result == nil { t.Fatal("expected non-nil result") } @@ -427,114 +159,31 @@ func TestSelectSaveForSync_NilSlotTreatedAsDefault(t *testing.T) { } } -func TestDetermineActions_WithSlotPreference(t *testing.T) { - remoteUpdated := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - path := makeTempSave(t, remoteUpdated.Add(1*time.Hour)) +// --- SyncAction tests --- - localSaves := []LocalSave{ - {RomID: 1, RomName: "Mario", FilePath: path}, +func TestSyncAction_String(t *testing.T) { + tests := []struct { + action SyncAction + want string + }{ + {ActionUpload, "upload"}, + {ActionDownload, "download"}, + {ActionConflict, "conflict"}, + {ActionSkip, "skip"}, } - - remoteSaves := map[int][]romm.Save{ - 1: { - {ID: 10, RomID: 1, UpdatedAt: remoteUpdated, Slot: ptrStr("default")}, - {ID: 20, RomID: 1, UpdatedAt: remoteUpdated, Slot: ptrStr("quicksave")}, - }, - } - - config := &internal.Config{ - SlotPreferences: map[string]string{"1": "quicksave"}, - } - - items := DetermineActions(localSaves, remoteSaves, "device-1", config) - - if len(items) != 1 { - t.Fatalf("expected 1 item, got %d", len(items)) - } - if items[0].RemoteSave == nil { - t.Fatal("expected RemoteSave to be set") - } - if items[0].RemoteSave.ID != 20 { - t.Errorf("expected RemoteSave.ID 20 (quicksave slot), got %d", items[0].RemoteSave.ID) - } -} - -func TestDetermineActions_SlotFallbackForcesUpload(t *testing.T) { - // When the preferred slot doesn't exist on the server, DetermineActions - // should force an upload with nil RemoteSave so the slot gets created, - // rather than comparing against a fallback save from a different slot. - remoteUpdated := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) - // Local file is OLDER than the remote — without fallback detection this - // would normally be ActionDownload, which would be wrong. - localMtime := remoteUpdated.Add(-1 * time.Hour) - path := makeTempSave(t, localMtime) - - localSaves := []LocalSave{ - {RomID: 1, RomName: "Mario", FilePath: path}, - } - - // Remote only has "default" slot, but user prefers "quicksave" - remoteSaves := map[int][]romm.Save{ - 1: {{ID: 10, RomID: 1, UpdatedAt: remoteUpdated, Slot: ptrStr("default")}}, - } - - config := &internal.Config{ - SlotPreferences: map[string]string{"1": "quicksave"}, - } - - items := DetermineActions(localSaves, remoteSaves, "device-1", config) - - if len(items) != 1 { - t.Fatalf("expected 1 item, got %d", len(items)) - } - if items[0].Action != ActionUpload { - t.Errorf("expected ActionUpload (slot fallback), got %s", items[0].Action) - } - if items[0].RemoteSave != nil { - t.Error("expected nil RemoteSave when falling back to different slot") - } - if items[0].TargetSlot != "quicksave" { - t.Errorf("expected TargetSlot 'quicksave', got %q", items[0].TargetSlot) - } -} - -// --- Helper function tests --- - -func TestLocalSavesWithoutRemote(t *testing.T) { - saves := []LocalSave{ - {RomID: 1, RomName: "Mario"}, - {RomID: 2, RomName: "Zelda"}, - {RomID: 3, RomName: "Metroid"}, - } - remoteSaves := map[int][]romm.Save{ - 1: {{ID: 10}}, - 3: {{ID: 30}}, - } - - result := LocalSavesWithoutRemote(saves, remoteSaves) - - if len(result) != 1 { - t.Fatalf("expected 1 save without remote, got %d", len(result)) - } - if result[0].RomID != 2 { - t.Errorf("expected RomID 2, got %d", result[0].RomID) + for _, tt := range tests { + if got := tt.action.String(); got != tt.want { + t.Errorf("SyncAction(%d).String() = %q, want %q", tt.action, got, tt.want) + } } } -func TestNewSaveUploadActions(t *testing.T) { - saves := []LocalSave{ - {RomID: 1, RomName: "Mario"}, - {RomID: 2, RomName: "Zelda"}, - } - - items := NewSaveUploadActions(saves, nil) +// --- SyncItem.Resolve tests --- - if len(items) != 2 { - t.Fatalf("expected 2 items, got %d", len(items)) - } - for _, item := range items { - if item.Action != ActionUpload { - t.Errorf("expected ActionUpload, got %s", item.Action) - } +func TestSyncItem_Resolve(t *testing.T) { + item := SyncItem{Action: ActionConflict} + item.Resolve(ActionUpload) + if item.Action != ActionUpload { + t.Errorf("expected ActionUpload after Resolve, got %s", item.Action) } } diff --git a/sync/models.go b/sync/models.go index b04cb16c..6181e6a3 100644 --- a/sync/models.go +++ b/sync/models.go @@ -51,6 +51,11 @@ func (item *SyncItem) Resolve(action SyncAction) { item.Action = action } +type SyncResult struct { + Items []SyncItem + SessionID int +} + type SyncReport struct { Uploaded int Downloaded int diff --git a/sync/scan.go b/sync/scan.go new file mode 100644 index 00000000..64804bfd --- /dev/null +++ b/sync/scan.go @@ -0,0 +1,133 @@ +package sync + +import ( + "grout/cache" + "grout/cfw" + "grout/internal" + "grout/romm" + "os" + "path/filepath" + "strings" + + gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool" +) + +func ScanSaves(config *internal.Config) []LocalSave { + logger := gaba.GetLogger() + currentCFW := cfw.GetCFW() + + baseSavePath := cfw.BaseSavePath() + if baseSavePath == "" { + logger.Error("No save path for current CFW") + return nil + } + + emulatorMap := cfw.EmulatorFolderMap(currentCFW) + if emulatorMap == nil { + logger.Error("No emulator folder map for current CFW") + return nil + } + + cm := cache.GetCacheManager() + if cm == nil { + logger.Error("Cache manager not available for save scan") + return nil + } + + var saves []LocalSave + + logger.Debug("Starting save scan", "baseSavePath", baseSavePath, "platformCount", len(emulatorMap)) + + for fsSlug, emulatorDirs := range emulatorMap { + rommFSSlug := fsSlug + if config != nil { + rommFSSlug = config.ResolveRommFSSlug(fsSlug) + } + + for _, emuDir := range emulatorDirs { + saveDir := filepath.Join(baseSavePath, emuDir) + + if _, err := os.Stat(saveDir); os.IsNotExist(err) { + continue + } + + entries, err := os.ReadDir(saveDir) + if err != nil { + logger.Error("Could not read save directory", "path", saveDir, "error", err) + continue + } + + saveFileCount := 0 + for _, entry := range entries { + if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + + ext := strings.ToLower(filepath.Ext(entry.Name())) + if !ValidSaveExtensions[ext] { + continue + } + + saveFileCount++ + nameNoExt := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) + + rom, err := cm.GetRomByFSLookup(rommFSSlug, nameNoExt) + if err != nil { + logger.Debug("No cache match for save file", "file", entry.Name(), "fsSlug", rommFSSlug, "nameNoExt", nameNoExt) + continue + } + + logger.Debug("Matched save to ROM", "file", entry.Name(), "romID", rom.ID, "romName", rom.Name) + + saves = append(saves, LocalSave{ + RomID: rom.ID, + RomName: rom.Name, + FSSlug: rommFSSlug, + FileName: entry.Name(), + FilePath: filepath.Join(saveDir, entry.Name()), + EmulatorDir: emuDir, + }) + } + + if saveFileCount > 0 { + logger.Debug("Scanned emulator directory", "path", saveDir, "saveFiles", saveFileCount) + } + } + } + + logger.Debug("Completed save scan", "matched", len(saves)) + return saves +} + +// SelectSaveForSlot picks the latest save from the given slot. +// Falls back to the most recently updated save if the slot has no saves. +func SelectSaveForSlot(saves []romm.Save, preferredSlot string) *romm.Save { + if len(saves) == 0 { + return nil + } + + var best *romm.Save + for i, s := range saves { + slotName := "default" + if s.Slot != nil { + slotName = *s.Slot + } + if slotName != preferredSlot { + continue + } + if best == nil || s.UpdatedAt.After(best.UpdatedAt) { + best = &saves[i] + } + } + if best != nil { + return best + } + + best = &saves[0] + for i := 1; i < len(saves); i++ { + if saves[i].UpdatedAt.After(best.UpdatedAt) { + best = &saves[i] + } + } + return best +} diff --git a/taskfile.yml b/taskfile.yml index 0ce6ba87..b7f360c9 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -1,5 +1,7 @@ version: '3' +dotenv: ['.env'] + vars: IMAGE_NAME: grout-build CONTAINER_NAME: grout-extract @@ -253,6 +255,22 @@ tasks: - adb push config-platformless.json /userdata/roms/ports/Grout/config.json silent: true + upload-muos: + desc: Deploy to muOS device via SSH/rsync + preconditions: + - sh: test -n "$DEVICE_IP_ADDRESS" + msg: "DEVICE_IP_ADDRESS not set" + - sh: test -n "$PRIVATE_KEY_PATH" || test -n "$SSH_PASSWORD" + msg: "Set PRIVATE_KEY_PATH or SSH_PASSWORD" + vars: + SSH_OPTS: '{{if .PRIVATE_KEY_PATH}}-i {{.PRIVATE_KEY_PATH}}{{end}} -o StrictHostKeyChecking=no' + SSH_PREFIX: '{{if not .PRIVATE_KEY_PATH}}sshpass -p "{{.SSH_PASSWORD}}"{{end}}' + cmds: + - '{{.SSH_PREFIX}} ssh {{.SSH_OPTS}} root@{{.DEVICE_IP_ADDRESS}} "rm -rf /mnt/mmc/MUOS/application/Grout"' + - '{{.SSH_PREFIX}} rsync -avz --no-owner --no-group -e "ssh {{.SSH_OPTS}}" build64/muOS/Grout root@{{.DEVICE_IP_ADDRESS}}:/mnt/mmc/MUOS/application/' + # - '{{.SSH_PREFIX}} scp {{.SSH_OPTS}} config-platformless.json root@{{.DEVICE_IP_ADDRESS}}:/mnt/mmc/MUOS/application/Grout/config.json' + silent: true + i18n: desc: Extract messages and find missing translations cmds: diff --git a/tools/save-sync-dry-run/main.go b/tools/save-sync-dry-run/main.go index 82c4de72..ff31bdbe 100644 --- a/tools/save-sync-dry-run/main.go +++ b/tools/save-sync-dry-run/main.go @@ -1,8 +1,6 @@ package main import ( - "encoding/json" - "flag" "fmt" "grout/cache" "grout/internal" @@ -13,11 +11,7 @@ import ( "time" ) -var debug bool - func main() { - flag.BoolVar(&debug, "debug", false, "dump raw API responses for each save") - flag.Parse() config, err := internal.LoadConfig() if err != nil { @@ -48,38 +42,14 @@ func main() { fmt.Printf("Device: %s\n", host.DeviceID) fmt.Println() - localSaves := sync.ScanSaves(config) - fmt.Printf("Local saves found: %d\n", len(localSaves)) - - remoteSaves, err := sync.FetchRemoteSaves(client, localSaves, host.DeviceID) + result, err := sync.ResolveSaveSync(client, config, host.DeviceID) if err != nil { - fmt.Fprintf(os.Stderr, "Failed to fetch remote saves: %v\n", err) + fmt.Fprintf(os.Stderr, "Failed to resolve sync: %v\n", err) os.Exit(1) } - fmt.Printf("ROMs with remote saves: %d\n", len(remoteSaves)) - - if debug { - for romID, saves := range remoteSaves { - fmt.Printf("\n[DEBUG] Saves for rom_id=%d (%d saves):\n", romID, len(saves)) - for _, s := range saves { - dumpJSON(s) - } - } - } - - newSaves := sync.LocalSavesWithoutRemote(localSaves, remoteSaves) - var items []sync.SyncItem - items = append(items, sync.NewSaveUploadActions(newSaves, config)...) - items = append(items, sync.DetermineActions(localSaves, remoteSaves, host.DeviceID, config)...) - - fmt.Println("Scanning for remote-only saves...") - remoteOnly, err := sync.DiscoverRemoteSaves(client, config, localSaves, host.DeviceID) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to discover remote saves: %v\n", err) - os.Exit(1) - } - items = append(items, remoteOnly...) + items := result.Items + fmt.Printf("Session ID: %d\n", result.SessionID) fmt.Printf("Total sync items: %d\n\n", len(items)) if len(items) == 0 { @@ -90,12 +60,6 @@ func main() { printTable(items, host.DeviceID) } -func dumpJSON(v any) { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent(" ", " ") - enc.Encode(v) -} - type row struct { action string rom string diff --git a/ui/save_conflict.go b/ui/save_conflict.go index 49f25fb7..7887167c 100644 --- a/ui/save_conflict.go +++ b/ui/save_conflict.go @@ -13,6 +13,7 @@ type SaveConflictInput struct { Items []sync.SyncItem AllItems []sync.SyncItem // Full items list (passed through for transition) ConflictIndices map[int]int // Conflict index → AllItems index (passed through) + SessionID int // Sync session ID (passed through) } type SaveConflictOutput struct { @@ -20,6 +21,7 @@ type SaveConflictOutput struct { Items []sync.SyncItem AllItems []sync.SyncItem // Passed through from input ConflictIndices map[int]int // Passed through from input + SessionID int // Sync session ID (passed through) } type SaveConflictScreen struct{} @@ -34,6 +36,7 @@ func (s *SaveConflictScreen) Draw(input SaveConflictInput) (SaveConflictOutput, Items: input.Items, AllItems: input.AllItems, ConflictIndices: input.ConflictIndices, + SessionID: input.SessionID, } items := s.buildMenuItems(input.Items) diff --git a/ui/save_sync.go b/ui/save_sync.go index 71e9a44f..65008e3b 100644 --- a/ui/save_sync.go +++ b/ui/save_sync.go @@ -19,12 +19,14 @@ type SaveSyncInput struct { NewSlotName string // If set, upload-only mode for a new slot NewSlotRomID int // ROM ID to upload saves for ResolvedItems []sync.SyncItem // If set, skip resolve phase and execute directly + SessionID int // Sync session ID from negotiate (passed through conflict resolution) } type SaveSyncOutput struct { NeedsConflictResolution bool Items []sync.SyncItem ConflictIndices map[int]int // maps conflict slice index → items slice index + SessionID int // Sync session ID to pass through conflict resolution } type SaveSyncScreen struct{} @@ -40,7 +42,7 @@ func (s *SaveSyncScreen) Execute(input SaveSyncInput) SaveSyncOutput { // If we have resolved items from the conflict screen, skip to execute phase if input.ResolvedItems != nil { - return s.executeSyncPhase(client, config, host.DeviceID, input.ResolvedItems) + return s.executeSyncPhase(client, config, host.DeviceID, input.ResolvedItems, input.SessionID) } // Health check — verify server is reachable before starting sync @@ -58,15 +60,15 @@ func (s *SaveSyncScreen) Execute(input SaveSyncInput) SaveSyncOutput { return s.executeNewSlotUpload(client, config, host.DeviceID, input.NewSlotRomID, input.NewSlotName) } - // Phase 1: Resolve — scan local saves, fetch summaries, determine actions - var items []sync.SyncItem + // Phase 1: Resolve — scan local saves, negotiate with server + var result sync.SyncResult var resolveErr error gaba.ProcessMessage( i18n.Localize(&goi18n.Message{ID: "save_sync_scanning", Other: "Scanning saves..."}, nil), gaba.ProcessMessageOptions{ShowThemeBackground: true}, func() (any, error) { var err error - items, err = sync.ResolveSaveSync(client, config, host.DeviceID) + result, err = sync.ResolveSaveSync(client, config, host.DeviceID) resolveErr = err return nil, nil }, @@ -81,6 +83,8 @@ func (s *SaveSyncScreen) Execute(input SaveSyncInput) SaveSyncOutput { return SaveSyncOutput{} } + items := result.Items + // Slot selection for first-time downloads with multiple slots items = s.resolveMultiSlotDownloads(config, items) @@ -101,14 +105,15 @@ func (s *SaveSyncScreen) Execute(input SaveSyncInput) SaveSyncOutput { NeedsConflictResolution: true, Items: items, ConflictIndices: conflictIndices, + SessionID: result.SessionID, } } // No conflicts — execute directly - return s.executeSyncPhase(client, config, host.DeviceID, items) + return s.executeSyncPhase(client, config, host.DeviceID, items, result.SessionID) } -func (s *SaveSyncScreen) executeSyncPhase(client *romm.Client, config *internal.Config, deviceID string, items []sync.SyncItem) SaveSyncOutput { +func (s *SaveSyncScreen) executeSyncPhase(client *romm.Client, config *internal.Config, deviceID string, items []sync.SyncItem, sessionID int) SaveSyncOutput { var report sync.SyncReport hasActionable := false @@ -129,7 +134,7 @@ func (s *SaveSyncScreen) executeSyncPhase(client *romm.Client, config *internal. Progress: progress, }, func() (any, error) { - report = sync.ExecuteSaveSync(client, config, deviceID, items, func(current, total int) { + report = sync.ExecuteSaveSync(client, config, deviceID, items, sessionID, func(current, total int) { if total > 0 { progress.Store(float64(current) / float64(total)) } @@ -138,7 +143,7 @@ func (s *SaveSyncScreen) executeSyncPhase(client *romm.Client, config *internal. }, ) } else { - report = sync.ExecuteSaveSync(client, config, deviceID, items, nil) + report = sync.ExecuteSaveSync(client, config, deviceID, items, sessionID, nil) } s.showReport(report) @@ -168,7 +173,7 @@ func (s *SaveSyncScreen) executeNewSlotUpload(client *romm.Client, config *inter }) } } - report = sync.ExecuteSaveSync(client, config, deviceID, items, func(current, total int) { + report = sync.ExecuteSaveSync(client, config, deviceID, items, 0, func(current, total int) { if total > 0 { progress.Store(float64(current) / float64(total)) }