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