Caching work and fixing save sync

This commit is contained in:
Brandon T. Kowalski
2026-01-06 18:53:43 -05:00
parent 4d0c47ec17
commit fb4a027e66
33 changed files with 471 additions and 1728 deletions
+20 -85
View File
@@ -1,7 +1,6 @@
package main
import (
"grout/artwork"
"grout/cache"
"grout/cfw"
"grout/constants"
@@ -44,7 +43,6 @@ const (
saveSyncSettings gaba.StateName = "save_sync_settings"
info gaba.StateName = "info"
logoutConfirmation gaba.StateName = "logout_confirmation"
clearCacheConfirmation gaba.StateName = "clear_cache_confirmation"
refreshCache gaba.StateName = "refresh_cache"
saveSync gaba.StateName = "save_sync"
biosDownload gaba.StateName = "bios_download"
@@ -124,12 +122,7 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui
}
// Validate artwork cache in background
go artwork.ValidateCache()
// Index any existing artwork files
if cm := cache.GetCacheManager(); cm != nil {
go cm.ScanAndIndexArtwork()
}
cache.RunArtworkValidation()
gaba.AddState(fsm, platformSelection, func(ctx *gaba.Context) (ui.PlatformSelectionOutput, gaba.ExitCode) {
platforms, _ := gaba.Get[[]romm.Platform](ctx)
@@ -562,6 +555,7 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui
}).
On(constants.ExitCodeGeneralSettings, generalSettings).
On(constants.ExitCodeCollectionsSettings, collectionsSettings).
On(constants.ExitCodeEditMappings, settingsPlatformMapping).
On(constants.ExitCodeAdvancedSettings, advancedSettings).
On(constants.ExitCodeSaveSyncSettings, saveSyncSettings).
On(constants.ExitCodeInfo, info).
@@ -665,7 +659,6 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui
}).
On(gaba.ExitCodeSuccess, settings).
On(constants.ExitCodeEditMappings, settingsPlatformMapping).
On(constants.ExitCodeClearCache, clearCacheConfirmation).
On(constants.ExitCodeRefreshCache, refreshCache).
On(constants.ExitCodeSyncArtwork, artworkSync).
On(gaba.ExitCodeBack, settings)
@@ -744,6 +737,12 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui
config, _ := gaba.Get[*internal.Config](ctx)
currentCFW, _ := gaba.Get[cfw.CFW](ctx)
// Delete the entire cache folder on logout
if err := cache.DeleteCacheFolder(); err != nil {
gaba.GetLogger().Error("Failed to delete cache folder", "error", err)
// Continue with logout even if cache deletion fails
}
config.Hosts = nil
config.DirectoryMappings = nil
config.PlatformOrder = nil
@@ -787,6 +786,11 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui
}
}
// Re-initialize cache manager for the new host
if err := cache.InitCacheManager(config.Hosts[0], config); err != nil {
gaba.GetLogger().Error("Failed to initialize cache manager after re-login", "error", err)
}
platforms, err := internal.GetMappedPlatforms(config.Hosts[0], config.DirectoryMappings, config.ApiTimeout)
if err != nil {
gaba.GetLogger().Error("Failed to load platforms after re-login", "error", err)
@@ -794,44 +798,8 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui
}
gaba.Set(ctx, platforms)
nav, _ := gaba.Get[*NavState](ctx)
nav.ResetGameList()
nav.PlatformListPos = ListPosition{}
return nil
})
gaba.AddState(fsm, clearCacheConfirmation, func(ctx *gaba.Context) (ui.ClearCacheOutput, gaba.ExitCode) {
screen := ui.NewClearCacheScreen()
result, err := screen.Draw()
if err != nil {
return ui.ClearCacheOutput{}, gaba.ExitCodeError
}
return result.Value, result.ExitCode
}).
On(gaba.ExitCodeBack, advancedSettings).
OnWithHook(gaba.ExitCodeSuccess, advancedSettings, func(ctx *gaba.Context) error {
logger := gaba.GetLogger()
// If games cache was cleared, re-fetch platforms and re-populate
cm := cache.GetCacheManager()
if cm != nil && cm.IsFirstRun() {
host, _ := gaba.Get[romm.Host](ctx)
config, _ := gaba.Get[*internal.Config](ctx)
// Fetch fresh platform list from API
platforms, err := internal.GetMappedPlatforms(host, config.DirectoryMappings, config.ApiTimeout)
if err != nil {
logger.Error("Failed to fetch platforms after cache clear", "error", err)
return nil // Don't fail, just log
}
// Update platforms in context
gaba.Set(ctx, platforms)
// Re-populate cache with progress
// Populate cache for the new login
if cm := cache.GetCacheManager(); cm != nil && cm.IsFirstRun() {
progress := uatomic.NewFloat64(0)
gaba.ProcessMessage(
i18n.Localize(&goi18n.Message{ID: "cache_building", Other: "Building cache..."}, nil),
@@ -844,10 +812,12 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui
return nil, cm.PopulateFullCacheWithProgress(platforms, progress)
},
)
logger.Info("Cache rebuilt after clear")
}
nav, _ := gaba.Get[*NavState](ctx)
nav.ResetGameList()
nav.PlatformListPos = ListPosition{}
return nil
})
@@ -876,7 +846,6 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui
refreshGames := false
refreshCollections := false
refreshArtwork := false
for _, t := range result.SelectedTypes {
switch t {
@@ -884,8 +853,6 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui
refreshGames = true
case ui.RefreshCacheCollections:
refreshCollections = true
case ui.RefreshCacheArtwork:
refreshArtwork = true
}
}
@@ -900,11 +867,6 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui
logger.Error("Failed to clear collections cache", "error", err)
}
}
if refreshArtwork {
if err := cm.ClearArtwork(); err != nil {
logger.Error("Failed to clear artwork cache", "error", err)
}
}
// If we need to refresh games or collections, re-populate
if refreshGames || refreshCollections {
@@ -933,36 +895,9 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui
)
}
// If only refreshing artwork, sync it in background
if refreshArtwork && !refreshGames && !refreshCollections {
platforms, _ := gaba.Get[[]romm.Platform](ctx)
go func() {
for _, p := range platforms {
games, err := cm.GetPlatformGames(p.ID)
if err == nil {
artwork.SyncInBackground(host, games)
}
}
// Record artwork refresh time after sync completes
cm.RecordRefreshTime(cache.MetaKeyArtworkRefreshedAt)
}()
gaba.ProcessMessage(
i18n.Localize(&goi18n.Message{ID: "artwork_sync_started", Other: "Artwork sync started in background"}, nil),
gaba.ProcessMessageOptions{ShowThemeBackground: true},
func() (interface{}, error) {
return nil, nil
},
)
} else if refreshArtwork {
// Record artwork refresh time (it was cleared and will be re-downloaded on demand)
cm.RecordRefreshTime(cache.MetaKeyArtworkRefreshedAt)
}
logger.Info("Cache refresh completed",
"games", refreshGames,
"collections", refreshCollections,
"artwork", refreshArtwork)
"collections", refreshCollections)
return nil
})
-94
View File
@@ -1,94 +0,0 @@
package artwork
import (
"grout/cache"
"grout/internal/fileutil"
"os"
"path/filepath"
"strconv"
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
)
func GetCacheDir() string {
return cache.GetArtworkCacheDir()
}
func ClearCache() error {
// Clear SQLite metadata
if cm := cache.GetCacheManager(); cm != nil {
if err := cm.ClearArtwork(); err != nil {
gaba.GetLogger().Debug("Failed to clear artwork metadata", "error", err)
}
}
// Clear files from disk
cacheDir := GetCacheDir()
if !fileutil.FileExists(cacheDir) {
return nil
}
return os.RemoveAll(cacheDir)
}
func HasCache() bool {
// Check SQLite first for speed
if cm := cache.GetCacheManager(); cm != nil {
if cm.GetArtworkCount() > 0 {
return true
}
}
// Fall back to filesystem check
cacheDir := GetCacheDir()
entries, err := os.ReadDir(cacheDir)
if err != nil {
return false
}
return len(entries) > 0
}
func GetCachePath(platformFSSlug string, romID int) string {
return filepath.Join(GetCacheDir(), platformFSSlug, strconv.Itoa(romID)+".png")
}
func Exists(platformFSSlug string, romID int) bool {
// Check SQLite metadata first (fast)
if cm := cache.GetCacheManager(); cm != nil {
if cm.IsArtworkCached(platformFSSlug, romID) {
return true
}
}
// Fall back to filesystem check
return fileutil.FileExists(GetCachePath(platformFSSlug, romID))
}
func EnsureCacheDir(platformFSSlug string) error {
dir := filepath.Join(GetCacheDir(), platformFSSlug)
return os.MkdirAll(dir, 0755)
}
// MarkCached records that artwork has been cached for a ROM
func MarkCached(platformFSSlug string, romID int) {
if cm := cache.GetCacheManager(); cm != nil {
path := GetCachePath(platformFSSlug, romID)
if err := cm.MarkArtworkCached(platformFSSlug, romID, path); err != nil {
gaba.GetLogger().Debug("Failed to mark artwork cached", "error", err)
}
}
}
func ValidateCache() {
if cm := cache.GetCacheManager(); cm != nil {
go func() {
removed, err := cm.ValidateArtworkCache()
if err != nil {
gaba.GetLogger().Debug("Failed to validate artwork cache", "error", err)
return
}
if removed > 0 {
gaba.GetLogger().Debug("Removed invalid artwork entries", "count", removed)
}
}()
}
}
-203
View File
@@ -1,203 +0,0 @@
package artwork
import (
"fmt"
"grout/constants"
"grout/internal/imageutil"
"grout/romm"
"image/png"
"io"
"net/http"
"os"
"strings"
"time"
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
)
// GetWithArtwork returns all ROMs that have artwork URLs available
func GetWithArtwork(roms []romm.Rom) []romm.Rom {
var withArtwork []romm.Rom
for _, rom := range roms {
if HasURL(rom) {
withArtwork = append(withArtwork, rom)
}
}
return withArtwork
}
func GetMissing(roms []romm.Rom) []romm.Rom {
var missing []romm.Rom
for _, rom := range roms {
if !HasURL(rom) {
continue
}
if !Exists(rom.PlatformFSSlug, rom.ID) {
missing = append(missing, rom)
}
}
return missing
}
func CheckRemoteLastModified(url string, authHeader string) (time.Time, error) {
req, err := http.NewRequest("HEAD", url, nil)
if err != nil {
return time.Time{}, err
}
if authHeader != "" {
req.Header.Set("Authorization", authHeader)
}
client := &http.Client{Timeout: 10 * constants.DefaultHTTPTimeout}
resp, err := client.Do(req)
if err != nil {
return time.Time{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return time.Time{}, fmt.Errorf("bad status: %s", resp.Status)
}
lastModified := resp.Header.Get("Last-Modified")
if lastModified == "" {
return time.Time{}, nil
}
return http.ParseTime(lastModified)
}
func NeedsUpdate(rom romm.Rom, host romm.Host) bool {
cachePath := GetCachePath(rom.PlatformFSSlug, rom.ID)
localInfo, err := os.Stat(cachePath)
if err != nil {
return true
}
coverPath := GetCoverPath(rom)
if coverPath == "" {
return false
}
artURL := host.URL() + coverPath
artURL = strings.ReplaceAll(artURL, " ", "%20")
remoteModTime, err := CheckRemoteLastModified(artURL, host.BasicAuthHeader())
if err != nil || remoteModTime.IsZero() {
return false // On error or no Last-Modified header, skip re-download
}
return remoteModTime.After(localInfo.ModTime())
}
func GetOutdated(roms []romm.Rom, host romm.Host) []romm.Rom {
var outdated []romm.Rom
for _, rom := range roms {
if !HasURL(rom) {
continue
}
if Exists(rom.PlatformFSSlug, rom.ID) && NeedsUpdate(rom, host) {
outdated = append(outdated, rom)
}
}
return outdated
}
func HasURL(rom romm.Rom) bool {
return rom.PathCoverSmall != "" || rom.PathCoverLarge != "" || rom.URLCover != ""
}
func GetCoverPath(rom romm.Rom) string {
if rom.PathCoverSmall != "" {
return rom.PathCoverSmall
}
if rom.PathCoverLarge != "" {
return rom.PathCoverLarge
}
return rom.URLCover
}
func DownloadAndCache(rom romm.Rom, host romm.Host) error {
logger := gaba.GetLogger()
coverPath := GetCoverPath(rom)
if coverPath == "" {
return nil // No artwork available
}
if err := EnsureCacheDir(rom.PlatformFSSlug); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
cachePath := GetCachePath(rom.PlatformFSSlug, rom.ID)
artURL := host.URL() + coverPath
artURL = strings.ReplaceAll(artURL, " ", "%20")
req, err := http.NewRequest("GET", artURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", host.BasicAuthHeader())
client := &http.Client{Timeout: constants.DefaultClientTimeout}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download artwork: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
outFile, err := os.Create(cachePath)
if err != nil {
return fmt.Errorf("failed to create cache file: %w", err)
}
defer outFile.Close()
if _, err = io.Copy(outFile, resp.Body); err != nil {
os.Remove(cachePath)
return fmt.Errorf("failed to write cache file: %w", err)
}
outFile.Close()
if err := imageutil.ProcessArtImage(cachePath); err != nil {
logger.Warn("Failed to process artwork image", "path", cachePath, "error", err)
os.Remove(cachePath)
return fmt.Errorf("failed to process artwork: %w", err)
}
file, err := os.Open(cachePath)
if err != nil {
return fmt.Errorf("failed to open processed artwork: %w", err)
}
_, err = png.DecodeConfig(file)
file.Close()
if err != nil {
os.Remove(cachePath)
return fmt.Errorf("processed artwork is not a valid PNG: %w", err)
}
// Record in SQLite metadata
MarkCached(rom.PlatformFSSlug, rom.ID)
return nil
}
func SyncInBackground(host romm.Host, games []romm.Rom) {
logger := gaba.GetLogger()
missing := GetMissing(games)
if len(missing) == 0 {
return
}
for _, rom := range missing {
if err := DownloadAndCache(rom, host); err != nil {
logger.Debug("Failed to download artwork", "rom", rom.Name, "error", err)
}
}
}
+214 -289
View File
@@ -1,177 +1,95 @@
package cache
import (
"database/sql"
"fmt"
"grout/constants"
"grout/internal/fileutil"
"grout/internal/imageutil"
"grout/romm"
"image/png"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
)
func GetArtworkCachePath(fsSlug string, romID int) string {
return filepath.Join(GetArtworkCacheDir(), fsSlug, strconv.Itoa(romID)+".png")
func GetArtworkCachePath(platformFSSlug string, romID int) string {
return filepath.Join(GetArtworkCacheDir(), platformFSSlug, strconv.Itoa(romID)+".png")
}
func (cm *CacheManager) IsArtworkCached(fsSlug string, romID int) bool {
if cm == nil || !cm.initialized {
// Fallback to file check
return fileutil.FileExists(GetArtworkCachePath(fsSlug, romID))
}
cm.mu.RLock()
defer cm.mu.RUnlock()
var count int
err := cm.db.QueryRow(`
SELECT COUNT(*) FROM artwork_metadata
WHERE platform_fs_slug = ? AND rom_id = ?
`, fsSlug, romID).Scan(&count)
if err != nil || count == 0 {
// No metadata, but check if file exists (for legacy compatibility)
return fileutil.FileExists(GetArtworkCachePath(fsSlug, romID))
}
return fileutil.FileExists(GetArtworkCachePath(fsSlug, romID))
func ArtworkExists(platformFSSlug string, romID int) bool {
return fileutil.FileExists(GetArtworkCachePath(platformFSSlug, romID))
}
func (cm *CacheManager) GetArtworkPath(fsSlug string, romID int) string {
return GetArtworkCachePath(fsSlug, romID)
}
func (cm *CacheManager) MarkArtworkCached(fsSlug string, romID int, filePath string) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
logger := gaba.GetLogger()
cm.mu.Lock()
defer cm.mu.Unlock()
var size int64
if info, err := os.Stat(filePath); err == nil {
size = info.Size()
}
_, err := cm.db.Exec(`
INSERT OR REPLACE INTO artwork_metadata
(platform_fs_slug, rom_id, file_path, file_size_bytes, cached_at, validated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, fsSlug, romID, filePath, size)
if err != nil {
logger.Debug("Failed to mark artwork cached", "fsSlug", fsSlug, "romID", romID, "error", err)
return newCacheError("save", "artwork", strconv.Itoa(romID), err)
}
return nil
}
func (cm *CacheManager) RemoveArtworkMetadata(fsSlug string, romID int) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
cm.mu.Lock()
defer cm.mu.Unlock()
_, err := cm.db.Exec(`
DELETE FROM artwork_metadata
WHERE platform_fs_slug = ? AND rom_id = ?
`, fsSlug, romID)
if err != nil {
return newCacheError("delete", "artwork", strconv.Itoa(romID), err)
}
return nil
}
func (cm *CacheManager) ValidateArtworkCache() (int, error) {
if cm == nil || !cm.initialized {
return 0, ErrNotInitialized
}
logger := gaba.GetLogger()
cm.mu.RLock()
rows, err := cm.db.Query(`
SELECT platform_fs_slug, rom_id, file_path FROM artwork_metadata
`)
cm.mu.RUnlock()
if err != nil {
return 0, newCacheError("validate", "artwork", "", err)
}
defer rows.Close()
type toRemove struct {
fsSlug string
romID int
path string
}
var removeList []toRemove
for rows.Next() {
var fsSlug string
var romID int
var filePath string
if err := rows.Scan(&fsSlug, &romID, &filePath); err != nil {
continue
}
if !fileutil.FileExists(filePath) {
removeList = append(removeList, toRemove{fsSlug, romID, filePath})
continue
}
if !isValidPNG(filePath) {
removeList = append(removeList, toRemove{fsSlug, romID, filePath})
}
}
cm.mu.Lock()
for _, item := range removeList {
cm.db.Exec(`
DELETE FROM artwork_metadata
WHERE platform_fs_slug = ? AND rom_id = ?
`, item.fsSlug, item.romID)
os.Remove(item.path)
}
cm.mu.Unlock()
if len(removeList) > 0 {
logger.Debug("Removed invalid artwork entries", "count", len(removeList))
}
return len(removeList), nil
}
func (cm *CacheManager) GetArtworkCount() int {
if cm == nil || !cm.initialized {
return 0
}
cm.mu.RLock()
defer cm.mu.RUnlock()
var count int
cm.db.QueryRow(`SELECT COUNT(*) FROM artwork_metadata`).Scan(&count)
return count
}
func EnsureArtworkCacheDir(fsSlug string) error {
dir := filepath.Join(GetArtworkCacheDir(), fsSlug)
func EnsureArtworkCacheDir(platformFSSlug string) error {
dir := filepath.Join(GetArtworkCacheDir(), platformFSSlug)
return os.MkdirAll(dir, 0755)
}
func (cm *Manager) ValidateArtworkCache() (int, error) {
logger := gaba.GetLogger()
cacheDir := GetArtworkCacheDir()
removed := 0
platformDirs, err := os.ReadDir(cacheDir)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
for _, platformDir := range platformDirs {
if !platformDir.IsDir() {
continue
}
platformPath := filepath.Join(cacheDir, platformDir.Name())
files, err := os.ReadDir(platformPath)
if err != nil {
continue
}
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".png" {
continue
}
filePath := filepath.Join(platformPath, file.Name())
if !isValidPNG(filePath) {
os.Remove(filePath)
removed++
}
}
}
if removed > 0 {
logger.Debug("Removed invalid artwork files", "count", removed)
}
return removed, nil
}
func RunArtworkValidation() {
if cm := GetCacheManager(); cm != nil {
go func() {
removed, err := cm.ValidateArtworkCache()
if err != nil {
gaba.GetLogger().Debug("Failed to validate artwork cache", "error", err)
return
}
if removed > 0 {
gaba.GetLogger().Debug("Removed invalid artwork files", "count", removed)
}
}()
}
}
func isValidPNG(path string) bool {
f, err := os.Open(path)
if err != nil {
@@ -183,165 +101,172 @@ func isValidPNG(path string) bool {
return err == nil
}
func (cm *CacheManager) ScanAndIndexArtwork() (int, error) {
if cm == nil || !cm.initialized {
return 0, ErrNotInitialized
func GetRomsWithArtwork(roms []romm.Rom) []romm.Rom {
var withArtwork []romm.Rom
for _, rom := range roms {
if HasArtworkURL(rom) {
withArtwork = append(withArtwork, rom)
}
}
return withArtwork
}
func GetMissingArtwork(roms []romm.Rom) []romm.Rom {
var missing []romm.Rom
for _, rom := range roms {
if !HasArtworkURL(rom) {
continue
}
if !ArtworkExists(rom.PlatformFSSlug, rom.ID) {
missing = append(missing, rom)
}
}
return missing
}
func HasArtworkURL(rom romm.Rom) bool {
return rom.PathCoverSmall != "" || rom.PathCoverLarge != "" || rom.URLCover != ""
}
func GetArtworkCoverPath(rom romm.Rom) string {
if rom.PathCoverSmall != "" {
return rom.PathCoverSmall
}
if rom.PathCoverLarge != "" {
return rom.PathCoverLarge
}
return rom.URLCover
}
func DownloadAndCacheArtwork(rom romm.Rom, host romm.Host) error {
logger := gaba.GetLogger()
cacheDir := GetArtworkCacheDir()
platformDirs, err := os.ReadDir(cacheDir)
coverPath := GetArtworkCoverPath(rom)
if coverPath == "" {
return nil // No artwork available
}
if err := EnsureArtworkCacheDir(rom.PlatformFSSlug); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
cachePath := GetArtworkCachePath(rom.PlatformFSSlug, rom.ID)
artURL := host.URL() + coverPath
artURL = strings.ReplaceAll(artURL, " ", "%20")
req, err := http.NewRequest("GET", artURL, nil)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, newCacheError("scan", "artwork", "", err)
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", host.BasicAuthHeader())
client := &http.Client{Timeout: constants.DefaultClientTimeout}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download artwork: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
indexed := 0
for _, platformDir := range platformDirs {
if !platformDir.IsDir() {
continue
}
outFile, err := os.Create(cachePath)
if err != nil {
return fmt.Errorf("failed to create cache file: %w", err)
}
defer outFile.Close()
fsSlug := platformDir.Name()
platformPath := filepath.Join(cacheDir, fsSlug)
if _, err = io.Copy(outFile, resp.Body); err != nil {
os.Remove(cachePath)
return fmt.Errorf("failed to write cache file: %w", err)
}
outFile.Close()
files, err := os.ReadDir(platformPath)
if err != nil {
continue
}
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".png" {
continue
}
name := file.Name()
romIDStr := name[:len(name)-4] // Remove ".png"
romID, err := strconv.Atoi(romIDStr)
if err != nil {
continue
}
filePath := filepath.Join(platformPath, file.Name())
cm.mu.RLock()
var count int
cm.db.QueryRow(`
SELECT COUNT(*) FROM artwork_metadata
WHERE platform_fs_slug = ? AND rom_id = ?
`, fsSlug, romID).Scan(&count)
cm.mu.RUnlock()
if count == 0 {
cm.MarkArtworkCached(fsSlug, romID, filePath)
indexed++
}
}
if err := imageutil.ProcessArtImage(cachePath); err != nil {
logger.Warn("Failed to process artwork image", "path", cachePath, "error", err)
os.Remove(cachePath)
return fmt.Errorf("failed to process artwork: %w", err)
}
if indexed > 0 {
logger.Debug("Indexed existing artwork files", "count", indexed)
file, err := os.Open(cachePath)
if err != nil {
return fmt.Errorf("failed to open processed artwork: %w", err)
}
_, err = png.DecodeConfig(file)
file.Close()
if err != nil {
os.Remove(cachePath)
return fmt.Errorf("processed artwork is not a valid PNG: %w", err)
}
return indexed, nil
return nil
}
func (cm *CacheManager) GetAllArtworkMetadata() ([]struct {
FSSlug string
RomID int
Path string
}, error) {
if cm == nil || !cm.initialized {
return nil, ErrNotInitialized
func SyncArtworkInBackground(host romm.Host, games []romm.Rom) {
logger := gaba.GetLogger()
missing := GetMissingArtwork(games)
if len(missing) == 0 {
return
}
cm.mu.RLock()
defer cm.mu.RUnlock()
rows, err := cm.db.Query(`
SELECT platform_fs_slug, rom_id, file_path FROM artwork_metadata
`)
if err != nil {
return nil, newCacheError("get", "artwork", "", err)
}
defer rows.Close()
var results []struct {
FSSlug string
RomID int
Path string
}
for rows.Next() {
var fsSlug string
var romID int
var path string
if err := rows.Scan(&fsSlug, &romID, &path); err != nil {
continue
for _, rom := range missing {
if err := DownloadAndCacheArtwork(rom, host); err != nil {
logger.Debug("Failed to download artwork", "rom", rom.Name, "error", err)
}
results = append(results, struct {
FSSlug string
RomID int
Path string
}{fsSlug, romID, path})
}
return results, nil
}
func (cm *CacheManager) HasArtworkByFilename(fsSlug string) bool {
if cm == nil || !cm.initialized {
func CheckRemoteArtworkLastModified(url string, authHeader string) (time.Time, error) {
req, err := http.NewRequest("HEAD", url, nil)
if err != nil {
return time.Time{}, err
}
if authHeader != "" {
req.Header.Set("Authorization", authHeader)
}
client := &http.Client{Timeout: 10 * constants.DefaultHTTPTimeout}
resp, err := client.Do(req)
if err != nil {
return time.Time{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return time.Time{}, fmt.Errorf("bad status: %s", resp.Status)
}
lastModified := resp.Header.Get("Last-Modified")
if lastModified == "" {
return time.Time{}, nil
}
return http.ParseTime(lastModified)
}
func ArtworkNeedsUpdate(rom romm.Rom, host romm.Host) bool {
cachePath := GetArtworkCachePath(rom.PlatformFSSlug, rom.ID)
localInfo, err := os.Stat(cachePath)
if err != nil {
return true
}
coverPath := GetArtworkCoverPath(rom)
if coverPath == "" {
return false
}
cm.mu.RLock()
defer cm.mu.RUnlock()
artURL := host.URL() + coverPath
artURL = strings.ReplaceAll(artURL, " ", "%20")
var count int
err := cm.db.QueryRow(`
SELECT COUNT(*) FROM artwork_metadata WHERE platform_fs_slug = ?
`, fsSlug).Scan(&count)
if err != nil {
// Fallback to checking directory
dir := filepath.Join(GetArtworkCacheDir(), fsSlug)
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
return len(entries) > 0
remoteModTime, err := CheckRemoteArtworkLastModified(artURL, host.BasicAuthHeader())
if err != nil || remoteModTime.IsZero() {
return false // On error or no Last-Modified header, skip re-download
}
return count > 0
}
func GetGameByIDForArtwork(gameID int) (struct {
PlatformFSSlug string
}, error) {
cm := GetCacheManager()
if cm == nil {
return struct{ PlatformFSSlug string }{}, ErrNotInitialized
}
cm.mu.RLock()
defer cm.mu.RUnlock()
var platformFSSlug string
err := cm.db.QueryRow(`
SELECT platform_fs_slug FROM games WHERE id = ?
`, gameID).Scan(&platformFSSlug)
if err == sql.ErrNoRows {
return struct{ PlatformFSSlug string }{}, ErrCacheMiss
}
if err != nil {
return struct{ PlatformFSSlug string }{}, err
}
return struct{ PlatformFSSlug string }{platformFSSlug}, nil
return remoteModTime.After(localInfo.ModTime())
}
+3 -74
View File
@@ -1,15 +1,13 @@
package cache
import (
"database/sql"
"encoding/json"
"grout/romm"
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
)
// GetCollections retrieves all cached collections
func (cm *CacheManager) GetCollections() ([]romm.Collection, error) {
func (cm *Manager) GetCollections() ([]romm.Collection, error) {
if cm == nil || !cm.initialized {
return nil, ErrNotInitialized
}
@@ -56,8 +54,7 @@ func (cm *CacheManager) GetCollections() ([]romm.Collection, error) {
return collections, nil
}
// GetCollectionsByType retrieves collections of a specific type
func (cm *CacheManager) GetCollectionsByType(collType string) ([]romm.Collection, error) {
func (cm *Manager) GetCollectionsByType(collType string) ([]romm.Collection, error) {
if cm == nil || !cm.initialized {
return nil, ErrNotInitialized
}
@@ -104,8 +101,7 @@ func (cm *CacheManager) GetCollectionsByType(collType string) ([]romm.Collection
return collections, nil
}
// SaveCollections saves collection metadata to cache
func (cm *CacheManager) SaveCollections(collections []romm.Collection) error {
func (cm *Manager) SaveCollections(collections []romm.Collection) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
@@ -176,70 +172,3 @@ func (cm *CacheManager) SaveCollections(collections []romm.Collection) error {
logger.Debug("Saved collections to cache", "count", len(collections))
return nil
}
// GetCollection retrieves a single collection by its identifier
func (cm *CacheManager) GetCollection(collection romm.Collection) (romm.Collection, error) {
if cm == nil || !cm.initialized {
return romm.Collection{}, ErrNotInitialized
}
cm.mu.RLock()
defer cm.mu.RUnlock()
var dataJSON string
var err error
if collection.IsVirtual {
err = cm.db.QueryRow(`
SELECT data_json FROM collections WHERE virtual_id = ?
`, collection.VirtualID).Scan(&dataJSON)
} else {
collType := "regular"
if collection.IsSmart {
collType = "smart"
}
err = cm.db.QueryRow(`
SELECT data_json FROM collections WHERE romm_id = ? AND type = ?
`, collection.ID, collType).Scan(&dataJSON)
}
if err == sql.ErrNoRows {
cm.stats.recordMiss()
return romm.Collection{}, ErrCacheMiss
}
if err != nil {
cm.stats.recordError()
return romm.Collection{}, newCacheError("get", "collections", collection.Name, err)
}
var result romm.Collection
if err := json.Unmarshal([]byte(dataJSON), &result); err != nil {
cm.stats.recordError()
return romm.Collection{}, newCacheError("get", "collections", collection.Name, err)
}
cm.stats.recordHit()
return result, nil
}
// HasCollectionGames checks if games are cached for a collection
func (cm *CacheManager) HasCollectionGames(collection romm.Collection) bool {
if cm == nil || !cm.initialized {
return false
}
cm.mu.RLock()
defer cm.mu.RUnlock()
collectionID, err := cm.getCollectionInternalID(collection)
if err != nil {
return false
}
var count int
err = cm.db.QueryRow(`
SELECT COUNT(*) FROM game_collections WHERE collection_id = ?
`, collectionID).Scan(&count)
return err == nil && count > 0
}
-2
View File
@@ -2,8 +2,6 @@ package cache
import "time"
// Config defines the configuration interface needed by the cache package.
// This interface is implemented by utils.Config.
type Config interface {
GetApiTimeout() time.Duration
GetShowCollections() bool
+5 -8
View File
@@ -5,7 +5,6 @@ import (
"fmt"
)
// Sentinel errors for cache operations
var (
ErrNotInitialized = errors.New("cache manager not initialized")
ErrCacheMiss = errors.New("cache miss")
@@ -13,15 +12,14 @@ var (
ErrInvalidCacheKey = errors.New("invalid cache key")
)
// CacheError provides detailed error information for cache operations
type CacheError struct {
type Error struct {
Op string // Operation name: "get", "save", "delete", etc.
Key string // Cache key if applicable
CacheType string // "platform", "collection", "rom_id", "artwork"
Err error // Underlying error
}
func (e *CacheError) Error() string {
func (e *Error) Error() string {
if e.Key != "" {
return fmt.Sprintf("cache %s [%s:%s]: %v", e.Op, e.CacheType, e.Key, e.Err)
}
@@ -31,13 +29,12 @@ func (e *CacheError) Error() string {
return fmt.Sprintf("cache %s: %v", e.Op, e.Err)
}
func (e *CacheError) Unwrap() error {
func (e *Error) Unwrap() error {
return e.Err
}
// newCacheError creates a new CacheError
func newCacheError(op, cacheType, key string, err error) *CacheError {
return &CacheError{
func newCacheError(op, cacheType, key string, err error) *Error {
return &Error{
Op: op,
Key: key,
CacheType: cacheType,
+18 -124
View File
@@ -11,7 +11,6 @@ import (
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
)
// Cache key types (kept for compatibility)
type Type string
const (
@@ -21,17 +20,14 @@ const (
VirtualCollection Type = "virtual_collection"
)
// GetCacheKey generates a cache key (kept for compatibility with existing code)
func GetCacheKey(cacheType Type, id string) string {
return string(cacheType) + "_" + id
}
// GetPlatformCacheKey generates a platform cache key
func GetPlatformCacheKey(platformID int) string {
return GetCacheKey(Platform, strconv.Itoa(platformID))
}
// GetCollectionCacheKey generates a collection cache key
func GetCollectionCacheKey(collection romm.Collection) string {
if collection.IsVirtual {
return GetCacheKey(VirtualCollection, collection.VirtualID)
@@ -42,8 +38,7 @@ func GetCollectionCacheKey(collection romm.Collection) string {
return GetCacheKey(Collection, strconv.Itoa(collection.ID))
}
// GetPlatformGames retrieves all games for a platform from cache
func (cm *CacheManager) GetPlatformGames(platformID int) ([]romm.Rom, error) {
func (cm *Manager) GetPlatformGames(platformID int) ([]romm.Rom, error) {
if cm == nil || !cm.initialized {
return nil, ErrNotInitialized
}
@@ -90,8 +85,7 @@ func (cm *CacheManager) GetPlatformGames(platformID int) ([]romm.Rom, error) {
return games, nil
}
// SavePlatformGames saves games for a platform to cache
func (cm *CacheManager) SavePlatformGames(platformID int, games []romm.Rom) error {
func (cm *Manager) SavePlatformGames(platformID int, games []romm.Rom) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
@@ -107,15 +101,14 @@ func (cm *CacheManager) SavePlatformGames(platformID int, games []romm.Rom) erro
}
defer tx.Rollback()
// Delete existing games for this platform
_, err = tx.Exec(`DELETE FROM games WHERE platform_id = ?`, platformID)
if err != nil {
return newCacheError("save", "games", GetPlatformCacheKey(platformID), err)
}
stmt, err := tx.Prepare(`
INSERT INTO games (id, platform_id, platform_fs_slug, name, fs_name, data_json, updated_at, cached_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO games (id, platform_id, platform_fs_slug, name, fs_name, fs_name_no_ext, crc_hash, md5_hash, sha1_hash, data_json, updated_at, cached_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return newCacheError("save", "games", GetPlatformCacheKey(platformID), err)
@@ -135,6 +128,10 @@ func (cm *CacheManager) SavePlatformGames(platformID int, games []romm.Rom) erro
game.PlatformFSSlug,
game.Name,
game.FsName,
game.FsNameNoExt,
game.CrcHash,
game.Md5Hash,
game.Sha1Hash,
string(dataJSON),
game.UpdatedAt,
now,
@@ -152,8 +149,7 @@ func (cm *CacheManager) SavePlatformGames(platformID int, games []romm.Rom) erro
return nil
}
// GetCollectionGames retrieves all games for a collection from cache
func (cm *CacheManager) GetCollectionGames(collection romm.Collection) ([]romm.Rom, error) {
func (cm *Manager) GetCollectionGames(collection romm.Collection) ([]romm.Rom, error) {
if cm == nil || !cm.initialized {
return nil, ErrNotInitialized
}
@@ -161,7 +157,6 @@ func (cm *CacheManager) GetCollectionGames(collection romm.Collection) ([]romm.R
cm.mu.RLock()
defer cm.mu.RUnlock()
// Get the collection's internal ID
collectionID, err := cm.getCollectionInternalID(collection)
if err != nil {
return nil, err
@@ -209,9 +204,7 @@ func (cm *CacheManager) GetCollectionGames(collection romm.Collection) ([]romm.R
return games, nil
}
// SaveCollectionGames saves game-collection mappings to cache
// Games should already exist from platform fetching; this only creates the mappings
func (cm *CacheManager) SaveCollectionGames(collection romm.Collection, games []romm.Rom) error {
func (cm *Manager) SaveCollectionGames(collection romm.Collection, games []romm.Rom) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
@@ -277,8 +270,7 @@ func (cm *CacheManager) SaveCollectionGames(collection romm.Collection, games []
return nil
}
// getCollectionInternalIDLocked gets collection ID without acquiring lock (caller must hold lock)
func (cm *CacheManager) getCollectionInternalIDLocked(collection romm.Collection) (int64, error) {
func (cm *Manager) getCollectionInternalID(collection romm.Collection) (int64, error) {
var id int64
var err error
@@ -304,9 +296,11 @@ func (cm *CacheManager) getCollectionInternalIDLocked(collection romm.Collection
return id, nil
}
// SaveAllCollectionMappings saves all game-collection mappings in a single transaction
// Uses ROMIDs from the collection response - no additional API calls needed
func (cm *CacheManager) SaveAllCollectionMappings(collections []romm.Collection) error {
func (cm *Manager) getCollectionInternalIDLocked(collection romm.Collection) (int64, error) {
return cm.getCollectionInternalID(collection)
}
func (cm *Manager) SaveAllCollectionMappings(collections []romm.Collection) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
@@ -402,8 +396,7 @@ func (cm *CacheManager) SaveAllCollectionMappings(collections []romm.Collection)
return nil
}
// GetGamesByIDs retrieves multiple games by their IDs efficiently
func (cm *CacheManager) GetGamesByIDs(gameIDs []int) ([]romm.Rom, error) {
func (cm *Manager) GetGamesByIDs(gameIDs []int) ([]romm.Rom, error) {
if cm == nil || !cm.initialized {
return nil, ErrNotInitialized
}
@@ -462,55 +455,7 @@ func (cm *CacheManager) GetGamesByIDs(gameIDs []int) ([]romm.Rom, error) {
return games, nil
}
// GetGameByID retrieves a single game by ID
func (cm *CacheManager) GetGameByID(gameID int) (romm.Rom, error) {
if cm == nil || !cm.initialized {
return romm.Rom{}, ErrNotInitialized
}
cm.mu.RLock()
defer cm.mu.RUnlock()
var dataJSON string
err := cm.db.QueryRow(`
SELECT data_json FROM games WHERE id = ?
`, gameID).Scan(&dataJSON)
if err == sql.ErrNoRows {
cm.stats.recordMiss()
return romm.Rom{}, ErrCacheMiss
}
if err != nil {
cm.stats.recordError()
return romm.Rom{}, newCacheError("get", "games", strconv.Itoa(gameID), err)
}
var game romm.Rom
if err := json.Unmarshal([]byte(dataJSON), &game); err != nil {
cm.stats.recordError()
return romm.Rom{}, newCacheError("get", "games", strconv.Itoa(gameID), err)
}
cm.stats.recordHit()
return game, nil
}
// HasPlatformGames checks if games are cached for a platform
func (cm *CacheManager) HasPlatformGames(platformID int) bool {
if cm == nil || !cm.initialized {
return false
}
cm.mu.RLock()
defer cm.mu.RUnlock()
var count int
err := cm.db.QueryRow(`SELECT COUNT(*) FROM games WHERE platform_id = ?`, platformID).Scan(&count)
return err == nil && count > 0
}
// GetCachedGameIDs returns a map of all game IDs in the cache for fast lookup
func (cm *CacheManager) GetCachedGameIDs() map[int]bool {
func (cm *Manager) GetCachedGameIDs() map[int]bool {
if cm == nil || !cm.initialized {
return nil
}
@@ -534,54 +479,3 @@ func (cm *CacheManager) GetCachedGameIDs() map[int]bool {
return gameIDs
}
// Helper function to get collection internal ID
func (cm *CacheManager) getCollectionInternalID(collection romm.Collection) (int64, error) {
var id int64
var err error
if collection.IsVirtual {
err = cm.db.QueryRow(`SELECT id FROM collections WHERE virtual_id = ?`, collection.VirtualID).Scan(&id)
} else {
collType := "regular"
if collection.IsSmart {
collType = "smart"
}
err = cm.db.QueryRow(`SELECT id FROM collections WHERE romm_id = ? AND type = ?`, collection.ID, collType).Scan(&id)
}
if err == sql.ErrNoRows {
cm.stats.recordMiss()
return 0, ErrCacheMiss
}
if err != nil {
cm.stats.recordError()
return 0, newCacheError("get", "collections", GetCollectionCacheKey(collection), err)
}
return id, nil
}
// ClearGamesCache clears the games cache (compatibility wrapper)
func ClearGamesCache() error {
cm := GetCacheManager()
if cm == nil {
return nil
}
return cm.ClearGames()
}
// HasGamesCache checks if games cache has data (compatibility wrapper)
func HasGamesCache() bool {
cm := GetCacheManager()
if cm == nil {
return false
}
return cm.HasCache()
}
// GetGamesCacheDir returns the cache directory (kept for compatibility)
// Note: This no longer reflects actual storage location but is kept for compatibility
func GetGamesCacheDir() string {
return GetArtworkCacheDir() // Just return something valid
}
+49 -106
View File
@@ -14,8 +14,7 @@ import (
_ "modernc.org/sqlite"
)
// CacheManager is the unified cache interface for all caching operations
type CacheManager struct {
type Manager struct {
db *sql.DB
dbPath string
mu sync.RWMutex
@@ -23,11 +22,9 @@ type CacheManager struct {
config Config
initialized bool
// Stats tracking
stats *CacheStats
}
// CacheStats tracks cache performance metrics
type CacheStats struct {
mu sync.Mutex
Hits int64
@@ -57,19 +54,15 @@ func (s *CacheStats) recordError() {
}
var (
cacheManager *CacheManager
cacheManager *Manager
cacheManagerOnce sync.Once
cacheManagerErr error
)
// GetCacheManager returns the singleton cache manager instance
// Returns nil if not yet initialized
func GetCacheManager() *CacheManager {
func GetCacheManager() *Manager {
return cacheManager
}
// InitCacheManager initializes the singleton cache manager
// This should be called once at application startup
func InitCacheManager(host romm.Host, config Config) error {
cacheManagerOnce.Do(func() {
cacheManager, cacheManagerErr = newCacheManager(host, config)
@@ -77,38 +70,32 @@ func InitCacheManager(host romm.Host, config Config) error {
return cacheManagerErr
}
// newCacheManager creates a new CacheManager instance
func newCacheManager(host romm.Host, config Config) (*CacheManager, error) {
func newCacheManager(host romm.Host, config Config) (*Manager, error) {
logger := gaba.GetLogger()
dbPath := getCacheDBPath()
// Ensure cache directory exists
cacheDir := filepath.Dir(dbPath)
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return nil, newCacheError("init", "", "", err)
}
// Clean up old JSON cache directories if they exist
cleanupLegacyCache()
// Open SQLite database with WAL mode for better concurrent read performance
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, newCacheError("init", "", "", err)
}
// SQLite is single-writer, limit connections
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
// Create tables
if err := createTables(db); err != nil {
db.Close()
return nil, newCacheError("init", "", "", err)
}
cm := &CacheManager{
cm := &Manager{
db: db,
dbPath: dbPath,
host: host,
@@ -121,8 +108,7 @@ func newCacheManager(host romm.Host, config Config) (*CacheManager, error) {
return cm, nil
}
// Close closes the database connection
func (cm *CacheManager) Close() error {
func (cm *Manager) Close() error {
if cm == nil || cm.db == nil {
return nil
}
@@ -134,8 +120,7 @@ func (cm *CacheManager) Close() error {
return cm.db.Close()
}
// IsFirstRun returns true if the cache has no games (needs population)
func (cm *CacheManager) IsFirstRun() bool {
func (cm *Manager) IsFirstRun() bool {
if cm == nil || !cm.initialized {
return true
}
@@ -152,8 +137,7 @@ func (cm *CacheManager) IsFirstRun() bool {
return count == 0
}
// HasCache returns true if the cache database exists and has data
func (cm *CacheManager) HasCache() bool {
func (cm *Manager) HasCache() bool {
if cm == nil || !cm.initialized {
return false
}
@@ -166,8 +150,7 @@ func (cm *CacheManager) HasCache() bool {
return err == nil && count > 0
}
// Clear removes all cached data but keeps the database structure
func (cm *CacheManager) Clear() error {
func (cm *Manager) Clear() error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
@@ -177,7 +160,7 @@ func (cm *CacheManager) Clear() error {
cm.mu.Lock()
defer cm.mu.Unlock()
tables := []string{"games", "game_collections", "collections", "platforms", "rom_id_cache", "artwork_metadata", "bios_availability"}
tables := []string{"games", "game_collections", "collections", "platforms", "bios_availability"}
tx, err := cm.db.Begin()
if err != nil {
@@ -195,7 +178,6 @@ func (cm *CacheManager) Clear() error {
return newCacheError("clear", "", "", err)
}
// Also clear artwork files from disk
artworkDir := GetArtworkCacheDir()
if fileutil.FileExists(artworkDir) {
os.RemoveAll(artworkDir)
@@ -205,8 +187,7 @@ func (cm *CacheManager) Clear() error {
return nil
}
// ClearGames removes only the games cache
func (cm *CacheManager) ClearGames() error {
func (cm *Manager) ClearGames() error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
@@ -231,30 +212,7 @@ func (cm *CacheManager) ClearGames() error {
return tx.Commit()
}
// ClearArtwork removes artwork metadata and files
func (cm *CacheManager) ClearArtwork() error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
cm.mu.Lock()
defer cm.mu.Unlock()
if _, err := cm.db.Exec("DELETE FROM artwork_metadata"); err != nil {
return newCacheError("clear_artwork", "", "", err)
}
// Also clear artwork files from disk
artworkDir := GetArtworkCacheDir()
if fileutil.FileExists(artworkDir) {
os.RemoveAll(artworkDir)
}
return nil
}
// ClearCollections removes collections and their game mappings
func (cm *CacheManager) ClearCollections() error {
func (cm *Manager) ClearCollections() error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
@@ -279,8 +237,7 @@ func (cm *CacheManager) ClearCollections() error {
return tx.Commit()
}
// HasCollections returns true if collections are cached
func (cm *CacheManager) HasCollections() bool {
func (cm *Manager) HasCollections() bool {
if cm == nil || !cm.initialized {
return false
}
@@ -293,15 +250,12 @@ func (cm *CacheManager) HasCollections() bool {
return err == nil && count > 0
}
// Cache refresh timestamp keys
const (
MetaKeyGamesRefreshedAt = "games_refreshed_at"
MetaKeyCollectionsRefreshedAt = "collections_refreshed_at"
MetaKeyArtworkRefreshedAt = "artwork_refreshed_at"
)
// SetMetadata stores a key-value pair in the cache metadata table
func (cm *CacheManager) SetMetadata(key, value string) error {
func (cm *Manager) SetMetadata(key, value string) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
@@ -320,8 +274,7 @@ func (cm *CacheManager) SetMetadata(key, value string) error {
return nil
}
// GetMetadata retrieves a value from the cache metadata table
func (cm *CacheManager) GetMetadata(key string) (string, error) {
func (cm *Manager) GetMetadata(key string) (string, error) {
if cm == nil || !cm.initialized {
return "", ErrNotInitialized
}
@@ -338,8 +291,7 @@ func (cm *CacheManager) GetMetadata(key string) (string, error) {
return value, nil
}
// GetLastRefreshTime returns the last refresh time for a cache type
func (cm *CacheManager) GetLastRefreshTime(key string) (time.Time, error) {
func (cm *Manager) GetLastRefreshTime(key string) (time.Time, error) {
value, err := cm.GetMetadata(key)
if err != nil {
return time.Time{}, err
@@ -348,16 +300,14 @@ func (cm *CacheManager) GetLastRefreshTime(key string) (time.Time, error) {
return time.Parse(time.RFC3339, value)
}
// RecordRefreshTime records the current time as the last refresh for a cache type
func (cm *CacheManager) RecordRefreshTime(key string) error {
func (cm *Manager) RecordRefreshTime(key string) error {
return cm.SetMetadata(key, time.Now().Format(time.RFC3339))
}
// GetAllRefreshTimes returns the last refresh times for all cache types
func (cm *CacheManager) GetAllRefreshTimes() map[string]time.Time {
func (cm *Manager) GetAllRefreshTimes() map[string]time.Time {
result := make(map[string]time.Time)
keys := []string{MetaKeyGamesRefreshedAt, MetaKeyCollectionsRefreshedAt, MetaKeyArtworkRefreshedAt}
keys := []string{MetaKeyGamesRefreshedAt, MetaKeyCollectionsRefreshedAt}
for _, key := range keys {
if t, err := cm.GetLastRefreshTime(key); err == nil {
result[key] = t
@@ -367,36 +317,7 @@ func (cm *CacheManager) GetAllRefreshTimes() map[string]time.Time {
return result
}
// GetStats returns current cache statistics
func (cm *CacheManager) GetStats() CacheStats {
if cm == nil || cm.stats == nil {
return CacheStats{}
}
cm.stats.mu.Lock()
defer cm.stats.mu.Unlock()
return CacheStats{
Hits: cm.stats.Hits,
Misses: cm.stats.Misses,
Errors: cm.stats.Errors,
LastAccess: cm.stats.LastAccess,
}
}
// SetHost updates the host configuration (used after re-login)
func (cm *CacheManager) SetHost(host romm.Host) {
if cm == nil {
return
}
cm.mu.Lock()
cm.host = host
cm.mu.Unlock()
}
// PopulateFullCacheWithProgress populates the entire cache with progress reporting
func (cm *CacheManager) PopulateFullCacheWithProgress(platforms []romm.Platform, progress *atomic.Float64) error {
func (cm *Manager) PopulateFullCacheWithProgress(platforms []romm.Platform, progress *atomic.Float64) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
@@ -404,9 +325,6 @@ func (cm *CacheManager) PopulateFullCacheWithProgress(platforms []romm.Platform,
return cm.populateCache(platforms, progress)
}
// Helper functions
// getCacheDBPath returns the path to the SQLite database file
func getCacheDBPath() string {
wd, err := os.Getwd()
if err != nil {
@@ -415,7 +333,6 @@ func getCacheDBPath() string {
return filepath.Join(wd, ".cache", "grout.db")
}
// GetArtworkCacheDir returns the path to the artwork cache directory
func GetArtworkCacheDir() string {
wd, err := os.Getwd()
if err != nil {
@@ -424,7 +341,35 @@ func GetArtworkCacheDir() string {
return filepath.Join(wd, ".cache", "artwork")
}
// cleanupLegacyCache removes old JSON cache directories
func GetCacheDir() string {
wd, err := os.Getwd()
if err != nil {
return filepath.Join(os.TempDir(), ".cache")
}
return filepath.Join(wd, ".cache")
}
func DeleteCacheFolder() error {
logger := gaba.GetLogger()
if cacheManager != nil {
cacheManager.Close()
cacheManager = nil
}
cacheManagerOnce = sync.Once{}
cacheManagerErr = nil
cacheDir := GetCacheDir()
if err := os.RemoveAll(cacheDir); err != nil {
logger.Error("Failed to delete cache folder", "path", cacheDir, "error", err)
return err
}
logger.Info("Cache folder deleted", "path", cacheDir)
return nil
}
func cleanupLegacyCache() {
logger := gaba.GetLogger()
@@ -433,7 +378,6 @@ func cleanupLegacyCache() {
return
}
// Remove old games JSON cache
gamesDir := filepath.Join(wd, ".cache", "games")
if fileutil.FileExists(gamesDir) {
if err := os.RemoveAll(gamesDir); err != nil {
@@ -443,7 +387,6 @@ func cleanupLegacyCache() {
}
}
// Remove old ROM ID JSON cache
romsDir := filepath.Join(wd, ".cache", "roms")
if fileutil.FileExists(romsDir) {
if err := os.RemoveAll(romsDir); err != nil {
+7 -43
View File
@@ -9,8 +9,7 @@ import (
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
)
// GetPlatforms retrieves all cached platforms
func (cm *CacheManager) GetPlatforms() ([]romm.Platform, error) {
func (cm *Manager) GetPlatforms() ([]romm.Platform, error) {
if cm == nil || !cm.initialized {
return nil, ErrNotInitialized
}
@@ -57,8 +56,7 @@ func (cm *CacheManager) GetPlatforms() ([]romm.Platform, error) {
return platforms, nil
}
// SavePlatforms saves platform data to the cache
func (cm *CacheManager) SavePlatforms(platforms []romm.Platform) error {
func (cm *Manager) SavePlatforms(platforms []romm.Platform) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
@@ -76,8 +74,8 @@ func (cm *CacheManager) SavePlatforms(platforms []romm.Platform) error {
stmt, err := tx.Prepare(`
INSERT OR REPLACE INTO platforms
(id, slug, fs_slug, name, custom_name, rom_count, has_bios, data_json, updated_at, cached_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, slug, fs_slug, name, api_name, custom_name, rom_count, has_bios, data_json, updated_at, cached_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return newCacheError("save", "platforms", "", err)
@@ -101,6 +99,7 @@ func (cm *CacheManager) SavePlatforms(platforms []romm.Platform) error {
p.Slug,
p.FSSlug,
p.Name,
p.ApiName,
p.CustomName,
p.ROMCount,
hasBIOS,
@@ -121,41 +120,7 @@ func (cm *CacheManager) SavePlatforms(platforms []romm.Platform) error {
return nil
}
// GetPlatformByID retrieves a single platform by ID
func (cm *CacheManager) GetPlatformByID(platformID int) (romm.Platform, error) {
if cm == nil || !cm.initialized {
return romm.Platform{}, ErrNotInitialized
}
cm.mu.RLock()
defer cm.mu.RUnlock()
var dataJSON string
err := cm.db.QueryRow(`
SELECT data_json FROM platforms WHERE id = ?
`, platformID).Scan(&dataJSON)
if err == sql.ErrNoRows {
cm.stats.recordMiss()
return romm.Platform{}, ErrCacheMiss
}
if err != nil {
cm.stats.recordError()
return romm.Platform{}, newCacheError("get", "platforms", "", err)
}
var platform romm.Platform
if err := json.Unmarshal([]byte(dataJSON), &platform); err != nil {
cm.stats.recordError()
return romm.Platform{}, newCacheError("get", "platforms", "", err)
}
cm.stats.recordHit()
return platform, nil
}
// HasBIOS returns whether a platform has BIOS files available
func (cm *CacheManager) HasBIOS(platformID int) (bool, bool) {
func (cm *Manager) HasBIOS(platformID int) (bool, bool) {
if cm == nil || !cm.initialized {
return false, false
}
@@ -178,8 +143,7 @@ func (cm *CacheManager) HasBIOS(platformID int) (bool, bool) {
return hasBIOS == 1, true
}
// SetBIOSAvailability sets whether a platform has BIOS files
func (cm *CacheManager) SetBIOSAvailability(platformID int, hasBIOS bool) error {
func (cm *Manager) SetBIOSAvailability(platformID int, hasBIOS bool) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
+19 -204
View File
@@ -8,19 +8,12 @@ import (
"go.uber.org/atomic"
)
// Configuration constants for API pagination
const (
// DefaultRomPageSize is the number of ROMs to fetch per API call
// Kept small for better progress feedback and to avoid timeouts
// Increase this when the bulk API becomes more performant
DefaultRomPageSize = 200
// MaxConcurrentPlatformFetches limits parallel platform API calls
DefaultRomPageSize = 200
MaxConcurrentPlatformFetches = 5
)
// populateCache populates the entire cache with platform and collection data
func (cm *CacheManager) populateCache(platforms []romm.Platform, progress *atomic.Float64) error {
func (cm *Manager) populateCache(platforms []romm.Platform, progress *atomic.Float64) error {
logger := gaba.GetLogger()
if len(platforms) == 0 {
@@ -30,22 +23,18 @@ func (cm *CacheManager) populateCache(platforms []romm.Platform, progress *atomi
return nil
}
// Save platforms first
if err := cm.SavePlatforms(platforms); err != nil {
return err
}
// Calculate total expected games for granular progress tracking
// Use 90% for games, reserve 10% for collections
totalExpectedGames := int64(0)
for _, p := range platforms {
totalExpectedGames += int64(p.ROMCount)
}
if totalExpectedGames == 0 {
totalExpectedGames = int64(len(platforms)) // Fallback to platform count
totalExpectedGames = int64(len(platforms))
}
// Track progress based on games fetched
gamesFetched := &atomic.Int64{}
updateProgress := func(count int) {
if progress != nil {
@@ -59,7 +48,6 @@ func (cm *CacheManager) populateCache(platforms []romm.Platform, progress *atomi
}
}
// Use bounded concurrency for platform fetches
sem := make(chan struct{}, MaxConcurrentPlatformFetches)
var wg sync.WaitGroup
var firstErr error
@@ -69,8 +57,8 @@ func (cm *CacheManager) populateCache(platforms []romm.Platform, progress *atomi
wg.Add(1)
go func(p romm.Platform) {
defer wg.Done()
sem <- struct{}{} // Acquire semaphore
defer func() { <-sem }() // Release semaphore
sem <- struct{}{}
defer func() { <-sem }()
if err := cm.fetchAndCachePlatformGamesWithProgress(p, updateProgress); err != nil {
logger.Error("Failed to cache platform", "platform", p.Name, "error", err)
@@ -83,7 +71,6 @@ func (cm *CacheManager) populateCache(platforms []romm.Platform, progress *atomi
}(platform)
}
// Also fetch BIOS availability in parallel
wg.Add(1)
go func() {
defer wg.Done()
@@ -92,16 +79,12 @@ func (cm *CacheManager) populateCache(platforms []romm.Platform, progress *atomi
wg.Wait()
// Record games refresh time
if firstErr == nil {
cm.RecordRefreshTime(MetaKeyGamesRefreshedAt)
}
// Fetch collections after platforms (they may depend on game data)
// This uses the remaining 10% of progress (90% -> 100%)
cm.fetchAndCacheCollectionsWithProgress(progress)
// Record collections refresh time
cm.RecordRefreshTime(MetaKeyCollectionsRefreshedAt)
if progress != nil {
@@ -112,13 +95,11 @@ func (cm *CacheManager) populateCache(platforms []romm.Platform, progress *atomi
return firstErr
}
// fetchAndCachePlatformGames fetches all games for a platform using paginated API calls
func (cm *CacheManager) fetchAndCachePlatformGames(platform romm.Platform) error {
func (cm *Manager) fetchAndCachePlatformGames(platform romm.Platform) error {
return cm.fetchAndCachePlatformGamesWithProgress(platform, nil)
}
// fetchAndCachePlatformGamesWithProgress fetches all games with progress callback per batch
func (cm *CacheManager) fetchAndCachePlatformGamesWithProgress(platform romm.Platform, onProgress func(count int)) error {
func (cm *Manager) fetchAndCachePlatformGamesWithProgress(platform romm.Platform, onProgress func(count int)) error {
logger := gaba.GetLogger()
client := romm.NewClientFromHost(cm.host, cm.config.GetApiTimeout())
@@ -126,7 +107,6 @@ func (cm *CacheManager) fetchAndCachePlatformGamesWithProgress(platform romm.Pla
var allGames []romm.Rom
offset := 0
expectedTotal := 0
requestCount := 0
for {
opt := romm.GetRomsQuery{
@@ -143,72 +123,33 @@ func (cm *CacheManager) fetchAndCachePlatformGamesWithProgress(platform romm.Pla
"error", err)
return err
}
requestCount++
// Capture expected total from first request
if offset == 0 {
expectedTotal = res.Total
}
logger.Debug("Fetched games batch",
"platform", platform.Name,
"offset", offset,
"received", len(res.Items),
"total", res.Total,
"expectedTotal", expectedTotal,
"accumulated", len(allGames)+len(res.Items))
allGames = append(allGames, res.Items...)
// Report progress after each batch
if onProgress != nil && len(res.Items) > 0 {
onProgress(len(res.Items))
}
// Check if we've fetched all games:
// 1. We have at least as many as expected
// 2. OR we received an empty batch (no more items)
// 3. OR we received fewer items than requested (last batch)
// Terminate when: got all expected, empty batch, or partial page (last batch)
if len(allGames) >= expectedTotal || len(res.Items) == 0 || len(res.Items) < DefaultRomPageSize {
break
}
offset += len(res.Items)
// Safety limit to prevent infinite loops
if requestCount > 1000 {
logger.Warn("Hit request limit while fetching games",
"platform", platform.Name,
"accumulated", len(allGames))
break
}
}
logger.Info("Fetched all games for platform",
logger.Info("Cached platform games",
"platform", platform.Name,
"expected", expectedTotal,
"actual", len(allGames))
"count", len(allGames))
// Save to cache (this acquires its own lock)
if err := cm.SavePlatformGames(platform.ID, allGames); err != nil {
return err
}
logger.Debug("Fetched and cached platform games",
"platform", platform.Name,
"count", len(allGames),
"requests", requestCount)
return nil
return cm.SavePlatformGames(platform.ID, allGames)
}
// fetchAndCacheCollections fetches and caches all collection types
func (cm *CacheManager) fetchAndCacheCollections() {
cm.fetchAndCacheCollectionsWithProgress(nil)
}
// fetchAndCacheCollectionsWithProgress fetches and caches all collection types with progress tracking
func (cm *CacheManager) fetchAndCacheCollectionsWithProgress(progress *atomic.Float64) {
func (cm *Manager) fetchAndCacheCollectionsWithProgress(progress *atomic.Float64) {
logger := gaba.GetLogger()
client := romm.NewClientFromHost(cm.host, cm.config.GetApiTimeout())
@@ -217,9 +158,6 @@ func (cm *CacheManager) fetchAndCacheCollectionsWithProgress(progress *atomic.Fl
var mu sync.Mutex
var wg sync.WaitGroup
// Fetch regular collections
// Always fetch all collection types regardless of display settings
// so data is ready when user enables them
wg.Add(1)
go func() {
defer wg.Done()
@@ -233,7 +171,6 @@ func (cm *CacheManager) fetchAndCacheCollectionsWithProgress(progress *atomic.Fl
mu.Unlock()
}()
// Fetch smart collections
wg.Add(1)
go func() {
defer wg.Done()
@@ -250,7 +187,6 @@ func (cm *CacheManager) fetchAndCacheCollectionsWithProgress(progress *atomic.Fl
mu.Unlock()
}()
// Fetch virtual collections
wg.Add(1)
go func() {
defer wg.Done()
@@ -268,7 +204,7 @@ func (cm *CacheManager) fetchAndCacheCollectionsWithProgress(progress *atomic.Fl
wg.Wait()
// Update progress to 92% after fetching collection metadata
// Update progress to 92% after fetching collection metadata, arbitrary I know
if progress != nil {
progress.Store(0.92)
}
@@ -277,7 +213,6 @@ func (cm *CacheManager) fetchAndCacheCollectionsWithProgress(progress *atomic.Fl
return
}
// Save collection metadata
if err := cm.SaveCollections(allCollections); err != nil {
logger.Error("Failed to save collections", "error", err)
}
@@ -286,8 +221,6 @@ func (cm *CacheManager) fetchAndCacheCollectionsWithProgress(progress *atomic.Fl
progress.Store(0.94)
}
// Save game-collection mappings using ROMs already in collection response
// No need to fetch games again - they're already included!
if err := cm.SaveAllCollectionMappings(allCollections); err != nil {
logger.Error("Failed to save collection mappings", "error", err)
}
@@ -299,84 +232,7 @@ func (cm *CacheManager) fetchAndCacheCollectionsWithProgress(progress *atomic.Fl
logger.Debug("Cached collections", "count", len(allCollections))
}
// fetchCollectionGames fetches all games for a collection
func (cm *CacheManager) fetchCollectionGames(collection romm.Collection) ([]romm.Rom, error) {
logger := gaba.GetLogger()
client := romm.NewClientFromHost(cm.host, cm.config.GetApiTimeout())
var allGames []romm.Rom
offset := 0
expectedTotal := 0
requestCount := 0
for {
opt := romm.GetRomsQuery{
Offset: offset,
Limit: DefaultRomPageSize,
}
if collection.IsVirtual {
opt.VirtualCollectionID = collection.VirtualID
} else if collection.IsSmart {
opt.SmartCollectionID = collection.ID
} else {
opt.CollectionID = collection.ID
}
res, err := client.GetRoms(opt)
if err != nil {
logger.Error("Failed to fetch collection games",
"collection", collection.Name,
"offset", offset,
"error", err)
return nil, err
}
requestCount++
// Capture expected total from first request
if offset == 0 {
expectedTotal = res.Total
}
logger.Debug("Fetched collection games batch",
"collection", collection.Name,
"offset", offset,
"received", len(res.Items),
"total", res.Total,
"expectedTotal", expectedTotal,
"accumulated", len(allGames)+len(res.Items))
allGames = append(allGames, res.Items...)
// Check if we've fetched all games:
// 1. We have at least as many as expected
// 2. OR we received an empty batch (no more items)
// 3. OR we received fewer items than requested (last batch)
if len(allGames) >= expectedTotal || len(res.Items) == 0 || len(res.Items) < DefaultRomPageSize {
break
}
offset += len(res.Items)
// Safety limit to prevent infinite loops
if requestCount > 1000 {
logger.Warn("Hit request limit while fetching collection games",
"collection", collection.Name,
"accumulated", len(allGames))
break
}
}
logger.Debug("Fetched all games for collection",
"collection", collection.Name,
"expected", expectedTotal,
"actual", len(allGames))
return allGames, nil
}
// fetchBIOSAvailability fetches BIOS availability for all platforms
func (cm *CacheManager) fetchBIOSAvailability(platforms []romm.Platform) {
func (cm *Manager) fetchBIOSAvailability(platforms []romm.Platform) {
logger := gaba.GetLogger()
client := romm.NewClientFromHost(cm.host, cm.config.GetApiTimeout())
@@ -406,9 +262,7 @@ func (cm *CacheManager) fetchBIOSAvailability(platforms []romm.Platform) {
wg.Wait()
}
// RefreshPlatformGames fetches and updates games for a single platform
// Useful for refreshing a specific platform without full cache rebuild
func (cm *CacheManager) RefreshPlatformGames(platform romm.Platform) error {
func (cm *Manager) RefreshPlatformGames(platform romm.Platform) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
@@ -416,9 +270,7 @@ func (cm *CacheManager) RefreshPlatformGames(platform romm.Platform) error {
return cm.fetchAndCachePlatformGames(platform)
}
// RefreshPlatformGamesWithProgress fetches and updates games for a single platform with progress tracking
// Progress is reported as 0.0 to 1.0 based on games fetched vs expected total
func (cm *CacheManager) RefreshPlatformGamesWithProgress(platform romm.Platform, progress *atomic.Float64) error {
func (cm *Manager) RefreshPlatformGamesWithProgress(platform romm.Platform, progress *atomic.Float64) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
@@ -429,7 +281,6 @@ func (cm *CacheManager) RefreshPlatformGamesWithProgress(platform romm.Platform,
var allGames []romm.Rom
offset := 0
expectedTotal := 0
requestCount := 0
for {
opt := romm.GetRomsQuery{
@@ -446,24 +297,13 @@ func (cm *CacheManager) RefreshPlatformGamesWithProgress(platform romm.Platform,
"error", err)
return err
}
requestCount++
// Capture expected total from first request
if offset == 0 {
expectedTotal = res.Total
}
logger.Debug("Fetched games batch",
"platform", platform.Name,
"offset", offset,
"received", len(res.Items),
"total", res.Total,
"expectedTotal", expectedTotal,
"accumulated", len(allGames)+len(res.Items))
allGames = append(allGames, res.Items...)
// Update progress after each batch
if progress != nil && expectedTotal > 0 {
pct := float64(len(allGames)) / float64(expectedTotal)
if pct > 1.0 {
@@ -472,50 +312,25 @@ func (cm *CacheManager) RefreshPlatformGamesWithProgress(platform romm.Platform,
progress.Store(pct)
}
// Check if we've fetched all games
// Terminate when: got all expected, empty batch, or partial page (last batch)
if len(allGames) >= expectedTotal || len(res.Items) == 0 || len(res.Items) < DefaultRomPageSize {
break
}
offset += len(res.Items)
// Safety limit to prevent infinite loops
if requestCount > 1000 {
logger.Warn("Hit request limit while fetching games",
"platform", platform.Name,
"accumulated", len(allGames))
break
}
}
logger.Info("Fetched all games for platform",
logger.Info("Refreshed platform games",
"platform", platform.Name,
"expected", expectedTotal,
"actual", len(allGames))
"count", len(allGames))
// Save to cache
if err := cm.SavePlatformGames(platform.ID, allGames); err != nil {
return err
}
// Ensure progress is at 100% when done
if progress != nil {
progress.Store(1.0)
}
return nil
}
// RefreshCollectionGames fetches and updates games for a single collection
func (cm *CacheManager) RefreshCollectionGames(collection romm.Collection) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
games, err := cm.fetchCollectionGames(collection)
if err != nil {
return err
}
return cm.SaveCollectionGames(collection, games)
}
+37 -100
View File
@@ -2,20 +2,12 @@ package cache
import (
"database/sql"
"path/filepath"
"strings"
"grout/internal/stringutil"
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
)
// stripExtension removes the file extension from a filename
func stripExtension(filename string) string {
return strings.TrimSuffix(filename, filepath.Ext(filename))
}
// GetRomIDByFilename looks up a ROM ID by platform and filename
// Returns (romID, romName, found)
func (cm *CacheManager) GetRomIDByFilename(fsSlug, filename string) (int, string, bool) {
func (cm *Manager) GetRomIDByFilename(fsSlug, filename string) (int, string, bool) {
if cm == nil || !cm.initialized {
return 0, "", false
}
@@ -23,13 +15,13 @@ func (cm *CacheManager) GetRomIDByFilename(fsSlug, filename string) (int, string
cm.mu.RLock()
defer cm.mu.RUnlock()
key := stripExtension(filename)
key := stringutil.StripExtension(filename)
var romID int
var romName string
err := cm.db.QueryRow(`
SELECT rom_id, rom_name FROM rom_id_cache
WHERE platform_fs_slug = ? AND filename_key = ?
SELECT id, name FROM games
WHERE platform_fs_slug = ? AND fs_name_no_ext = ?
`, fsSlug, key).Scan(&romID, &romName)
if err == sql.ErrNoRows {
@@ -38,6 +30,7 @@ func (cm *CacheManager) GetRomIDByFilename(fsSlug, filename string) (int, string
}
if err != nil {
cm.stats.recordError()
gaba.GetLogger().Debug("ROM lookup error", "fsSlug", fsSlug, "filename", filename, "error", err)
return 0, "", false
}
@@ -45,68 +38,45 @@ func (cm *CacheManager) GetRomIDByFilename(fsSlug, filename string) (int, string
return romID, romName, true
}
// StoreRomID stores a ROM ID mapping for a platform and filename
func (cm *CacheManager) StoreRomID(fsSlug, filename string, romID int, romName string) error {
func (cm *Manager) GetRomByHash(md5, sha1, crc string) (int, string, bool) {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
logger := gaba.GetLogger()
cm.mu.Lock()
defer cm.mu.Unlock()
key := stripExtension(filename)
_, err := cm.db.Exec(`
INSERT OR REPLACE INTO rom_id_cache
(platform_fs_slug, filename_key, rom_id, rom_name, cached_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
`, fsSlug, key, romID, romName)
if err != nil {
logger.Debug("Failed to store ROM ID", "fsSlug", fsSlug, "filename", filename, "error", err)
return newCacheError("save", "rom_id", key, err)
}
logger.Debug("Stored ROM ID mapping", "fsSlug", fsSlug, "filename", filename, "romID", romID)
return nil
}
// ClearRomIDCache clears all ROM ID mappings
func (cm *CacheManager) ClearRomIDCache() error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
}
cm.mu.Lock()
defer cm.mu.Unlock()
_, err := cm.db.Exec(`DELETE FROM rom_id_cache`)
if err != nil {
return newCacheError("clear", "rom_id", "", err)
}
return nil
}
// GetRomIDCacheCount returns the number of cached ROM ID mappings
func (cm *CacheManager) GetRomIDCacheCount() int {
if cm == nil || !cm.initialized {
return 0
return 0, "", false
}
cm.mu.RLock()
defer cm.mu.RUnlock()
var count int
cm.db.QueryRow(`SELECT COUNT(*) FROM rom_id_cache`).Scan(&count)
return count
var romID int
var romName string
if md5 != "" {
err := cm.db.QueryRow(`SELECT id, name FROM games WHERE md5_hash = ?`, md5).Scan(&romID, &romName)
if err == nil {
cm.stats.recordHit()
return romID, romName, true
}
}
if sha1 != "" {
err := cm.db.QueryRow(`SELECT id, name FROM games WHERE sha1_hash = ?`, sha1).Scan(&romID, &romName)
if err == nil {
cm.stats.recordHit()
return romID, romName, true
}
}
if crc != "" {
err := cm.db.QueryRow(`SELECT id, name FROM games WHERE crc_hash = ?`, crc).Scan(&romID, &romName)
if err == nil {
cm.stats.recordHit()
return romID, romName, true
}
}
cm.stats.recordMiss()
return 0, "", false
}
// Compatibility functions for existing code
// GetCachedRomIDByFilename looks up a ROM ID (compatibility wrapper)
func GetCachedRomIDByFilename(fsSlug, filename string) (int, string, bool) {
cm := GetCacheManager()
if cm == nil {
@@ -114,36 +84,3 @@ func GetCachedRomIDByFilename(fsSlug, filename string) (int, string, bool) {
}
return cm.GetRomIDByFilename(fsSlug, filename)
}
// StoreRomID stores a ROM ID mapping (compatibility wrapper)
func StoreRomID(fsSlug, filename string, romID int, romName string) {
cm := GetCacheManager()
if cm == nil {
return
}
_ = cm.StoreRomID(fsSlug, filename, romID, romName)
}
// ClearRomCache clears the ROM ID cache (compatibility wrapper)
func ClearRomCache() error {
cm := GetCacheManager()
if cm == nil {
return nil
}
return cm.ClearRomIDCache()
}
// HasRomCache checks if ROM cache has data (compatibility wrapper)
func HasRomCache() bool {
cm := GetCacheManager()
if cm == nil {
return false
}
return cm.GetRomIDCacheCount() > 0
}
// GetRomCacheDir returns a placeholder path (compatibility wrapper)
// Note: ROM cache is now in SQLite, this is kept for compatibility
func GetRomCacheDir() string {
return GetArtworkCacheDir()
}
+25 -53
View File
@@ -6,16 +6,13 @@ import (
const schemaVersion = 1
// createTables creates all required database tables
func createTables(db *sql.DB) error {
// Create tables in a transaction
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Cache metadata table - stores schema version and global state
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS cache_metadata (
key TEXT PRIMARY KEY,
@@ -27,13 +24,13 @@ func createTables(db *sql.DB) error {
return err
}
// Platforms table - cached platform data
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS platforms (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL,
fs_slug TEXT NOT NULL,
name TEXT NOT NULL,
api_name TEXT DEFAULT '',
custom_name TEXT DEFAULT '',
rom_count INTEGER DEFAULT 0,
has_bios INTEGER DEFAULT 0,
@@ -51,7 +48,6 @@ func createTables(db *sql.DB) error {
return err
}
// Collections table - cached collection data
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -76,8 +72,6 @@ func createTables(db *sql.DB) error {
return err
}
// Games table - cached ROM/game data
// Stores full JSON to preserve all fields from romm.Rom
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY,
@@ -85,6 +79,10 @@ func createTables(db *sql.DB) error {
platform_fs_slug TEXT NOT NULL,
name TEXT NOT NULL,
fs_name TEXT DEFAULT '',
fs_name_no_ext TEXT DEFAULT '',
crc_hash TEXT DEFAULT '',
md5_hash TEXT DEFAULT '',
sha1_hash TEXT DEFAULT '',
data_json TEXT NOT NULL,
updated_at DATETIME,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -104,7 +102,26 @@ func createTables(db *sql.DB) error {
return err
}
// Game-Collection many-to-many relationship
_, err = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_games_fs_lookup ON games(platform_fs_slug, fs_name_no_ext)`)
if err != nil {
return err
}
_, err = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_games_md5 ON games(md5_hash) WHERE md5_hash != ''`)
if err != nil {
return err
}
_, err = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_games_sha1 ON games(sha1_hash) WHERE sha1_hash != ''`)
if err != nil {
return err
}
_, err = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_games_crc ON games(crc_hash) WHERE crc_hash != ''`)
if err != nil {
return err
}
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS game_collections (
game_id INTEGER NOT NULL,
@@ -116,50 +133,6 @@ func createTables(db *sql.DB) error {
return err
}
// ROM ID cache - maps filenames to ROM IDs for save sync
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS rom_id_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform_fs_slug TEXT NOT NULL,
filename_key TEXT NOT NULL,
rom_id INTEGER NOT NULL,
rom_name TEXT NOT NULL,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(platform_fs_slug, filename_key)
)
`)
if err != nil {
return err
}
_, err = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_rom_id_cache_lookup ON rom_id_cache(platform_fs_slug, filename_key)`)
if err != nil {
return err
}
// Artwork metadata - tracks artwork files on disk
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS artwork_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform_fs_slug TEXT NOT NULL,
rom_id INTEGER NOT NULL,
file_path TEXT NOT NULL,
file_size_bytes INTEGER DEFAULT 0,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
validated_at DATETIME,
UNIQUE(platform_fs_slug, rom_id)
)
`)
if err != nil {
return err
}
_, err = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_artwork_platform_rom ON artwork_metadata(platform_fs_slug, rom_id)`)
if err != nil {
return err
}
// BIOS availability per platform
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS bios_availability (
platform_id INTEGER PRIMARY KEY,
@@ -171,7 +144,6 @@ func createTables(db *sql.DB) error {
return err
}
// Store schema version
_, err = tx.Exec(`
INSERT OR REPLACE INTO cache_metadata (key, value, updated_at)
VALUES ('schema_version', ?, CURRENT_TIMESTAMP)
-1
View File
@@ -15,7 +15,6 @@ const (
ExitCodeCollectionsSettings gaba.ExitCode = 107
ExitCodeAdvancedSettings gaba.ExitCode = 108
ExitCodeBackToAdvanced gaba.ExitCode = 109
ExitCodeClearCache gaba.ExitCode = 110
ExitCodeRefreshCache gaba.ExitCode = 111
ExitCodeSaveSyncSettings gaba.ExitCode = 112
ExitCodeGameOptions gaba.ExitCode = 113
+6 -7
View File
@@ -280,6 +280,9 @@ See [General Settings](#general-settings) below.
**Collections** - Opens a sub-menu for configuring collection display options.
See [Collections Settings](#collections-settings) below.
**Directory Mappings** Change which device directories are mapped to which RomM platforms. This takes you back to
the platform mapping screen that appeared during setup.
**Save Sync** - Controls save synchronization behavior:
- **Off** Save sync is completely disabled
@@ -350,15 +353,11 @@ This sub-menu contains all collection-related configuration:
This sub-menu contains advanced configuration and system settings:
**Directory Mappings** Change which device directories are mapped to which RomM platforms. This takes you back to
the platform mapping screen that appeared during setup.
**Cache Artwork** - Pre-cache artwork for all games across all mapped platforms. Grout scans your platforms, identifies
**Preload Artwork** - Pre-cache artwork for all games across all mapped platforms. Grout scans your platforms, identifies
games without cached artwork, and downloads cover art from RomM. Useful for pre-caching after adding new games.
**Clear Cache** - Clears cached artwork and/or games cache from your device. This will free up storage space but
artwork will need to be re-downloaded as you browse. You'll be prompted to select which caches to clear. Only visible
when there is cached data to clear.
**Refresh Cache** - Re-sync cached data from RomM. Select which caches to refresh: Games Cache (platform and ROM data)
or Collections Cache. Shows when each cache was last refreshed.
**Download Timeout** How long Grout waits for a single ROM to download before giving up. Useful for large files or
slow connections. Options range from 15 to 120 minutes.
+2 -14
View File
@@ -144,21 +144,13 @@ other = "Auswählen"
hash = "sha1-c7f73bb54d928922c3838bb789ee9fb8a5b1eb37"
other = "Einstellungen"
[cache_artwork]
hash = "sha1-1cb834b429143507648837792190c647a34f64ab"
other = "Artwork-Cache"
[cache_artwork_metadata]
other = "Artwork-Metadaten"
[cache_collections]
other = "Sammlungs-Cache"
[cache_games]
hash = "sha1-1e7dbd606b8bad53986d06fd58c9d73dcad67d64"
other = "Spiele-Cache"
[clear_cache_title]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "Cache leeren"
[collection_platform_no_mapped]
hash = "sha1-423fa43d1088dbfa790bd41466ee1193947902cd"
other = "Keine Plattformen mit zugeordneten Spielen in\n{{.Name}}"
@@ -551,10 +543,6 @@ other = "API-Zeitüberschreitung"
hash = "sha1-0eb3d5ab393c2db23feb4e143d5c1987ff52685a"
other = "Box-Art"
[settings_clear_cache]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "Cache leeren"
[settings_collection_view]
hash = "sha1-b5c401da5ed3ead09ad836091786ea351f1a4218"
other = "Sammlungsansicht"
+1 -4
View File
@@ -35,10 +35,8 @@ button_save_sync = "Sync"
button_search = "Search"
button_select = "Select"
button_settings = "Settings"
cache_artwork = "Artwork Cache"
cache_artwork_metadata = "Artwork Metadata"
cache_collections = "Collections Cache"
cache_games = "Games Cache"
clear_cache_title = "Clear Cache"
collection_platform_no_mapped = "No platforms with mapped games in\n{{.Name}}"
collection_platform_title = "{{.Name}} - Platforms"
collection_view_platform = "Platform"
@@ -138,7 +136,6 @@ save_sync_uploaded = "Uploaded"
settings_advanced = "Advanced"
settings_api_timeout = "API Timeout"
settings_box_art = "Box Art"
settings_clear_cache = "Clear Cache"
settings_collection_view = "Collection View"
settings_collections = "Collections"
settings_download_art = "Download Art"
+2 -15
View File
@@ -145,22 +145,13 @@ other = "Seleccionar"
hash = "sha1-c7f73bb54d928922c3838bb789ee9fb8a5b1eb37"
other = "Configuración"
[cache_artwork]
hash = "sha1-1cb834b429143507648837792190c647a34f64ab"
other = "Caché de Artwork"
[cache_collections]
other = "Caché de Colecciones"
[cache_games]
hash = "sha1-1e7dbd606b8bad53986d06fd58c9d73dcad67d64"
other = "Caché de Juegos"
[cache_artwork_metadata]
hash = "sha1-447a12c1a94ffbcefafc528ff6de40d16b247a7d"
other = "Metadatos de Artwork"
[clear_cache_title]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "Borrar Caché"
[collection_platform_no_mapped]
hash = "sha1-423fa43d1088dbfa790bd41466ee1193947902cd"
other = "No hay plataformas con juegos mapeados en\n{{.Name}}"
@@ -553,10 +544,6 @@ other = "Tiempo de Espera de API"
hash = "sha1-0eb3d5ab393c2db23feb4e143d5c1987ff52685a"
other = "Arte de Caja"
[settings_clear_cache]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "Borrar Caché"
[settings_collection_view]
hash = "sha1-b5c401da5ed3ead09ad836091786ea351f1a4218"
other = "Vista de Colección"
+2 -15
View File
@@ -145,22 +145,13 @@ other = "Sélectionner"
hash = "sha1-c7f73bb54d928922c3838bb789ee9fb8a5b1eb37"
other = "Paramètres"
[cache_artwork]
hash = "sha1-1cb834b429143507648837792190c647a34f64ab"
other = "Cache des Artworks"
[cache_collections]
other = "Cache des Collections"
[cache_games]
hash = "sha1-1e7dbd606b8bad53986d06fd58c9d73dcad67d64"
other = "Cache des Jeux"
[cache_artwork_metadata]
hash = "sha1-447a12c1a94ffbcefafc528ff6de40d16b247a7d"
other = "Métadonnées des illustrations"
[clear_cache_title]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "Vider le Cache"
[collection_platform_no_mapped]
hash = "sha1-423fa43d1088dbfa790bd41466ee1193947902cd"
other = "Aucune plateforme ne possède de jeux associés dans in\n{{.Name}}"
@@ -553,10 +544,6 @@ other = "Délai d'attente de l'API"
hash = "sha1-0eb3d5ab393c2db23feb4e143d5c1987ff52685a"
other = "Illustrations"
[settings_clear_cache]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "Vider le Cache"
[settings_collection_view]
hash = "sha1-b5c401da5ed3ead09ad836091786ea351f1a4218"
other = "Vue de Collection"
+2 -15
View File
@@ -145,22 +145,13 @@ other = "Seleziona"
hash = "sha1-c7f73bb54d928922c3838bb789ee9fb8a5b1eb37"
other = "Impostazioni"
[cache_artwork]
hash = "sha1-1cb834b429143507648837792190c647a34f64ab"
other = "Cache Artwork"
[cache_collections]
other = "Cache Collezioni"
[cache_games]
hash = "sha1-1e7dbd606b8bad53986d06fd58c9d73dcad67d64"
other = "Cache Giochi"
[cache_artwork_metadata]
hash = "sha1-447a12c1a94ffbcefafc528ff6de40d16b247a7d"
other = "Metadati Artwork"
[clear_cache_title]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "Svuota Cache"
[collection_platform_no_mapped]
hash = "sha1-423fa43d1088dbfa790bd41466ee1193947902cd"
other = "Nessuna piattaforma con giochi mappati in\n{{.Name}}"
@@ -553,10 +544,6 @@ other = "Timeout API"
hash = "sha1-0eb3d5ab393c2db23feb4e143d5c1987ff52685a"
other = "Copertina"
[settings_clear_cache]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "Svuota Cache"
[settings_collection_view]
hash = "sha1-b5c401da5ed3ead09ad836091786ea351f1a4218"
other = "Vista Collezione"
+2 -15
View File
@@ -145,22 +145,13 @@ other = "選択"
hash = "sha1-c7f73bb54d928922c3838bb789ee9fb8a5b1eb37"
other = "設定"
[cache_artwork]
hash = "sha1-1cb834b429143507648837792190c647a34f64ab"
other = "アートワークキャッシュ"
[cache_collections]
other = "コレクションキャッシュ"
[cache_games]
hash = "sha1-1e7dbd606b8bad53986d06fd58c9d73dcad67d64"
other = "ゲームキャッシュ"
[cache_artwork_metadata]
hash = "sha1-447a12c1a94ffbcefafc528ff6de40d16b247a7d"
other = "アートワークメタデータ"
[clear_cache_title]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "キャッシュをクリア"
[collection_platform_no_mapped]
hash = "sha1-423fa43d1088dbfa790bd41466ee1193947902cd"
other = "{{.Name}}にマッピングされたゲームのある\nプラットフォームがありません"
@@ -553,10 +544,6 @@ other = "APIタイムアウト"
hash = "sha1-0eb3d5ab393c2db23feb4e143d5c1987ff52685a"
other = "ボックスアート"
[settings_clear_cache]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "キャッシュをクリア"
[settings_collection_view]
hash = "sha1-b5c401da5ed3ead09ad836091786ea351f1a4218"
other = "コレクション表示"
+2 -15
View File
@@ -145,22 +145,13 @@ other = "Selecionar"
hash = "sha1-c7f73bb54d928922c3838bb789ee9fb8a5b1eb37"
other = "Configurações"
[cache_artwork]
hash = "sha1-1cb834b429143507648837792190c647a34f64ab"
other = "Cache de Artwork"
[cache_collections]
other = "Cache de Coleções"
[cache_games]
hash = "sha1-1e7dbd606b8bad53986d06fd58c9d73dcad67d64"
other = "Cache de Jogos"
[cache_artwork_metadata]
hash = "sha1-447a12c1a94ffbcefafc528ff6de40d16b247a7d"
other = "Metadados de Artwork"
[clear_cache_title]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "Limpar Cache"
[collection_platform_no_mapped]
hash = "sha1-423fa43d1088dbfa790bd41466ee1193947902cd"
other = "Nenhuma plataforma com jogos mapeados em\n{{.Name}}"
@@ -553,10 +544,6 @@ other = "Timeout da API"
hash = "sha1-0eb3d5ab393c2db23feb4e143d5c1987ff52685a"
other = "Capa"
[settings_clear_cache]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "Limpar Cache"
[settings_collection_view]
hash = "sha1-b5c401da5ed3ead09ad836091786ea351f1a4218"
other = "Visualização de Coleção"
+2 -15
View File
@@ -145,22 +145,13 @@ other = "Выбрать"
hash = "sha1-c7f73bb54d928922c3838bb789ee9fb8a5b1eb37"
other = "Настройки"
[cache_artwork]
hash = "sha1-1cb834b429143507648837792190c647a34f64ab"
other = "Кэш обложек"
[cache_collections]
other = "Кэш коллекций"
[cache_games]
hash = "sha1-1e7dbd606b8bad53986d06fd58c9d73dcad67d64"
other = "Кэш игр"
[cache_artwork_metadata]
hash = "sha1-447a12c1a94ffbcefafc528ff6de40d16b247a7d"
other = "Метаданные обложек"
[clear_cache_title]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "Очистить кэш"
[collection_platform_no_mapped]
hash = "sha1-423fa43d1088dbfa790bd41466ee1193947902cd"
other = "Нет платформ с сопоставленными играми в\n{{.Name}}"
@@ -553,10 +544,6 @@ other = "Тайм-аут API"
hash = "sha1-0eb3d5ab393c2db23feb4e143d5c1987ff52685a"
other = "Обложка"
[settings_clear_cache]
hash = "sha1-522175083f0bf50b4584d1dd8dbed60ae22535be"
other = "Очистить кэш"
[settings_collection_view]
hash = "sha1-b5c401da5ed3ead09ad836091786ea351f1a4218"
other = "Вид коллекции"
+4 -1
View File
@@ -10,6 +10,7 @@ type Platform struct {
Slug string `json:"slug"`
FSSlug string `json:"fs_slug"`
Name string `json:"name"`
ApiName string `json:"-"` // Original name from API (not serialized, set by DisambiguatePlatformNames)
CustomName string `json:"custom_name"`
ShortName string `json:"short_name"`
LogoPath string `json:"logo_path"`
@@ -48,10 +49,12 @@ func (c *Client) GetPlatform(id int) (Platform, error) {
// DisambiguatePlatformNames sets each platform's Name field to its display name
// (preferring CustomName if set), and appends the FSSlug when multiple platforms
// share the same display name (e.g., "Arcade" becomes "Arcade (fbneo)")
// The original API name is preserved in ApiName before modification.
func DisambiguatePlatformNames(platforms []Platform) {
// First pass: set Name to DisplayName and count occurrences
// First pass: save original API name, set Name to DisplayName, and count occurrences
nameCounts := make(map[string]int)
for i := range platforms {
platforms[i].ApiName = platforms[i].Name // Preserve original API name
platforms[i].Name = platforms[i].DisplayName()
nameCounts[platforms[i].Name]++
}
+24 -59
View File
@@ -5,7 +5,6 @@ import (
"grout/cache"
"grout/internal"
"grout/internal/fileutil"
"grout/internal/stringutil"
"grout/romm"
"os"
"path/filepath"
@@ -192,25 +191,16 @@ func (s *SaveSync) upload(host romm.Host, config *internal.Config) (string, erro
return s.Local.Path, nil
}
// lookupRomID looks up a ROM ID by filename, first checking cache then the provided ROM map
func lookupRomID(romFile *LocalRomFile, romsByFilename map[string]romm.Rom) (int, string) {
// lookupRomID looks up a ROM ID by filename from the cache
func lookupRomID(romFile *LocalRomFile) (int, string) {
logger := gaba.GetLogger()
// Check cache first
// Look up from the games cache
if romID, romName, found := cache.GetCachedRomIDByFilename(romFile.FSSlug, romFile.FileName); found {
logger.Debug("ROM lookup from cache", "fsSlug", romFile.FSSlug, "file", romFile.FileName, "romID", romID, "name", romName)
return romID, romName
}
// Look up in the ROM map by filename (without extension)
key := stringutil.StripExtension(romFile.FileName)
if rom, found := romsByFilename[key]; found {
// Cache the result for next time
cache.StoreRomID(romFile.FSSlug, romFile.FileName, rom.ID, rom.Name)
logger.Debug("ROM lookup from RomM", "fsSlug", romFile.FSSlug, "file", romFile.FileName, "romID", rom.ID, "name", rom.Name)
return rom.ID, rom.Name
}
logger.Debug("No ROM found for file", "fsSlug", romFile.FSSlug, "file", romFile.FileName)
return 0, ""
}
@@ -228,11 +218,21 @@ func FindSaveSyncsFromScan(host romm.Host, config *internal.Config, scanLocal Lo
logger.Debug("FindSaveSyncs: Scanned local ROMs", "platformCount", len(scanLocal))
// Get all platforms to build fsSlug -> platformID map
platforms, err := rc.GetPlatforms()
if err != nil {
logger.Error("FindSaveSyncs: Could not retrieve platforms", "error", err)
return []SaveSync{}, nil, err
// Get platforms from cache or API to build fsSlug -> platformID map
cm := cache.GetCacheManager()
var platforms []romm.Platform
var err error
if cm != nil {
platforms, err = cm.GetPlatforms()
}
if err != nil || len(platforms) == 0 {
// Fall back to API if cache miss
platforms, err = rc.GetPlatforms()
if err != nil {
logger.Error("FindSaveSyncs: Could not retrieve platforms", "error", err)
return []SaveSync{}, nil, err
}
}
fsSlugToPlatformID := make(map[string]int)
@@ -240,11 +240,10 @@ func FindSaveSyncsFromScan(host romm.Host, config *internal.Config, scanLocal Lo
fsSlugToPlatformID[p.FSSlug] = p.ID
}
// Fetch saves and ROMs per platform in parallel
// Fetch saves per platform in parallel (saves are not cached - always fresh from API)
type platformFetchResult struct {
fsSlug string
saves []romm.Save
roms map[string]romm.Rom
hasError bool
}
@@ -264,10 +263,9 @@ func FindSaveSyncsFromScan(host romm.Host, config *internal.Config, scanLocal Lo
result := platformFetchResult{
fsSlug: fsSlug,
roms: make(map[string]romm.Rom),
}
// Fetch saves for this platform
// Fetch saves for this platform (always from API - saves need to be fresh)
platformSaves, err := rc.GetSaves(romm.SaveQuery{PlatformID: platformID})
if err != nil {
logger.Warn("FindSaveSyncs: Could not retrieve saves for platform", "fsSlug", fsSlug, "error", err)
@@ -278,31 +276,6 @@ func FindSaveSyncsFromScan(host romm.Host, config *internal.Config, scanLocal Lo
result.saves = platformSaves
logger.Debug("FindSaveSyncs: Retrieved saves for platform", "fsSlug", fsSlug, "count", len(platformSaves))
// Fetch all ROMs for this platform to build filename map
offset := 0
for {
romsPage, err := rc.GetRoms(romm.GetRomsQuery{
PlatformID: platformID,
Offset: offset,
Limit: 100,
})
if err != nil {
logger.Warn("FindSaveSyncs: Could not retrieve ROMs for platform", "fsSlug", fsSlug, "error", err)
break
}
for _, rom := range romsPage.Items {
key := stringutil.StripExtension(rom.FsNameNoExt)
result.roms[key] = rom
}
if len(romsPage.Items) < 100 || len(result.roms) >= romsPage.Total {
break
}
offset += len(romsPage.Items)
}
logger.Debug("FindSaveSyncs: Built ROM filename map", "fsSlug", fsSlug, "count", len(result.roms))
resultChan <- result
}(fsSlug, platformID)
}
@@ -312,10 +285,8 @@ func FindSaveSyncsFromScan(host romm.Host, config *internal.Config, scanLocal Lo
close(resultChan)
}()
// Collect results
// Collect saves by ROM ID
savesByRomID := make(map[int][]romm.Save)
romsByFilename := make(map[string]map[string]romm.Rom)
for result := range resultChan {
if result.hasError {
continue
@@ -324,18 +295,11 @@ func FindSaveSyncsFromScan(host romm.Host, config *internal.Config, scanLocal Lo
for _, s := range result.saves {
savesByRomID[s.RomID] = append(savesByRomID[s.RomID], s)
}
romsByFilename[result.fsSlug] = result.roms
}
// Match local ROMs to remote ROMs by filename
// Match local ROMs to cached ROMs by filename
var unmatched []UnmatchedSave
for fsSlug, localRoms := range scanLocal {
platformRoms := romsByFilename[fsSlug]
if platformRoms == nil {
platformRoms = make(map[string]romm.Rom)
}
for idx := range localRoms {
romFile := &scanLocal[fsSlug][idx]
@@ -344,7 +308,8 @@ func FindSaveSyncsFromScan(host romm.Host, config *internal.Config, scanLocal Lo
continue
}
romID, romName := lookupRomID(romFile, platformRoms)
// Look up ROM ID from the games cache
romID, romName := lookupRomID(romFile)
if romID == 0 {
if romFile.SaveFile != nil {
-25
View File
@@ -2,8 +2,6 @@ package ui
import (
"errors"
"grout/artwork"
"grout/cache"
"grout/constants"
"grout/internal"
"grout/romm"
@@ -22,8 +20,6 @@ type AdvancedSettingsInput struct {
}
type AdvancedSettingsOutput struct {
EditMappingsClicked bool
ClearCacheClicked bool
RefreshCacheClicked bool
SyncArtworkClicked bool
LastSelectedIndex int
@@ -74,16 +70,6 @@ func (s *AdvancedSettingsScreen) Draw(input AdvancedSettingsInput) (ScreenResult
if result.Action == gaba.ListActionSelected {
selectedText := items[result.Selected].Item.Text
if selectedText == i18n.Localize(&goi18n.Message{ID: "settings_edit_mappings", Other: "Directory Mappings"}, nil) {
output.EditMappingsClicked = true
return withCode(output, constants.ExitCodeEditMappings), nil
}
if selectedText == i18n.Localize(&goi18n.Message{ID: "settings_clear_cache", Other: "Clear Cache"}, nil) {
output.ClearCacheClicked = true
return withCode(output, constants.ExitCodeClearCache), nil
}
if selectedText == i18n.Localize(&goi18n.Message{ID: "settings_refresh_cache", Other: "Refresh Cache"}, nil) {
output.RefreshCacheClicked = true
return withCode(output, constants.ExitCodeRefreshCache), nil
@@ -108,21 +94,10 @@ func (s *AdvancedSettingsScreen) Draw(input AdvancedSettingsInput) (ScreenResult
func (s *AdvancedSettingsScreen) buildMenuItems(config *internal.Config) []gaba.ItemWithOptions {
return []gaba.ItemWithOptions{
{
Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_edit_mappings", Other: "Directory Mappings"}, nil)},
Options: []gaba.Option{{Type: gaba.OptionTypeClickable}},
},
{
Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_sync_artwork", Other: "Preload Artwork"}, nil)},
Options: []gaba.Option{{Type: gaba.OptionTypeClickable}},
},
{
Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_clear_cache", Other: "Clear Cache"}, nil)},
Options: []gaba.Option{{Type: gaba.OptionTypeClickable}},
Visible: func() bool {
return artwork.HasCache() || cache.HasGamesCache()
},
},
{
Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_refresh_cache", Other: "Refresh Cache"}, nil)},
Options: []gaba.Option{{Type: gaba.OptionTypeClickable}},
+4 -5
View File
@@ -2,7 +2,6 @@ package ui
import (
"fmt"
"grout/artwork"
"grout/cache"
"grout/internal"
"grout/internal/imageutil"
@@ -106,7 +105,7 @@ func (s *ArtworkSyncScreen) draw(input ArtworkSyncInput) {
}
// Get all ROMs with artwork URLs (download everything)
withArtwork := artwork.GetWithArtwork(roms)
withArtwork := cache.GetRomsWithArtwork(roms)
allWithArtwork = append(allWithArtwork, withArtwork...)
return nil, nil
},
@@ -128,16 +127,16 @@ func (s *ArtworkSyncScreen) draw(input ArtworkSyncInput) {
baseURL := input.Host.URL()
for _, rom := range allWithArtwork {
coverPath := artwork.GetCoverPath(rom)
coverPath := cache.GetArtworkCoverPath(rom)
if coverPath == "" {
continue
}
downloadURL := strings.ReplaceAll(baseURL+coverPath, " ", "%20")
cachePath := artwork.GetCachePath(rom.PlatformFSSlug, rom.ID)
cachePath := cache.GetArtworkCachePath(rom.PlatformFSSlug, rom.ID)
// Ensure directory exists
artwork.EnsureCacheDir(rom.PlatformFSSlug)
cache.EnsureArtworkCacheDir(rom.PlatformFSSlug)
downloads = append(downloads, gaba.Download{
URL: downloadURL,
-107
View File
@@ -1,107 +0,0 @@
package ui
import (
"errors"
"grout/artwork"
"grout/cache"
"time"
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
icons "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool/constants"
"github.com/BrandonKowalski/gabagool/v2/pkg/gabagool/i18n"
goi18n "github.com/nicksnyder/go-i18n/v2/i18n"
)
type ClearCacheOutput struct {
ClearedCount int
}
type ClearCacheScreen struct{}
func NewClearCacheScreen() *ClearCacheScreen {
return &ClearCacheScreen{}
}
type cacheItem struct {
name string
hasCache bool
clear func() error
}
func (s *ClearCacheScreen) Draw() (ScreenResult[ClearCacheOutput], error) {
output := ClearCacheOutput{}
caches := []cacheItem{
{
name: i18n.Localize(&goi18n.Message{ID: "cache_artwork", Other: "Artwork Cache"}, nil),
hasCache: artwork.HasCache(),
clear: artwork.ClearCache,
},
{
name: i18n.Localize(&goi18n.Message{ID: "cache_games", Other: "Games Cache"}, nil),
hasCache: cache.HasGamesCache(),
clear: cache.ClearGamesCache,
},
}
// Build menu items for caches that exist
items := make([]gaba.MenuItem, 0)
availableCaches := make([]cacheItem, 0)
for _, cache := range caches {
if cache.hasCache {
items = append(items, gaba.MenuItem{Text: cache.name})
availableCaches = append(availableCaches, cache)
}
}
if len(items) == 0 {
// No caches to clear
return back(output), nil
}
options := gaba.DefaultListOptions(
i18n.Localize(&goi18n.Message{ID: "clear_cache_title", Other: "Clear Cache"}, nil),
items,
)
options.FooterHelpItems = []gaba.FooterHelpItem{
FooterCancel(),
{ButtonName: icons.Start, HelpText: i18n.Localize(&goi18n.Message{ID: "button_confirm", Other: "Confirm"}, nil), IsConfirmButton: true},
}
options.StartInMultiSelectMode = true
options.StatusBar = StatusBar()
options.SmallTitle = true
result, err := gaba.List(options)
if err != nil {
if errors.Is(err, gaba.ErrCancelled) {
return back(output), nil
}
return withCode(output, gaba.ExitCodeError), err
}
for _, idx := range result.Selected {
if idx >= 0 && idx < len(availableCaches) {
cache := availableCaches[idx]
if err := cache.clear(); err != nil {
gaba.GetLogger().Error("Failed to clear cache", "cache", cache.name, "error", err)
} else {
output.ClearedCount++
gaba.GetLogger().Info("Cleared cache", "cache", cache.name)
}
}
}
if output.ClearedCount > 0 {
gaba.ProcessMessage(
i18n.Localize(&goi18n.Message{ID: "cache_cleared", Other: "Cache cleared!"}, nil),
gaba.ProcessMessageOptions{},
func() (interface{}, error) {
time.Sleep(time.Second * 1)
return nil, nil
},
)
}
return success(output), nil
}
-13
View File
@@ -3,7 +3,6 @@ package ui
import (
"errors"
"fmt"
"grout/cache"
"grout/cfw"
"grout/cfw/muos"
"grout/constants"
@@ -267,18 +266,6 @@ func (s *DownloadScreen) draw(input downloadInput) (ScreenResult[downloadOutput]
return d.DisplayName == g.Name
}) {
downloadedGames = append(downloadedGames, g)
// Cache ROM filename for future save sync lookups
var cacheFilename string
if g.HasMultipleFiles {
cacheFilename = g.FsNameNoExt
} else if len(g.Files) > 0 {
cacheFilename = g.Files[0].FileName
}
if cacheFilename != "" {
cache.StoreRomID(g.PlatformFSSlug, cacheFilename, g.ID, g.Name)
logger.Debug("Cached ROM filename", "name", g.Name, "filename", cacheFilename, "id", g.ID)
}
}
}
+5 -6
View File
@@ -3,7 +3,7 @@ package ui
import (
"errors"
"fmt"
"grout/artwork"
"grout/cache"
"grout/internal"
"grout/internal/imageutil"
"grout/internal/stringutil"
@@ -205,8 +205,8 @@ func (s *GameDetailsScreen) getCoverImagePath(host romm.Host, game romm.Rom) str
logger := gaba.GetLogger()
// First, check if artwork is in the cache
if artwork.Exists(game.PlatformFSSlug, game.ID) {
cachePath := artwork.GetCachePath(game.PlatformFSSlug, game.ID)
if cache.ArtworkExists(game.PlatformFSSlug, game.ID) {
cachePath := cache.GetArtworkCachePath(game.PlatformFSSlug, game.ID)
logger.Debug("Using cached artwork for game details", "game", game.Name)
return cachePath
}
@@ -228,11 +228,10 @@ func (s *GameDetailsScreen) getCoverImagePath(host romm.Host, game romm.Rom) str
// Cache the artwork for future use and return cache path
if imageData != nil {
if err := artwork.EnsureCacheDir(game.PlatformFSSlug); err == nil {
cachePath := artwork.GetCachePath(game.PlatformFSSlug, game.ID)
if err := cache.EnsureArtworkCacheDir(game.PlatformFSSlug); err == nil {
cachePath := cache.GetArtworkCachePath(game.PlatformFSSlug, game.ID)
if err := os.WriteFile(cachePath, imageData, 0644); err == nil {
imageutil.ProcessArtImage(cachePath)
artwork.MarkCached(game.PlatformFSSlug, game.ID)
return cachePath
}
}
+2 -3
View File
@@ -3,7 +3,6 @@ package ui
import (
"errors"
"fmt"
"grout/artwork"
"grout/cache"
"grout/constants"
"grout/internal"
@@ -75,7 +74,7 @@ func (s *GameListScreen) Draw(input GameListInput) (ScreenResult[GameListOutput]
hasBIOS = loaded.hasBIOS
if input.Config.ShowBoxArt {
go artwork.SyncInBackground(input.Host, games)
go cache.SyncArtworkInBackground(input.Host, games)
}
}
@@ -173,7 +172,7 @@ func (s *GameListScreen) Draw(input GameListInput) (ScreenResult[GameListOutput]
for i, game := range displayGames {
imageFilename := ""
if input.Config.ShowBoxArt {
imageFilename = artwork.GetCachePath(game.PlatformFSSlug, game.ID)
imageFilename = cache.GetArtworkCachePath(game.PlatformFSSlug, game.ID)
}
menuItems[i] = gaba.MenuItem{
Text: game.DisplayName,
-8
View File
@@ -18,7 +18,6 @@ type RefreshCacheType int
const (
RefreshCacheGames RefreshCacheType = iota
RefreshCacheCollections
RefreshCacheArtwork
)
type RefreshCacheOutput struct {
@@ -65,13 +64,6 @@ func (s *RefreshCacheScreen) Draw() (ScreenResult[RefreshCacheOutput], error) {
metaKey: cache.MetaKeyCollectionsRefreshedAt,
lastRefresh: refreshTimes[cache.MetaKeyCollectionsRefreshedAt],
},
{
name: i18n.Localize(&goi18n.Message{ID: "cache_artwork_metadata", Other: "Artwork Metadata"}, nil),
cacheType: RefreshCacheArtwork,
hasCache: true, // Artwork can always be refreshed
metaKey: cache.MetaKeyArtworkRefreshedAt,
lastRefresh: refreshTimes[cache.MetaKeyArtworkRefreshedAt],
},
}
// Build menu items for caches that exist
+14
View File
@@ -31,6 +31,7 @@ type SettingsOutput struct {
GeneralSettingsClicked bool
InfoClicked bool
CollectionsSettingsClicked bool
DirectoryMappingsClicked bool
AdvancedSettingsClicked bool
SaveSyncSettingsClicked bool
CheckUpdatesClicked bool
@@ -49,6 +50,7 @@ type SettingType string
const (
SettingGeneralSettings SettingType = "general_settings"
SettingCollectionsSettings SettingType = "collections_settings"
SettingDirectoryMappings SettingType = "directory_mappings"
SettingSaveSync SettingType = "save_sync"
SettingSaveSyncSettings SettingType = "save_sync_settings"
SettingAdvancedSettings SettingType = "advanced_settings"
@@ -59,6 +61,7 @@ const (
var settingsOrder = []SettingType{
SettingGeneralSettings,
SettingCollectionsSettings,
SettingDirectoryMappings,
SettingSaveSync,
SettingSaveSyncSettings,
SettingAdvancedSettings,
@@ -119,6 +122,11 @@ func (s *SettingsScreen) Draw(input SettingsInput) (ScreenResult[SettingsOutput]
return withCode(output, constants.ExitCodeCollectionsSettings), nil
}
if selectedText == i18n.Localize(&goi18n.Message{ID: "settings_edit_mappings", Other: "Directory Mappings"}, nil) {
output.DirectoryMappingsClicked = true
return withCode(output, constants.ExitCodeEditMappings), nil
}
if selectedText == i18n.Localize(&goi18n.Message{ID: "settings_advanced", Other: "Advanced"}, nil) {
output.AdvancedSettingsClicked = true
return withCode(output, constants.ExitCodeAdvancedSettings), nil
@@ -161,6 +169,12 @@ func (s *SettingsScreen) buildMenuItem(settingType SettingType, config *internal
Options: []gaba.Option{{Type: gaba.OptionTypeClickable}},
}
case SettingDirectoryMappings:
return gaba.ItemWithOptions{
Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_edit_mappings", Other: "Directory Mappings"}, nil)},
Options: []gaba.Option{{Type: gaba.OptionTypeClickable}},
}
case SettingSaveSync:
return gaba.ItemWithOptions{
Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_save_sync", Other: "Save Sync"}, nil)},