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
2 changes: 1 addition & 1 deletion Taskfile.dist.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ version: "3"

env:
GOTOOLCHAIN: auto
GOPROXY: https://goproxy.io,direct
GOPROXY: direct
APP_VERSION:
sh: echo -n "${APP_VERSION:-$(git rev-parse --short HEAD)-dev}"
UID:
Expand Down
8 changes: 5 additions & 3 deletions pkg/api/methods/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,12 @@ func GenerateMediaDB(
defer db.MediaDB.BackgroundOperationDone()
total, err := mediascanner.NewNamesIndex(indexCtx, pl, cfg, systems, db, func(status mediascanner.IndexStatus) {
var desc string
switch status.Step {
case 1:
switch {
case status.Phase == mediascanner.PhaseDiscovering:
desc = "Finding media folders"
case status.Total:
case status.Phase == mediascanner.PhaseInitializing:
desc = "Initializing database"
case status.Step == status.Total:
desc = "Writing database"
default:
system, err := systemdefs.GetSystem(status.SystemID)
Expand Down
15 changes: 13 additions & 2 deletions pkg/api/methods/methods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"database/sql"
"errors"
"testing"
"time"

"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
Expand Down Expand Up @@ -578,8 +579,10 @@ func TestHandleGenerateMedia_SystemFiltering(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset indexing status to prevent race conditions between parallel tests
statusInstance.clear()
// Cancel any leftover background goroutine and wait for it
// to release the global statusInstance before starting.
CancelIndexing()
ClearIndexingStatus()

// Create mock environment
mockPlatform := mocks.NewMockPlatform()
Expand Down Expand Up @@ -674,6 +677,14 @@ func TestHandleGenerateMedia_SystemFiltering(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, result)

// Cancel the background indexing goroutine and wait for it
// to finish so it doesn't leak into the next subtest.
appState.StopService()
require.Eventually(t, func() bool {
return !statusInstance.isRunning()
}, 5*time.Second, 10*time.Millisecond,
"background indexing goroutine did not stop")

// Verify mock expectations were met
mockMediaDB.AssertExpectations(t)
mockPlatform.AssertExpectations(t)
Expand Down
78 changes: 78 additions & 0 deletions pkg/database/mediascanner/dircache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// 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 <http://www.gnu.org/licenses/>.

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
}
123 changes: 123 additions & 0 deletions pkg/database/mediascanner/dircache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// 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 <http://www.gnu.org/licenses/>.

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)
}
6 changes: 4 additions & 2 deletions pkg/database/mediascanner/findpath_fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
package mediascanner

import (
"context"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -63,7 +64,8 @@ func FuzzFindPath(f *testing.F) {
fullPath := filepath.Join(tmpDir, pathFragment)

// EXECUTE: Call FindPath
result, err := FindPath(fullPath)
ctx := context.Background()
result, err := FindPath(ctx, fullPath)

// INVARIANT CHECKS
if err == nil {
Expand All @@ -78,7 +80,7 @@ func FuzzFindPath(f *testing.F) {
}

// INVARIANT 4: Idempotency - calling FindPath on result should return same result
result2, err2 := FindPath(result)
result2, err2 := FindPath(ctx, result)
if err2 != nil {
t.Errorf("FindPath failed on its own output: %s (error: %v)", result, err2)
} else if result != result2 {
Expand Down
Loading
Loading