From cb70b7d2f717038bc0e07f31e608f73ba6bd104b Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Thu, 5 Mar 2026 17:10:23 +0800 Subject: [PATCH 1/3] perf(mediascanner): use fastwalk for parallel directory walking Replace filepath.WalkDir with charlievieth/fastwalk for parallel directory traversal in GetFiles(). fastwalk walks subdirectories concurrently, skips per-directory sorting, and uses raw syscalls to reduce overhead. Also enables built-in symlink following with cycle detection, replacing the manual visited map and recursive walk logic. Remove dirCache (dircache.go) which cached root directory listings during path discovery. The kernel page cache already makes repeat ReadDir calls on the same directory essentially free, making the userspace cache redundant. GetSystemPaths now uses FindPath uniformly for all folder lookups, which is simpler and still timeout-protected via fsutil.go wrappers for stale mount handling. --- go.mod | 1 + go.sum | 2 + pkg/database/mediascanner/dircache.go | 78 ------ pkg/database/mediascanner/dircache_test.go | 123 --------- pkg/database/mediascanner/mediascanner.go | 287 +++++---------------- 5 files changed, 70 insertions(+), 421 deletions(-) delete mode 100644 pkg/database/mediascanner/dircache.go delete mode 100644 pkg/database/mediascanner/dircache_test.go diff --git a/go.mod b/go.mod index 64ede8f2..fe4389ac 100755 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/adrg/xdg v0.5.3 github.com/andygrunwald/vdf v1.1.0 github.com/bendahl/uinput v1.7.0 + github.com/charlievieth/fastwalk v1.0.14 github.com/clausecker/nfc/v2 v2.1.4 github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc github.com/creativeprojects/go-selfupdate v1.5.2 diff --git a/go.sum b/go.sum index a5018d6b..a1814924 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= +github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= github.com/clausecker/nfc/v2 v2.1.4 h1:zw2Cnny7pxPnuxVMBo+DXqXYETzUN7pMhNEA61yT5gY= github.com/clausecker/nfc/v2 v2.1.4/go.mod h1:BjRBQUQTQmiwh2tEfQ+xBM5xY05sV2gnZ0JRYEHog/o= github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc h1:t8YjNUCt1DimB4HCIXBztwWMhgxr5yG5/YaRl9Afdfg= diff --git a/pkg/database/mediascanner/dircache.go b/pkg/database/mediascanner/dircache.go deleted file mode 100644 index e823664c..00000000 --- a/pkg/database/mediascanner/dircache.go +++ /dev/null @@ -1,78 +0,0 @@ -// Zaparoo Core -// Copyright (c) 2026 The Zaparoo Project Contributors. -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Zaparoo Core. -// -// Zaparoo Core is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Zaparoo Core is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Zaparoo Core. If not, see . - -package mediascanner - -import ( - "context" - "os" - "runtime" - "strings" -) - -// dirCache caches directory listings to avoid redundant filesystem operations -// during path discovery. Not safe for concurrent use — intended to be created -// and consumed within a single GetSystemPaths call. -type dirCache struct { - entries map[string][]os.DirEntry -} - -func newDirCache() *dirCache { - return &dirCache{entries: make(map[string][]os.DirEntry)} -} - -// list returns the directory entries for the given path, reading from cache -// if available or performing a readDirWithContext and caching the result. -func (c *dirCache) list(ctx context.Context, path string) ([]os.DirEntry, error) { - if cached, ok := c.entries[path]; ok { - return cached, nil - } - entries, err := readDirWithContext(ctx, path) - if err != nil { - return nil, err - } - c.entries[path] = entries - return entries, nil -} - -// findEntry does a case-insensitive lookup for name in the directory listing -// for dirPath. On Linux, prefers an exact case match. Returns the actual -// entry name, or empty string if not found. -func (c *dirCache) findEntry(ctx context.Context, dirPath, name string) (string, error) { - entries, err := c.list(ctx, dirPath) - if err != nil { - return "", err - } - - if runtime.GOOS == "linux" { - for _, e := range entries { - if e.Name() == name { - return e.Name(), nil - } - } - } - - for _, e := range entries { - if strings.EqualFold(e.Name(), name) { - return e.Name(), nil - } - } - - return "", nil -} diff --git a/pkg/database/mediascanner/dircache_test.go b/pkg/database/mediascanner/dircache_test.go deleted file mode 100644 index 791fc53d..00000000 --- a/pkg/database/mediascanner/dircache_test.go +++ /dev/null @@ -1,123 +0,0 @@ -// Zaparoo Core -// Copyright (c) 2026 The Zaparoo Project Contributors. -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Zaparoo Core. -// -// Zaparoo Core is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Zaparoo Core is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Zaparoo Core. If not, see . - -package mediascanner - -import ( - "context" - "os" - "path/filepath" - "runtime" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDirCache_CachesResults(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), []byte("x"), 0o600)) - - cache := newDirCache() - ctx := context.Background() - - entries1, err := cache.list(ctx, dir) - require.NoError(t, err) - require.Len(t, entries1, 1) - - // Add another file — cached result should still show 1 entry - require.NoError(t, os.WriteFile(filepath.Join(dir, "file2.txt"), []byte("y"), 0o600)) - - entries2, err := cache.list(ctx, dir) - require.NoError(t, err) - assert.Len(t, entries2, 1, "should return cached result, not re-read directory") -} - -func TestDirCache_FindEntry_ExactMatch(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.Mkdir(filepath.Join(dir, "NES"), 0o750)) - - cache := newDirCache() - ctx := context.Background() - - name, err := cache.findEntry(ctx, dir, "NES") - require.NoError(t, err) - assert.Equal(t, "NES", name) -} - -func TestDirCache_FindEntry_CaseInsensitive(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.Mkdir(filepath.Join(dir, "SNES"), 0o750)) - - cache := newDirCache() - ctx := context.Background() - - name, err := cache.findEntry(ctx, dir, "snes") - require.NoError(t, err) - assert.Equal(t, "SNES", name) -} - -func TestDirCache_FindEntry_PrefersExactOnLinux(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skip("exact-match preference only applies on Linux") - } - - dir := t.TempDir() - // Create both cases — Linux allows this on case-sensitive filesystems - require.NoError(t, os.Mkdir(filepath.Join(dir, "Games"), 0o750)) - require.NoError(t, os.Mkdir(filepath.Join(dir, "games"), 0o750)) - - cache := newDirCache() - ctx := context.Background() - - name, err := cache.findEntry(ctx, dir, "Games") - require.NoError(t, err) - assert.Equal(t, "Games", name) - - name, err = cache.findEntry(ctx, dir, "games") - require.NoError(t, err) - assert.Equal(t, "games", name) -} - -func TestDirCache_FindEntry_NotFound(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.Mkdir(filepath.Join(dir, "NES"), 0o750)) - - cache := newDirCache() - ctx := context.Background() - - name, err := cache.findEntry(ctx, dir, "GENESIS") - require.NoError(t, err) - assert.Empty(t, name) -} - -func TestDirCache_ContextCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - cache := newDirCache() - - _, err := cache.list(ctx, t.TempDir()) - require.Error(t, err) - require.ErrorIs(t, err, context.Canceled) - - _, err = cache.findEntry(ctx, t.TempDir(), "anything") - require.Error(t, err) - require.ErrorIs(t, err, context.Canceled) -} diff --git a/pkg/database/mediascanner/mediascanner.go b/pkg/database/mediascanner/mediascanner.go index 1722de85..f6500104 100644 --- a/pkg/database/mediascanner/mediascanner.go +++ b/pkg/database/mediascanner/mediascanner.go @@ -24,12 +24,12 @@ import ( "errors" "fmt" "io/fs" - "os" "path/filepath" "regexp" "runtime" "sort" "strings" + "sync/atomic" "time" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" @@ -37,7 +37,9 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/database/mediadb" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database/systemdefs" "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + "github.com/charlievieth/fastwalk" sqlite3 "github.com/mattn/go-sqlite3" "github.com/rs/zerolog/log" ) @@ -204,7 +206,6 @@ func GetSystemPaths( systems []systemdefs.System, ) []PathResult { var matches []PathResult - cache := newDirCache() log.Info(). Int("rootFolders", len(rootFolders)). @@ -230,15 +231,6 @@ func GetSystemPaths( continue } validRootFolders = append(validRootFolders, gf) - - // Pre-cache the root folder listing - if _, cacheErr := cache.list(ctx, gf); cacheErr != nil { - if ctx.Err() != nil { - log.Info().Msg("path discovery cancelled") - return matches - } - log.Debug().Err(cacheErr).Str("path", gf).Msg("failed to cache root folder listing") - } } log.Info(). @@ -281,36 +273,18 @@ func GetSystemPaths( Strs("rootFolders", validRootFolders). Msg("resolving system paths") - // check for / for _, gf := range validRootFolders { for _, folder := range folders { - // Use cached directory listing for single-component folder names - if !strings.Contains(folder, string(filepath.Separator)) && !filepath.IsAbs(folder) { - actualName, findErr := cache.findEntry(ctx, gf, folder) - if findErr != nil { - if ctx.Err() != nil { - return matches - } - continue - } - if actualName != "" { - matches = append(matches, PathResult{ - System: system, - Path: filepath.Join(gf, actualName), - }) - } - continue + if filepath.IsAbs(folder) { + continue // handled separately below } - // Multi-component paths fall through to full FindPath systemFolder := filepath.Join(gf, folder) path, err := FindPath(ctx, systemFolder) if err != nil { if ctx.Err() != nil { return matches } - log.Debug().Err(err).Str("path", systemFolder).Str("system", system.ID). - Msg("skipping system folder - not found or inaccessible") continue } @@ -321,24 +295,22 @@ func GetSystemPaths( } } - // check for absolute paths for _, folder := range folders { - if filepath.IsAbs(folder) { - systemFolder := folder - path, err := FindPath(ctx, systemFolder) - if err != nil { - if ctx.Err() != nil { - return matches - } - log.Debug().Err(err).Str("path", systemFolder).Str("system", system.ID). - Msg("skipping absolute path - not found or inaccessible") - continue + if !filepath.IsAbs(folder) { + continue + } + + path, err := FindPath(ctx, folder) + if err != nil { + if ctx.Err() != nil { + return matches } - matches = append(matches, PathResult{ - System: system, - Path: path, - }) + continue } + matches = append(matches, PathResult{ + System: system, + Path: path, + }) } } @@ -362,37 +334,9 @@ func GetSystemPaths( return deduplicated } -type resultsStack struct { - results [][]string -} - -func newResultsStack() *resultsStack { - return &resultsStack{ - results: make([][]string, 0), - } -} - -func (r *resultsStack) push() { - r.results = append(r.results, make([]string, 0)) -} - -func (r *resultsStack) pop() { - if len(r.results) == 0 { - return - } - r.results = r.results[:len(r.results)-1] -} - -func (r *resultsStack) get() (*[]string, error) { - if len(r.results) == 0 { - return nil, errors.New("nothing on stack") - } - return &r.results[len(r.results)-1], nil -} - // GetFiles searches for all valid games in a given path and returns a list of -// files. This function deep searches .zip files and handles symlinks at all -// levels. +// files. Uses fastwalk for parallel directory traversal with built-in symlink +// cycle detection. Deep searches .zip files when ZipsAsDirs is enabled. func GetFiles( ctx context.Context, cfg *config.Instance, @@ -400,207 +344,110 @@ func GetFiles( systemID string, path string, ) ([]string, error) { - var allResults []string - stack := newResultsStack() - visited := make(map[string]struct{}) - system, err := systemdefs.GetSystem(systemID) if err != nil { return nil, fmt.Errorf("failed to get system %s: %w", systemID, err) } - entriesScanned := 0 + var entriesScanned atomic.Int64 walkStartTime := time.Now() - lastProgressLog := time.Now() - var scanner func(path string, file fs.DirEntry, err error) error - scanner = func(path string, file fs.DirEntry, _ error) error { - // Check for cancellation + var mu syncutil.Mutex + var results []string + + conf := &fastwalk.Config{ + Follow: true, + } + + log.Debug().Str("system", systemID).Str("path", path).Msg("starting directory walk") + err = fastwalk.Walk(conf, path, func(p string, d fs.DirEntry, err error) error { + if err != nil { + log.Warn().Err(err).Str("path", p).Msg("walk error") + return nil + } + select { case <-ctx.Done(): return ctx.Err() default: } - entriesScanned++ - if entriesScanned%5000 == 0 || time.Since(lastProgressLog) > 10*time.Second { + n := entriesScanned.Add(1) + if n%5000 == 0 { log.Debug(). Str("system", systemID). - Str("path", path). - Int("entriesScanned", entriesScanned). + Str("path", p). + Int64("entriesScanned", n). Dur("elapsed", time.Since(walkStartTime)). Msg("directory walk progress") - lastProgressLog = time.Now() } - // avoid recursive symlinks - if file.IsDir() { - key := path - if file.Type()&os.ModeSymlink != 0 { - realPath, symlinkErr := evalSymlinksWithContext(ctx, path) - if symlinkErr != nil { - return fmt.Errorf("failed to evaluate symlink %s: %w", path, symlinkErr) - } - key = realPath - } - if _, seen := visited[key]; seen { - return filepath.SkipDir - } - visited[key] = struct{}{} - - // Check for .zaparooignore marker file - markerPath := filepath.Join(path, ".zaparooignore") + if d.IsDir() { + markerPath := filepath.Join(p, ".zaparooignore") if _, statErr := statWithContext(ctx, markerPath); statErr == nil { - log.Info().Str("path", path).Msg("skipping directory with .zaparooignore marker") + log.Info().Str("path", p).Msg("skipping directory with .zaparooignore marker") return filepath.SkipDir } + return nil } - // handle symlinked directories - if file.Type()&os.ModeSymlink != 0 { - log.Trace().Str("path", path).Msg("processing symlink") - absSym, absErr := filepath.Abs(path) - if absErr != nil { - return fmt.Errorf("failed to get absolute path for %s: %w", path, absErr) - } - - realPath, realPathErr := evalSymlinksWithContext(ctx, absSym) - if realPathErr != nil { - return fmt.Errorf("failed to evaluate symlink %s: %w", absSym, realPathErr) - } - log.Trace().Str("symlink", path).Str("target", realPath).Msg("resolved symlink") - - file, statErr := statWithContext(ctx, realPath) - if statErr != nil { - return fmt.Errorf("failed to stat symlink target %s: %w", realPath, statErr) - } - - if file.IsDir() { - stack.push() - defer stack.pop() - - walkErr := filepath.WalkDir(realPath, scanner) - if walkErr != nil { - return fmt.Errorf("failed to walk directory %s: %w", realPath, walkErr) - } - - results, stackErr := stack.get() - if stackErr != nil { - return stackErr - } - - for i := range *results { - allResults = append(allResults, strings.Replace((*results)[i], realPath, path, 1)) - } - - return nil - } - } - - results, stackErr := stack.get() - if stackErr != nil { - return stackErr - } - - if helpers.IsZip(path) && platform.Settings().ZipsAsDirs { - // zip files - log.Trace().Str("path", path).Msg("opening zip file for indexing") - zipFiles, zipErr := helpers.ListZip(path) + if helpers.IsZip(p) && platform.Settings().ZipsAsDirs { + log.Trace().Str("path", p).Msg("opening zip file for indexing") + zipFiles, zipErr := helpers.ListZip(p) if zipErr != nil { - // skip invalid zip files - log.Warn().Err(zipErr).Msgf("error listing zip: %s", path) + log.Warn().Err(zipErr).Msgf("error listing zip: %s", p) return nil } + mu.Lock() for i := range zipFiles { - abs := filepath.Join(path, zipFiles[i]) + abs := filepath.Join(p, zipFiles[i]) if helpers.MatchSystemFile(cfg, platform, system.ID, abs) { - *results = append(*results, abs) + results = append(results, abs) } } - } else if helpers.MatchSystemFile(cfg, platform, system.ID, path) { - // regular files - *results = append(*results, path) + mu.Unlock() + } else if helpers.MatchSystemFile(cfg, platform, system.ID, p) { + mu.Lock() + results = append(results, p) + mu.Unlock() } return nil - } - - stack.push() - defer stack.pop() - - root, err := lstatWithContext(ctx, path) - if err != nil { - return nil, fmt.Errorf("failed to stat path %s: %w", path, err) - } - - // handle symlinks on the root game folder because WalkDir fails silently on them - var realPath string - if root.Mode()&os.ModeSymlink == 0 { - realPath = path - } else { - realPath, err = evalSymlinksWithContext(ctx, path) - if err != nil { - return nil, fmt.Errorf("failed to evaluate symlink %s: %w", path, err) - } - } - - realRoot, err := statWithContext(ctx, realPath) + }) if err != nil { - return nil, fmt.Errorf("failed to stat real path %s: %w", realPath, err) + return nil, fmt.Errorf("failed to walk directory %s: %w", path, err) } - if !realRoot.IsDir() { - return nil, errors.New("root is not a directory") - } - - log.Debug().Str("system", systemID).Str("path", realPath).Msg("starting directory walk") - err = filepath.WalkDir(realPath, scanner) - if err != nil { - return nil, fmt.Errorf("failed to walk directory %s: %w", realPath, err) - } + scanned := entriesScanned.Load() walkElapsed := time.Since(walkStartTime) - results, err := stack.get() - if err != nil { - return nil, err - } - - allResults = append(allResults, *results...) - log.Debug(). Str("system", systemID). - Str("path", realPath). - Int("entriesScanned", entriesScanned). - Int("filesFound", len(allResults)). + Str("path", path). + Int64("entriesScanned", scanned). + Int("filesFound", len(results)). Dur("elapsed", walkElapsed). Msg("completed directory walk") - if entriesScanned > 0 && len(allResults) == 0 { + if scanned > 0 && len(results) == 0 { log.Info(). Str("system", systemID). - Str("path", realPath). - Int("entriesScanned", entriesScanned). + Str("path", path). + Int64("entriesScanned", scanned). Msg("directory walk found entries but no files matched any launcher") } if walkElapsed > 15*time.Second { log.Warn(). Str("system", systemID). - Str("path", realPath). - Int("entriesScanned", entriesScanned). + Str("path", path). + Int64("entriesScanned", scanned). Dur("elapsed", walkElapsed). Msg("directory walk took longer than expected - large directory or slow storage") } - // change root back to symlink - if realPath != path { - for i := range allResults { - allResults[i] = strings.Replace(allResults[i], realPath, path, 1) - } - } - - return allResults, nil + return results, nil } // handleCancellation performs cleanup when media indexing is cancelled From 91eeb29cc9a79aff10baa442c82f4f490811701e Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Thu, 5 Mar 2026 17:11:14 +0800 Subject: [PATCH 2/3] fix(zapscript): increase zaplink HTTP client timeouts Raise dial, TLS handshake, response header, and overall client timeouts to more reasonable values for real-world network conditions. --- pkg/zapscript/zaplinks.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/zapscript/zaplinks.go b/pkg/zapscript/zaplinks.go index da954adc..43cb0da3 100644 --- a/pkg/zapscript/zaplinks.go +++ b/pkg/zapscript/zaplinks.go @@ -79,19 +79,19 @@ type httpDoer interface { var zapFetchTransport = &http.Transport{ DialContext: (&net.Dialer{ - Timeout: 1 * time.Second, + Timeout: 2 * time.Second, KeepAlive: 10 * time.Second, }).DialContext, - TLSHandshakeTimeout: 1 * time.Second, - ResponseHeaderTimeout: 1 * time.Second, - ExpectContinueTimeout: 500 * time.Millisecond, + TLSHandshakeTimeout: 2 * time.Second, + ResponseHeaderTimeout: 5 * time.Second, + ExpectContinueTimeout: 1 * time.Second, } var zapFetchClient = &http.Client{ Transport: &installer.AuthTransport{ Base: zapFetchTransport, }, - Timeout: 2 * time.Second, + Timeout: 10 * time.Second, } // ErrWellKnownNotFound is returned when the .well-known/zaparoo endpoint From 32848637d8db5afbd44e177672f8a814961e42c6 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Fri, 6 Mar 2026 16:57:20 +0800 Subject: [PATCH 3/3] test(mediascanner): add coverage for GetSystemPaths and GetFiles changes Add tests for relative folder resolution in GetSystemPaths, nonexistent folder skipping, and ZipsAsDirs handling in the fastwalk-based GetFiles. --- .../mediascanner/mediascanner_test.go | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/pkg/database/mediascanner/mediascanner_test.go b/pkg/database/mediascanner/mediascanner_test.go index 53d3de9d..59174917 100644 --- a/pkg/database/mediascanner/mediascanner_test.go +++ b/pkg/database/mediascanner/mediascanner_test.go @@ -20,6 +20,7 @@ package mediascanner import ( + "archive/zip" "context" "database/sql" "os" @@ -1843,3 +1844,179 @@ func TestZaparooignoreMarker(t *testing.T) { }) } } + +func TestGetSystemPaths_RelativeFolderResolution(t *testing.T) { + // Cannot use t.Parallel() - modifies shared GlobalLauncherCache + + // Create a root directory with system subdirectories + rootDir := t.TempDir() + nesDir := filepath.Join(rootDir, "NES") + snesDir := filepath.Join(rootDir, "SNES") + require.NoError(t, os.MkdirAll(nesDir, 0o750)) + require.NoError(t, os.MkdirAll(snesDir, 0o750)) + + // Create launchers with relative folder names (the common case) + launchers := []platforms.Launcher{ + { + ID: "nes-launcher", + SystemID: systemdefs.SystemNES, + Folders: []string{"NES"}, + Extensions: []string{".nes"}, + }, + { + ID: "snes-launcher", + SystemID: systemdefs.SystemSNES, + Folders: []string{"SNES"}, + Extensions: []string{".sfc"}, + }, + } + + fs := testhelpers.NewMemoryFS() + cfg, err := testhelpers.NewTestConfig(fs, t.TempDir()) + require.NoError(t, err) + + platform := mocks.NewMockPlatform() + platform.On("ID").Return("test-platform") + platform.On("Settings").Return(platforms.Settings{}) + platform.On("Launchers", mock.AnythingOfType("*config.Instance")).Return(launchers) + + testLauncherCacheMutex.Lock() + originalCache := helpers.GlobalLauncherCache + testCache := &helpers.LauncherCache{} + testCache.Initialize(platform, cfg) + helpers.GlobalLauncherCache = testCache + defer func() { + helpers.GlobalLauncherCache = originalCache + testLauncherCacheMutex.Unlock() + }() + + systems := []systemdefs.System{ + {ID: systemdefs.SystemNES}, + {ID: systemdefs.SystemSNES}, + } + results := GetSystemPaths(context.Background(), cfg, platform, []string{rootDir}, systems) + + require.Len(t, results, 2) + + pathsBySystem := make(map[string]string) + for _, r := range results { + pathsBySystem[r.System.ID] = r.Path + } + assert.Equal(t, nesDir, pathsBySystem[systemdefs.SystemNES]) + assert.Equal(t, snesDir, pathsBySystem[systemdefs.SystemSNES]) +} + +func TestGetSystemPaths_NonexistentFolderSkipped(t *testing.T) { + // Cannot use t.Parallel() - modifies shared GlobalLauncherCache + + rootDir := t.TempDir() + // Only create NES, not SNES + nesDir := filepath.Join(rootDir, "NES") + require.NoError(t, os.MkdirAll(nesDir, 0o750)) + + launchers := []platforms.Launcher{ + { + ID: "nes-launcher", + SystemID: systemdefs.SystemNES, + Folders: []string{"NES"}, + Extensions: []string{".nes"}, + }, + { + ID: "snes-launcher", + SystemID: systemdefs.SystemSNES, + Folders: []string{"SNES"}, + Extensions: []string{".sfc"}, + }, + } + + fs := testhelpers.NewMemoryFS() + cfg, err := testhelpers.NewTestConfig(fs, t.TempDir()) + require.NoError(t, err) + + platform := mocks.NewMockPlatform() + platform.On("ID").Return("test-platform") + platform.On("Settings").Return(platforms.Settings{}) + platform.On("Launchers", mock.AnythingOfType("*config.Instance")).Return(launchers) + + testLauncherCacheMutex.Lock() + originalCache := helpers.GlobalLauncherCache + testCache := &helpers.LauncherCache{} + testCache.Initialize(platform, cfg) + helpers.GlobalLauncherCache = testCache + defer func() { + helpers.GlobalLauncherCache = originalCache + testLauncherCacheMutex.Unlock() + }() + + systems := []systemdefs.System{ + {ID: systemdefs.SystemNES}, + {ID: systemdefs.SystemSNES}, + } + results := GetSystemPaths(context.Background(), cfg, platform, []string{rootDir}, systems) + + // Only NES should be found, SNES folder doesn't exist + require.Len(t, results, 1) + assert.Equal(t, systemdefs.SystemNES, results[0].System.ID) + assert.Equal(t, nesDir, results[0].Path) +} + +func TestGetFiles_ZipsAsDirs(t *testing.T) { + // Cannot use t.Parallel() - modifies shared GlobalLauncherCache + + rootDir := t.TempDir() + + // Create a zip file containing game ROMs + zipPath := filepath.Join(rootDir, "games.zip") + zipFile, err := os.Create(zipPath) //nolint:gosec // test file in t.TempDir() + require.NoError(t, err) + w := zip.NewWriter(zipFile) + for _, name := range []string{"game1.nes", "game2.nes", "readme.txt"} { + fw, createErr := w.Create(name) + require.NoError(t, createErr) + _, writeErr := fw.Write([]byte("data")) + require.NoError(t, writeErr) + } + require.NoError(t, w.Close()) + require.NoError(t, zipFile.Close()) + + launcher := platforms.Launcher{ + ID: "nes-launcher", + SystemID: systemdefs.SystemNES, + Folders: []string{rootDir}, + Extensions: []string{".nes"}, + } + + fs := testhelpers.NewMemoryFS() + cfg, err := testhelpers.NewTestConfig(fs, t.TempDir()) + require.NoError(t, err) + + platform := mocks.NewMockPlatform() + platform.On("ID").Return("test-platform") + platform.On("Settings").Return(platforms.Settings{ZipsAsDirs: true}) + platform.On("RootDirs", mock.AnythingOfType("*config.Instance")).Return([]string{}) + platform.On("Launchers", mock.AnythingOfType("*config.Instance")).Return([]platforms.Launcher{launcher}) + + testLauncherCacheMutex.Lock() + originalCache := helpers.GlobalLauncherCache + testCache := &helpers.LauncherCache{} + testCache.Initialize(platform, cfg) + helpers.GlobalLauncherCache = testCache + defer func() { + helpers.GlobalLauncherCache = originalCache + testLauncherCacheMutex.Unlock() + }() + + ctx := context.Background() + files, err := GetFiles(ctx, cfg, platform, systemdefs.SystemNES, rootDir) + require.NoError(t, err) + + // Should find the 2 .nes files inside the zip, but not readme.txt + assert.Len(t, files, 2) + foundFiles := make(map[string]bool) + for _, f := range files { + foundFiles[filepath.Base(f)] = true + } + assert.True(t, foundFiles["game1.nes"]) + assert.True(t, foundFiles["game2.nes"]) + assert.False(t, foundFiles["readme.txt"]) +}