diff --git a/cfw/roms.go b/cfw/roms.go index 17b6c47..9a59d54 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/internal/config.go b/internal/config.go index 71057e9..c9a8739 100644 --- a/internal/config.go +++ b/internal/config.go @@ -77,6 +77,7 @@ type Config struct { AdditionalDownloads AdditionalDownloads `json:"additional_downloads,omitempty"` SwapFaceButtons bool `json:"swap_face_buttons,omitempty"` + SubfolderPerGame bool `json:"subfolder_per_game,omitempty"` PlatformOrder []string `json:"platform_order,omitempty"` SaveDirectoryMappings map[string]string `json:"save_directory_mappings,omitempty"` SlotPreferences map[string]string `json:"-"` // Stored in save_slots.json, not config.json @@ -220,6 +221,10 @@ func SaveConfig(config *Config) error { return nil } +func (c Config) IsSubfolderPerGame() bool { + return c.SubfolderPerGame +} + func InitKidMode(config *Config) { kidModeEnabled.Store(config.KidMode) } diff --git a/sync/flow.go b/sync/flow.go index 5f34bf2..987631f 100644 --- a/sync/flow.go +++ b/sync/flow.go @@ -10,6 +10,7 @@ import ( "grout/version" "os" "path/filepath" + "regexp" "sort" "strings" gosync "sync" @@ -182,6 +183,10 @@ func ScanSaves(config *internal.Config) []LocalSave { nameNoExt := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) rom, err := cm.GetRomByFSLookup(rommFSSlug, nameNoExt) + if err != nil && config != nil && config.SubfolderPerGame && cfw.GetCFW() == cfw.MinUI { + cleanName := stripSaveParentheses(nameNoExt) + rom, err = cm.GetRomByNameLookup(rommFSSlug, cleanName) + } if err != nil { logger.Debug("No cache match for save file", "file", entry.Name(), "fsSlug", rommFSSlug, "nameNoExt", nameNoExt) continue @@ -197,14 +202,13 @@ func ScanSaves(config *internal.Config) []LocalSave { 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 @@ -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 230ab53..6d65349 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() } @@ -456,7 +556,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 { @@ -497,7 +597,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{ @@ -526,7 +633,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 { @@ -547,7 +654,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 { @@ -564,7 +671,6 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host, }) } } - } gamesSummaries = append(gamesSummaries, gamelistRomEntry) } @@ -572,6 +678,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/general_settings.go b/ui/general_settings.go index a684f58..28f70c8 100644 --- a/ui/general_settings.go +++ b/ui/general_settings.go @@ -67,6 +67,8 @@ func (s *GeneralSettingsScreen) buildMenuItems(config *internal.Config) []gaba.I c := cfw.GetCFW() isMuOS := c == cfw.MuOS isESBasedOS := c == cfw.Knulli || c == cfw.ROCKNIX + isMinUI := atomic.Bool{} + isMinUI.Store(c == cfw.MinUI) showArtKind := atomic.Bool{} showArtKind.Store(config.DownloadArt) displayDownloadArtPreview := atomic.Bool{} @@ -106,6 +108,15 @@ func (s *GeneralSettingsScreen) buildMenuItems(config *internal.Config) []gaba.I }, SelectedOption: boolToIndex(!config.UnzipDownloads), }, + { + Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_subfolder_per_game", Other: "5 Game Handheld"}, nil)}, + Options: []gaba.Option{ + {DisplayName: i18n.Localize(&goi18n.Message{ID: "common_false", Other: "False"}, nil), Value: false}, + {DisplayName: i18n.Localize(&goi18n.Message{ID: "common_true", Other: "True"}, nil), Value: true}, + }, + SelectedOption: boolToIndex(config.SubfolderPerGame), + VisibleWhen: &isMinUI, + }, { Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_download_art", Other: "Download Art"}, nil)}, Options: []gaba.Option{ @@ -271,6 +282,11 @@ func (s *GeneralSettingsScreen) applySettings(config *internal.Config, items []g config.UnzipDownloads = val } + case i18n.Localize(&goi18n.Message{ID: "settings_subfolder_per_game", Other: "5 Game Handheld"}, nil): + if val, ok := item.Options[item.SelectedOption].Value.(bool); ok { + config.SubfolderPerGame = val + } + case i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_marquee", Other: "Download Marquee Image"}, nil): if val, ok := item.Options[item.SelectedOption].Value.(artutil.ArtKind); ok { config.AdditionalDownloads.Marquee = val