From 369dbb418af30d316530f75cd0c0d90129d9a700 Mon Sep 17 00:00:00 2001 From: Klinkertinlegs Date: Sat, 11 Apr 2026 21:28:54 -0400 Subject: [PATCH 1/5] Various fixes and support for save syncing in 5 game handheld mode --- cfw/roms.go | 47 ++++++++ cfw/roms.go:Zone.Identifier | Bin 0 -> 25 bytes internal/config.go | 5 + internal/config.go:Zone.Identifier | Bin 0 -> 25 bytes sync/flow.go | 23 +++- ui/download.go | 154 ++++++++++++++++++++++--- ui/download.go:Zone.Identifier | Bin 0 -> 25 bytes ui/general_settings.go | 16 +++ ui/general_settings.go:Zone.Identifier | Bin 0 -> 25 bytes 9 files changed, 227 insertions(+), 18 deletions(-) create mode 100644 cfw/roms.go:Zone.Identifier create mode 100644 internal/config.go:Zone.Identifier create mode 100644 ui/download.go:Zone.Identifier create mode 100644 ui/general_settings.go:Zone.Identifier diff --git a/cfw/roms.go b/cfw/roms.go index 17b6c47d..9a59d54f 100644 --- a/cfw/roms.go +++ b/cfw/roms.go @@ -16,6 +16,7 @@ import ( type RomScanConfig interface { GetDirectoryMapping(fsSlug string) (relativePath string, ok bool) ResolveRommFSSlug(cfwKey string) string + IsSubfolderPerGame() bool } type LocalRomFile struct { @@ -106,6 +107,52 @@ func scanRomsByPlatform(baseRomDir string, platformMap map[string][]string, conf } } } + } else if currentCFW == MinUI && config != nil && config.IsSubfolderPerGame() { + // 5 Game Handheld mode: ROMs are in /Roms/GameName (TAG)/ top-level folders + entries, err := os.ReadDir(baseRomDir) + if err != nil { + logger.Error("Failed to read ROM directory", "path", baseRomDir, "error", err) + return result + } + + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + + dirName := entry.Name() + tag := stringutil.ParseTag(dirName) + if tag == "" { + continue + } + + // Find which fsSlug this tag belongs to + for fsSlug, cfwDirs := range platformMap { + matched := false + for _, cfwDir := range cfwDirs { + cfwTag := stringutil.ParseTag(cfwDir) + if cfwTag == tag { + matched = true + break + } + } + if !matched { + continue + } + + rommFSSlug := fsSlug + if config != nil { + rommFSSlug = config.ResolveRommFSSlug(fsSlug) + } + + romDir := filepath.Join(baseRomDir, dirName) + roms := scanRomDirectory(rommFSSlug, romDir) + if len(roms) > 0 { + result[rommFSSlug] = append(result[rommFSSlug], roms...) + logger.Debug("Found ROMs for platform (5GH mode)", "fsSlug", rommFSSlug, "dir", dirName, "count", len(roms)) + } + } + } } else { type platformResult struct { fsSlug string diff --git a/cfw/roms.go:Zone.Identifier b/cfw/roms.go:Zone.Identifier new file mode 100644 index 0000000000000000000000000000000000000000..d6c1ec682968c796b9f5e9e080cc6f674b57c766 GIT binary patch literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2xdl#JyUFr831@K2x 0 { logger.Debug("Scanned emulator directory", "path", saveDir, "saveFiles", saveFileCount) } } } } + } logger.Debug("Completed save scan", "matched", len(saves)) return saves @@ -607,10 +611,16 @@ func download(client *romm.Client, config *internal.Config, deviceID string, ite if savePath == "" { saveDir := ResolveSaveDirectory(item.LocalSave.FSSlug, config) if saveDir != "" { - fileName := item.RemoteSave.FileName - if item.LocalSave.RomFileName != "" { + var fileName string + if config != nil && config.SubfolderPerGame && cfw.GetCFW() == cfw.MinUI { + cleanName := stripSaveParentheses(item.LocalSave.RomName) + tag := filepath.Base(saveDir) + fileName = fmt.Sprintf("%s (%s).%s", cleanName, tag, item.RemoteSave.FileExtension) + } else if item.LocalSave.RomFileName != "" { romNameNoExt := strings.TrimSuffix(item.LocalSave.RomFileName, filepath.Ext(item.LocalSave.RomFileName)) fileName = romNameNoExt + "." + item.RemoteSave.FileExtension + } else { + fileName = item.RemoteSave.FileName } savePath = filepath.Join(saveDir, fileName) } @@ -829,6 +839,11 @@ func cleanupBackups(backupDir string, baseName string, limit int) { } } +func stripSaveParentheses(name string) string { + re := regexp.MustCompile(`\s*\([^)]*\)`) + return strings.TrimSpace(re.ReplaceAllString(name, "")) +} + func ResolveSaveDirectory(fsSlug string, config *internal.Config) string { if config != nil && config.SaveDirectoryMappings != nil { if mapped, ok := config.SaveDirectoryMappings[fsSlug]; ok && mapped != "" { diff --git a/ui/download.go b/ui/download.go index 56e82413..73f3b51e 100644 --- a/ui/download.go +++ b/ui/download.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "grout/cfw" + "grout/cfw/minui" "grout/cfw/muos" "grout/internal" "grout/internal/artutil" @@ -19,6 +20,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "slices" "strconv" "strings" @@ -234,7 +236,21 @@ func (s *DownloadScreen) draw(input DownloadInput) (DownloadOutput, error) { ext := strings.ToLower(filepath.Ext(g.Files[0].FileName)) if ext == ".zip" || ext == ".7z" { romDirectory := input.Config.GetPlatformRomDirectory(gamePlatform) - archivePath := filepath.Join(romDirectory, g.Files[0].FileName) + var archivePath string + var extractDir string + var cleanName string + var gameFolder string + + if input.Config.SubfolderPerGame && cfw.GetCFW() == cfw.MinUI { + tag := extractPlatformTag(romDirectory) + cleanName = stripParentheses(g.FsNameNoExt) + gameFolder = fmt.Sprintf("%s (%s)", cleanName, tag) + extractDir = filepath.Join(minui.GetRomDirectory(), gameFolder) + archivePath = filepath.Join(extractDir, g.Files[0].FileName) + } else { + extractDir = romDirectory + archivePath = filepath.Join(romDirectory, g.Files[0].FileName) + } progress := &atomic.Float64{} _, err := gaba.ProcessMessage( @@ -252,12 +268,12 @@ func (s *DownloadScreen) draw(input DownloadInput) (DownloadOutput, error) { if ext == ".7z" { archiveFiles, extractErr = fileutil.SevenZipFileNames(archivePath) if extractErr == nil { - extractErr = fileutil.Un7zip(archivePath, romDirectory, progress) + extractErr = fileutil.Un7zip(archivePath, extractDir, progress) } } else { archiveFiles, extractErr = fileutil.ZipFileNames(archivePath) if extractErr == nil { - extractErr = fileutil.Unzip(archivePath, romDirectory, progress) + extractErr = fileutil.Unzip(archivePath, extractDir, progress) } } @@ -280,10 +296,33 @@ func (s *DownloadScreen) draw(input DownloadInput) (DownloadOutput, error) { } } } - for i, entry := range gamelistEntries { - if entry.Game.ID == g.ID { - gamelistEntries[i].GamePath = filepath.Join(romDirectory, gamePath) - break + + // Write m3u and rename extracted file if SubfolderPerGame + if input.Config.SubfolderPerGame && cfw.GetCFW() == cfw.MinUI { + realExt := filepath.Ext(gamePath) + cleanFileName := cleanName + realExt + oldPath := filepath.Join(extractDir, gamePath) + newPath := filepath.Join(extractDir, cleanFileName) + if err := os.Rename(oldPath, newPath); err != nil { + logger.Warn("Failed to rename extracted ROM", "from", oldPath, "to", newPath, "error", err) + cleanFileName = gamePath // fallback to original name + } + m3uPath := filepath.Join(extractDir, gameFolder+".m3u") + if err := os.WriteFile(m3uPath, []byte(cleanFileName), 0644); err != nil { + logger.Warn("Failed to write m3u file", "path", m3uPath, "error", err) + } + for i, entry := range gamelistEntries { + if entry.Game.ID == g.ID { + gamelistEntries[i].GamePath = newPath + break + } + } + } else { + for i, entry := range gamelistEntries { + if entry.Game.ID == g.ID { + gamelistEntries[i].GamePath = filepath.Join(romDirectory, gamePath) + break + } } } } @@ -301,6 +340,54 @@ func (s *DownloadScreen) draw(input DownloadInput) (DownloadOutput, error) { } } + // Write m3u for SubfolderPerGame when not unzipping + if input.Config.SubfolderPerGame && !input.Config.UnzipDownloads && cfw.GetCFW() == cfw.MinUI { + for _, g := range input.SelectedGames { + if g.HasMultipleFiles { + continue + } + + completed := slices.ContainsFunc(res.Completed, func(d gaba.Download) bool { + return d.DisplayName == g.Name + }) + if !completed { + continue + } + + if len(g.Files) == 0 { + continue + } + + ext := strings.ToLower(filepath.Ext(g.Files[0].FileName)) + + gamePlatform := input.Platform + if input.Platform.ID == 0 && g.PlatformID != 0 { + gamePlatform = romm.Platform{ + ID: g.PlatformID, + FSSlug: g.PlatformFSSlug, + Name: g.PlatformDisplayName, + } + } + + romDirectory := input.Config.GetPlatformRomDirectory(gamePlatform) + tag := extractPlatformTag(romDirectory) + cleanName := stripParentheses(g.FsNameNoExt) + gameFolder := fmt.Sprintf("%s (%s)", cleanName, tag) + subdir := filepath.Join(minui.GetRomDirectory(), gameFolder) + + if err := os.MkdirAll(subdir, 0755); err != nil { + logger.Warn("Failed to create game subfolder for m3u", "dir", subdir, "error", err) + continue + } + + romFileName := cleanName + ext + m3uPath := filepath.Join(subdir, gameFolder+".m3u") + if err := os.WriteFile(m3uPath, []byte(romFileName), 0644); err != nil { + logger.Warn("Failed to write m3u file", "path", m3uPath, "error", err) + } + } + } + downloadedGames := make([]romm.Rom, 0, len(res.Completed)) for _, g := range input.SelectedGames { if slices.ContainsFunc(res.Completed, func(d gaba.Download) bool { @@ -378,7 +465,20 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host, } } } - downloadLocation = filepath.Join(romDirectory, fileToDownload.FileName) + if config.SubfolderPerGame && cfw.GetCFW() == cfw.MinUI { + tag := extractPlatformTag(romDirectory) + cleanName := stripParentheses(g.FsNameNoExt) + gameFolder := fmt.Sprintf("%s (%s)", cleanName, tag) + subdir := filepath.Join(minui.GetRomDirectory(), gameFolder) + if err := os.MkdirAll(subdir, 0755); err != nil { + gaba.GetLogger().Warn("Failed to create game subfolder", "dir", subdir, "error", err) + } + ext := filepath.Ext(fileToDownload.FileName) + cleanFileName := cleanName + ext + downloadLocation = filepath.Join(subdir, cleanFileName) + } else { + downloadLocation = filepath.Join(romDirectory, fileToDownload.FileName) + } sourceURL, _ = url.JoinPath(host.URL(), "/api/roms/", strconv.Itoa(g.ID), "content", fileToDownload.FileName) sourceURL += "?" + url.Values{"file_ids": {strconv.Itoa(fileToDownload.ID)}}.Encode() } @@ -395,7 +495,12 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host, if config.DownloadArt && (g.PathCoverLarge != "" || g.PathCoverSmall != "" || g.URLCover != "") { // Prepare download for cover art artDir := config.GetArtDirectory(gamePlatform) - artFileName := g.FsNameNoExt + ".png" + var artFileName string + if cfw.GetCFW() == cfw.MinUI && len(g.Files) > 0 { + artFileName = g.Files[0].FileName + ".png" + } else { + artFileName = g.FsNameNoExt + ".png" + } artLocation := filepath.Join(artDir, artFileName) coverURL := g.GetArtworkURL(config.ArtKind, host) @@ -452,7 +557,7 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host, artMarqueeDir := config.GetArtMarqueeDirectory(gamePlatform) if config.AdditionalDownloads.Marquee != artutil.ArtKindNone && artMarqueeDir != "" { marqueeArtFileName := g.FsNameNoExt - // is cfw is ES based, use -marquee suffix to avoid conflicts with cover art + // if cfw is ES based, use -marquee suffix to avoid conflicts with cover art if cfw.GetCFW().IsBasedOnEmulationStation() { marqueeArtFileName += "-marquee.png" } else { @@ -493,7 +598,14 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host, artBezelDir := config.GetArtBezelDirectory(gamePlatform) if config.AdditionalDownloads.Bezel && artBezelDir != "" { - bezelArtLocation := filepath.Join(artBezelDir, artFileName) + bezelArtFileName := g.FsNameNoExt + // if cfw is ES based, use -bezel suffix to avoid conflicts with cover art + if cfw.GetCFW().IsBasedOnEmulationStation() { + bezelArtFileName += "-bezel.png" + } else { + bezelArtFileName += ".png" + } + bezelArtLocation := filepath.Join(artBezelDir, bezelArtFileName) if bezelURL := g.GetBezelURL(host); bezelURL != "" { gamelistRomEntry.ArtLocation.BezelPath = bezelArtLocation artDownloads = append(artDownloads, artDownload{ @@ -522,7 +634,7 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host, boxbackDir := config.GetBoxbackDirectory(gamePlatform) if config.AdditionalDownloads.BoxBack && boxbackDir != "" { boxbackArtFileName := g.FsNameNoExt - // is cfw is ES based, use -boxback suffix to avoid conflicts with cover art + // if cfw is ES based, use -boxback suffix to avoid conflicts with cover art if cfw.GetCFW().IsBasedOnEmulationStation() { boxbackArtFileName += "-boxback.png" } else { @@ -543,7 +655,7 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host, fanartDir := config.GetFanartDirectory(gamePlatform) if config.AdditionalDownloads.Fanart && fanartDir != "" { fanartFileName := g.FsNameNoExt - // is cfw is ES based, use -fanart suffix to avoid conflicts with cover art + // if cfw is ES based, use -fanart suffix to avoid conflicts with cover art if cfw.GetCFW().IsBasedOnEmulationStation() { fanartFileName += "-fanart.png" } else { @@ -560,7 +672,6 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host, }) } } - } gamesSummaries = append(gamesSummaries, gamelistRomEntry) } @@ -568,6 +679,21 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host, return downloads, artDownloads, gamesSummaries } +func stripParentheses(name string) string { + re := regexp.MustCompile(`\s*\([^)]*\)`) + return strings.TrimSpace(re.ReplaceAllString(name, "")) +} + +func extractPlatformTag(romDirectory string) string { + base := filepath.Base(romDirectory) + start := strings.LastIndex(base, "(") + end := strings.LastIndex(base, ")") + if start >= 0 && end > start { + return base[start+1 : end] + } + return base +} + func (s *DownloadScreen) downloadArt(artDownloads []artDownload, downloadedGames []romm.Rom, headers map[string]string, progress *atomic.Float64, insecureSkipVerify bool) { logger := gaba.GetLogger() diff --git a/ui/download.go:Zone.Identifier b/ui/download.go:Zone.Identifier new file mode 100644 index 0000000000000000000000000000000000000000..d6c1ec682968c796b9f5e9e080cc6f674b57c766 GIT binary patch literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2xdl#JyUFr831@K2x Date: Wed, 15 Apr 2026 17:50:34 -0400 Subject: [PATCH 2/5] Delete cfw/roms.go:Zone.Identifier --- cfw/roms.go:Zone.Identifier | Bin 25 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 cfw/roms.go:Zone.Identifier diff --git a/cfw/roms.go:Zone.Identifier b/cfw/roms.go:Zone.Identifier deleted file mode 100644 index d6c1ec682968c796b9f5e9e080cc6f674b57c766..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2x Date: Wed, 15 Apr 2026 17:50:53 -0400 Subject: [PATCH 3/5] Delete internal/config.go:Zone.Identifier --- internal/config.go:Zone.Identifier | Bin 25 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 internal/config.go:Zone.Identifier diff --git a/internal/config.go:Zone.Identifier b/internal/config.go:Zone.Identifier deleted file mode 100644 index d6c1ec682968c796b9f5e9e080cc6f674b57c766..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2x Date: Wed, 15 Apr 2026 17:51:11 -0400 Subject: [PATCH 4/5] Delete ui/download.go:Zone.Identifier --- ui/download.go:Zone.Identifier | Bin 25 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 ui/download.go:Zone.Identifier diff --git a/ui/download.go:Zone.Identifier b/ui/download.go:Zone.Identifier deleted file mode 100644 index d6c1ec682968c796b9f5e9e080cc6f674b57c766..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2x Date: Wed, 15 Apr 2026 17:51:26 -0400 Subject: [PATCH 5/5] Delete ui/general_settings.go:Zone.Identifier --- ui/general_settings.go:Zone.Identifier | Bin 25 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 ui/general_settings.go:Zone.Identifier diff --git a/ui/general_settings.go:Zone.Identifier b/ui/general_settings.go:Zone.Identifier deleted file mode 100644 index d6c1ec682968c796b9f5e9e080cc6f674b57c766..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2x