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 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"]) +} 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