Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions app/states.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui
// If multiple games selected, skip details and go straight to download
if len(gameListOutput.SelectedGames) != 1 {
downloadScreen := ui.NewDownloadScreen()
downloadOutput := downloadScreen.Execute(*config, host, gameListOutput.Platform, gameListOutput.SelectedGames, gameListOutput.AllGames, nav.SearchFilter)
downloadOutput := downloadScreen.Execute(*config, host, gameListOutput.Platform, gameListOutput.SelectedGames, gameListOutput.AllGames, nav.SearchFilter, 0)
nav.CurrentGames = downloadOutput.AllGames
nav.SearchFilter = downloadOutput.SearchFilter
triggerAutoSync()
Expand All @@ -414,23 +414,22 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui

return result.Value, result.ExitCode
}).
OnWithHook(gaba.ExitCodeSuccess, gameList, func(ctx *gaba.Context) error {
OnWithHook(constants.ExitCodeDownloadRequested, gameDetails, func(ctx *gaba.Context) error {
detailsOutput, _ := gaba.Get[ui.GameDetailsOutput](ctx)
config, _ := gaba.Get[*internal.Config](ctx)
host, _ := gaba.Get[romm.Host](ctx)
gameListOutput, _ := gaba.Get[ui.GameListOutput](ctx)
nav, _ := gaba.Get[*NavState](ctx)

if detailsOutput.DownloadRequested {
downloadScreen := ui.NewDownloadScreen()
downloadOutput := downloadScreen.Execute(*config, host, detailsOutput.Platform, []romm.Rom{detailsOutput.Game}, gameListOutput.AllGames, nav.SearchFilter)
nav.CurrentGames = downloadOutput.AllGames
nav.SearchFilter = downloadOutput.SearchFilter
triggerAutoSync()
}
downloadScreen := ui.NewDownloadScreen()
downloadOutput := downloadScreen.Execute(*config, host, detailsOutput.Platform, []romm.Rom{detailsOutput.Game}, gameListOutput.AllGames, nav.SearchFilter, detailsOutput.SelectedFileID)
nav.CurrentGames = downloadOutput.AllGames
nav.SearchFilter = downloadOutput.SearchFilter
triggerAutoSync()

return nil
}).
On(gaba.ExitCodeSuccess, gameList).
On(gaba.ExitCodeBack, gameList).
On(constants.ExitCodeGameOptions, gameOptions)

Expand Down Expand Up @@ -876,7 +875,8 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui
return nil
}

// Update platforms in context
// Apply custom platform order and update in context
platforms = internal.SortPlatformsByOrder(platforms, config.PlatformOrder)
gaba.Set(ctx, platforms)

// Re-populate cache with progress
Expand Down
7 changes: 1 addition & 6 deletions cache/games.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,8 @@ func (cm *Manager) SavePlatformGames(platformID int, games []romm.Rom) error {
}
defer tx.Rollback()

_, 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, fs_name_no_ext, crc_hash, md5_hash, sha1_hash, data_json, updated_at, cached_at)
INSERT OR REPLACE 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 {
Expand Down
10 changes: 5 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ module grout
go 1.24.2

require (
github.com/BrandonKowalski/gabagool/v2 v2.5.3
github.com/BrandonKowalski/gabagool/v2 v2.6.0
github.com/UncleJunVIP/certifiable v1.2.0
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/piglig/go-qr v0.2.6
github.com/sonh/qs v0.6.4
go.uber.org/atomic v1.11.0
golang.org/x/image v0.34.0
modernc.org/sqlite v1.42.2
modernc.org/sqlite v1.43.0
)

require (
Expand All @@ -27,9 +27,9 @@ require (
github.com/veandco/go-sdl2 v0.4.40 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
)
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ github.com/BrandonKowalski/gabagool/v2 v2.5.2 h1:w835LTPQ/EscGDdCgrfTDjoGTQkIkhb
github.com/BrandonKowalski/gabagool/v2 v2.5.2/go.mod h1:GMra4TXqVze081BlRc4PYCwEk+kP7vh3h9X5PHOAjPg=
github.com/BrandonKowalski/gabagool/v2 v2.5.3 h1:pQ7oVvKYbD1P9eIKXao/3A2YnjvB/8PVgby9ViPETkM=
github.com/BrandonKowalski/gabagool/v2 v2.5.3/go.mod h1:GMra4TXqVze081BlRc4PYCwEk+kP7vh3h9X5PHOAjPg=
github.com/BrandonKowalski/gabagool/v2 v2.6.0 h1:ciimVltlZR2y5B03FmQZUdWSj4NL5rGUwDZrnasa2zk=
github.com/BrandonKowalski/gabagool/v2 v2.6.0/go.mod h1:GMra4TXqVze081BlRc4PYCwEk+kP7vh3h9X5PHOAjPg=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/UncleJunVIP/certifiable v1.2.0 h1:xGqpc7U9FafWPqJ/sQreOQRPj8fv0Y4x9EBdfa2ic3Q=
Expand Down Expand Up @@ -59,8 +61,12 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down Expand Up @@ -89,6 +95,8 @@ modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA=
modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
Expand Down
1 change: 1 addition & 0 deletions internal/constants/exit_codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const (
ExitCodeGameOptions gaba.ExitCode = 113
ExitCodeGeneralSettings gaba.ExitCode = 114
ExitCodeCheckUpdate gaba.ExitCode = 115
ExitCodeDownloadRequested gaba.ExitCode = 116
ExitCodeSearch gaba.ExitCode = 200
ExitCodeClearSearch gaba.ExitCode = 201
ExitCodeCollections gaba.ExitCode = 300
Expand Down
4 changes: 4 additions & 0 deletions internal/constants/icons.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package constants

const MultipleFiles = "\U000F0222"
const MultipleDownloaded = "\U000F09E9"
19 changes: 19 additions & 0 deletions internal/fileutil/fileutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"archive/zip"
"bufio"
"fmt"
"hash/crc32"
"io"
"os"
"path/filepath"
Expand Down Expand Up @@ -184,3 +185,21 @@ func FilterHiddenDirectories(entries []os.DirEntry) []os.DirEntry {
}
return result
}

// ComputeCRC32 computes the CRC32 hash of a file and returns it as an uppercase hex string
func ComputeCRC32(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()

hash := crc32.NewIEEE()
buffer := make([]byte, DefaultBufferSize)

if _, err := io.CopyBuffer(hash, file, buffer); err != nil {
return "", fmt.Errorf("failed to compute hash: %w", err)
}

return fmt.Sprintf("%08X", hash.Sum32()), nil
}
42 changes: 39 additions & 3 deletions romm/roms.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,45 @@ func (r Rom) GetLocalPath(resolver PlatformDirResolver) string {
}

func (r Rom) IsDownloaded(resolver PlatformDirResolver) bool {
path := r.GetLocalPath(resolver)
if path == "" {
if r.PlatformFSSlug == "" {
return false
}
return fileutil.FileExists(path)

platform := Platform{
ID: r.PlatformID,
FSSlug: r.PlatformFSSlug,
Name: r.PlatformDisplayName,
}
romDirectory := resolver.GetPlatformRomDirectory(platform)

// For multi-disk games, check the m3u file
if r.HasMultipleFiles {
m3uPath := filepath.Join(romDirectory, r.FsNameNoExt+".m3u")
return fileutil.FileExists(m3uPath)
}

// Check if any of the associated files exist
for _, file := range r.Files {
filePath := filepath.Join(romDirectory, file.FileName)
if fileutil.FileExists(filePath) {
return true
}
}

return false
}

func (r Rom) IsFileDownloaded(resolver PlatformDirResolver, fileName string) bool {
if r.PlatformFSSlug == "" {
return false
}

platform := Platform{
ID: r.PlatformID,
FSSlug: r.PlatformFSSlug,
Name: r.PlatformDisplayName,
}
romDirectory := resolver.GetPlatformRomDirectory(platform)
filePath := filepath.Join(romDirectory, fileName)
return fileutil.FileExists(filePath)
}
60 changes: 55 additions & 5 deletions sync/roms.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"grout/romm"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
gosync "sync"
Expand All @@ -16,18 +17,38 @@ import (
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
)

// timestampPattern matches the timestamp suffix appended to save files
// Format: " [YYYY-MM-DD HH-MM-SS-mmm]" e.g., " [2024-01-02 15-04-05-000]"
var timestampPattern = regexp.MustCompile(` \[\d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2}-\d{3}\]$`)

// extractSaveBaseName strips the timestamp suffix from a remote save's filename
// to get the original base name for comparison with local saves.
// e.g., "Pokemon Red [2024-01-02 15-04-05-000]" -> "Pokemon Red"
func extractSaveBaseName(fileNameNoExt string) string {
return timestampPattern.ReplaceAllString(fileNameNoExt, "")
}

type LocalRomFile struct {
RomID int
RomName string
FSSlug string
FileName string
FilePath string
RemoteSaves []romm.Save
SaveFile *LocalSave
}

// baseName returns the ROM filename without extension, used for matching saves
func (lrf LocalRomFile) baseName() string {
return strings.TrimSuffix(lrf.FileName, filepath.Ext(lrf.FileName))
}

func (lrf LocalRomFile) syncAction() SyncAction {
hasLocal := lrf.SaveFile != nil
hasRemote := len(lrf.RemoteSaves) > 0
baseName := lrf.baseName()

// Check for remote saves that match this ROM's base name
hasRemote := lrf.hasRemoteSaveForBaseName(baseName)

switch {
case !hasLocal && !hasRemote:
Expand All @@ -42,7 +63,8 @@ func (lrf LocalRomFile) syncAction() SyncAction {
// Truncate to second precision to avoid timestamp precision issues
// API timestamps are typically second/millisecond precision, but filesystem is nanosecond
localTime := lrf.SaveFile.LastModified.Truncate(time.Second)
remoteTime := lrf.lastRemoteSave().UpdatedAt.Truncate(time.Second)
remoteSave := lrf.lastRemoteSaveForBaseName(baseName)
remoteTime := remoteSave.UpdatedAt.Truncate(time.Second)

switch localTime.Compare(remoteTime) {
case -1:
Expand All @@ -54,16 +76,43 @@ func (lrf LocalRomFile) syncAction() SyncAction {
}
}

func (lrf LocalRomFile) lastRemoteSave() romm.Save {
// lastRemoteSaveForBaseName returns the most recent remote save that matches
// the given base name (after stripping timestamps from remote save filenames).
// This allows multiple local ROM files with different names but the same CRC32
// to each sync with their own set of remote saves.
func (lrf LocalRomFile) lastRemoteSaveForBaseName(baseName string) romm.Save {
if len(lrf.RemoteSaves) == 0 {
return romm.Save{}
}

slices.SortFunc(lrf.RemoteSaves, func(s1 romm.Save, s2 romm.Save) int {
// Filter saves to only those matching the base name
var matching []romm.Save
for _, s := range lrf.RemoteSaves {
remoteBaseName := extractSaveBaseName(s.FileNameNoExt)
if remoteBaseName == baseName {
matching = append(matching, s)
}
}

if len(matching) == 0 {
return romm.Save{}
}

slices.SortFunc(matching, func(s1 romm.Save, s2 romm.Save) int {
return s2.UpdatedAt.Compare(s1.UpdatedAt)
})

return lrf.RemoteSaves[0]
return matching[0]
}

// hasRemoteSaveForBaseName checks if there's any remote save matching the given base name
func (lrf LocalRomFile) hasRemoteSaveForBaseName(baseName string) bool {
for _, s := range lrf.RemoteSaves {
if extractSaveBaseName(s.FileNameNoExt) == baseName {
return true
}
}
return false
}

// LocalRomScan holds the results of scanning local ROMs, keyed by platform fs_slug
Expand Down Expand Up @@ -247,6 +296,7 @@ func scanRomDirectory(fsSlug, romDir string, saveFileMap map[string]*LocalSave)
rom := LocalRomFile{
FSSlug: fsSlug,
FileName: entry.Name(),
FilePath: filepath.Join(romDir, entry.Name()),
SaveFile: saveFile,
}

Expand Down
Loading