diff --git a/pkg/config/config.go b/pkg/config/config.go
index eed465d7c..e84c00256 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -64,6 +64,7 @@ type Values struct {
Launchers Launchers `toml:"launchers,omitempty"`
Playtime Playtime `toml:"playtime,omitempty"`
Media Media `toml:"media,omitempty"`
+ Scraper Scraper `toml:"scraper,omitempty"`
ZapScript ZapScript `toml:"zapscript,omitempty"`
Mappings Mappings `toml:"mappings,omitempty"`
Systems Systems `toml:"systems,omitempty"`
diff --git a/pkg/config/configscraper.go b/pkg/config/configscraper.go
new file mode 100644
index 000000000..59cfc0c14
--- /dev/null
+++ b/pkg/config/configscraper.go
@@ -0,0 +1,55 @@
+// 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 config
+
+type Scraper struct {
+ CustomGamelistsPath *string `toml:"custom_gamelists_path,omitempty"`
+ StatGamelistImages *bool `toml:"stat_gamelist_images,omitempty"`
+}
+
+// ScraperCustomGamelistsPath returns the configured custom gamelists root path,
+// or "" if unset. When set, the gamelistxml scraper looks for an additional
+// gamelist.xml at {path}/{system_id}/gamelist.xml for each scanned system.
+func (c *Instance) ScraperCustomGamelistsPath() string {
+ if c == nil {
+ return ""
+ }
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ if c.vals.Scraper.CustomGamelistsPath == nil {
+ return ""
+ }
+ return *c.vals.Scraper.CustomGamelistsPath
+}
+
+// ScraperStatGamelistImages returns whether the gamelistxml scraper should
+// verify image files referenced by gamelist.xml entries exist on disk before
+// including them as media metadata. Defaults to false.
+func (c *Instance) ScraperStatGamelistImages() bool {
+ if c == nil {
+ return false
+ }
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ if c.vals.Scraper.StatGamelistImages == nil {
+ return false
+ }
+ return *c.vals.Scraper.StatGamelistImages
+}
diff --git a/pkg/database/scraper/gamelistxml/scraper.go b/pkg/database/scraper/gamelistxml/scraper.go
index 0da48fbc7..eedc447c6 100644
--- a/pkg/database/scraper/gamelistxml/scraper.go
+++ b/pkg/database/scraper/gamelistxml/scraper.go
@@ -55,6 +55,7 @@ import (
type GamelistRecord struct {
AvailableMediaDirs map[string]string
SystemRootPath string
+ AssetRootPath string
MatchKind gamelistMatchKind
Game esapi.Game
MatchedMediaDBID int64
@@ -317,6 +318,7 @@ func resolveSystemsFromPlatform(
type parsedGamelistFile struct {
RootPath string
+ AssetRootPath string
GamelistPath string
AvailableMediaDirs map[string]string
Games []esapi.Game
@@ -356,14 +358,67 @@ func (g *GamelistXMLScraper) loadParsedGamelistSystem(
Msg("gamelistxml: loaded gamelist.xml")
parsed.Files = append(parsed.Files, parsedGamelistFile{
RootPath: rootPath,
+ AssetRootPath: rootPath,
GamelistPath: gamelistPath,
AvailableMediaDirs: statMediaDirsFS(g.filesystem(), rootPath),
Games: gl.Games,
})
}
+
+ if file, ok := g.loadCustomGamelistFile(system); ok {
+ parsed.Files = append(parsed.Files, file)
+ }
+
return parsed, nil
}
+// loadCustomGamelistFile checks the configured custom gamelists path for
+// {custom_gamelists_path}/{system.ID}/gamelist.xml and loads it if present.
+// Asset paths (image, video, manual, etc.) are resolved relative to the
+// custom system directory, while game.Path is resolved relative to the
+// system's first ROM path (falling back to the custom directory if the
+// system has none), matching how a regular ES gamelist.xml co-located with
+// ROMs would resolve paths.
+func (g *GamelistXMLScraper) loadCustomGamelistFile(system scraper.ScrapeSystem) (parsedGamelistFile, bool) {
+ if g.cfg == nil {
+ return parsedGamelistFile{}, false
+ }
+ customBase := g.cfg.ScraperCustomGamelistsPath()
+ if customBase == "" {
+ return parsedGamelistFile{}, false
+ }
+
+ customSystemDir := filepath.Join(customBase, system.ID)
+ gamelistPath := filepath.Join(customSystemDir, "gamelist.xml")
+ exists, statErr := afero.Exists(g.filesystem(), gamelistPath)
+ if statErr != nil || !exists {
+ return parsedGamelistFile{}, false
+ }
+
+ gl, err := readGameListXMLFS(g.filesystem(), gamelistPath)
+ if err != nil {
+ log.Warn().Err(err).Str("path", gamelistPath).
+ Msg("gamelistxml: failed to read custom gamelist.xml, skipping")
+ return parsedGamelistFile{}, false
+ }
+
+ rootPath := customSystemDir
+ if len(system.ROMPaths) > 0 {
+ rootPath = system.ROMPaths[0]
+ }
+ log.Info().
+ Str("path", gamelistPath).
+ Int("entries", len(gl.Games)).
+ Msg("gamelistxml: loaded custom gamelist.xml")
+ return parsedGamelistFile{
+ RootPath: rootPath,
+ AssetRootPath: customSystemDir,
+ GamelistPath: gamelistPath,
+ AvailableMediaDirs: statMediaDirsFS(g.filesystem(), customSystemDir),
+ Games: gl.Games,
+ }, true
+}
+
// LoadRecords iterates gamelist.xml files found under each ROM root path for
// the given system. It prefers the original slug/title match, uses the XML path
// to select the concrete Media row for that title when possible, and falls back
@@ -434,6 +489,7 @@ outer:
slugPathSelections++
records = append(records, &GamelistRecord{
SystemRootPath: file.RootPath,
+ AssetRootPath: file.AssetRootPath,
AvailableMediaDirs: file.AvailableMediaDirs,
Game: *game,
MatchKind: gamelistMatchSlugPath,
@@ -468,6 +524,7 @@ outer:
}
records = append(records, &GamelistRecord{
SystemRootPath: file.RootPath,
+ AssetRootPath: file.AssetRootPath,
AvailableMediaDirs: file.AvailableMediaDirs,
Game: *game,
MatchKind: selection.matchKind,
@@ -489,6 +546,7 @@ outer:
Msg("gamelistxml: path-only fallback matched record")
records = append(records, &GamelistRecord{
SystemRootPath: file.RootPath,
+ AssetRootPath: file.AssetRootPath,
AvailableMediaDirs: file.AvailableMediaDirs,
Game: *game,
MatchKind: gamelistMatchPathOnly,
@@ -1002,6 +1060,16 @@ func (g *GamelistXMLScraper) MapToDB(record *GamelistRecord) scraper.MapResult {
propType := string(tags.TagTypeProperty)
root := record.SystemRootPath
+ // assetRoot is the directory asset paths in the gamelist (image, video,
+ // manual, etc.) are resolved relative to. This is normally the same as
+ // root, but differs when the gamelist comes from a custom gamelists path
+ // (the asset paths stay relative to the gamelist's own directory while
+ // game.Path stays relative to the ROM root).
+ assetRoot := record.AssetRootPath
+ if assetRoot == "" {
+ assetRoot = root
+ }
+
// fallbackNames are ROM-relative PNG filenames used to locate matching
// artwork files under media/ sub-directories.
fallbackNames := artworkFallbackNames(game.Path, record.SystemRootPath)
@@ -1021,9 +1089,10 @@ func (g *GamelistXMLScraper) MapToDB(record *GamelistRecord) scraper.MapResult {
// For each image property: use the XML path when present, otherwise scan
// the pre-stated media sub-directories for a matching .png file.
+ statGamelistImages := g.cfg.ScraperStatGamelistImages()
appendImageProp := func(propValue tags.TagValue, xmlPath string) {
key := propType + ":" + string(propValue)
- p := pathProp(key, xmlPath, root, g.externalAssetRoots)
+ p := pathProp(key, xmlPath, assetRoot, g.externalAssetRoots, g.filesystem(), statGamelistImages)
if p == nil {
p = findMediaFilePropFS(
g.filesystem(), key, fallbackNames,
@@ -1058,10 +1127,16 @@ func (g *GamelistXMLScraper) MapToDB(record *GamelistRecord) scraper.MapResult {
appendImageProp(tags.TagPropertyImageTitleshot, titleshotXML)
appendImageProp(tags.TagPropertyImageMap, game.Map)
- if p := pathProp(propType+":"+string(tags.TagPropertyVideo), game.Video, root, g.externalAssetRoots); p != nil {
+ if p := pathProp(
+ propType+":"+string(tags.TagPropertyVideo), game.Video, assetRoot, g.externalAssetRoots,
+ g.filesystem(), false,
+ ); p != nil {
mediaProps = append(mediaProps, *p)
}
- if p := pathProp(propType+":"+string(tags.TagPropertyManual), game.Manual, root, g.externalAssetRoots); p != nil {
+ if p := pathProp(
+ propType+":"+string(tags.TagPropertyManual), game.Manual, assetRoot, g.externalAssetRoots,
+ g.filesystem(), false,
+ ); p != nil {
mediaProps = append(mediaProps, *p)
}
@@ -1226,14 +1301,23 @@ func appendNormalizedTag(tagInfos []database.TagInfo, tagType, raw, label string
// pathProp resolves esPath to an absolute path and returns a MediaProperty for
// the given typeTag. Returns nil if the path cannot be resolved (skipped cleanly).
-func pathProp(typeTag, esPath, systemRootPath string, externalAssetRoots []string) *database.MediaProperty {
+// If checkExists is true, the resolved file must exist on fs or nil is returned.
+func pathProp(
+ typeTag, esPath, systemRootPath string, externalAssetRoots []string, fs afero.Fs, checkExists bool,
+) *database.MediaProperty {
if esPath == "" {
return nil
}
- abs := filepath.ToSlash(resolveESAssetPath(esPath, systemRootPath, externalAssetRoots))
- if abs == "" {
+ resolved := resolveESAssetPath(esPath, systemRootPath, externalAssetRoots)
+ if resolved == "" {
return nil
}
+ if checkExists {
+ if exists, err := afero.Exists(fs, resolved); err != nil || !exists {
+ return nil
+ }
+ }
+ abs := filepath.ToSlash(resolved)
return &database.MediaProperty{
TypeTag: typeTag,
Text: abs,
@@ -1720,6 +1804,7 @@ func isCompanionGame(game *esapi.Game) bool {
type companionParent struct {
AvailableMediaDirs map[string]string
SystemRootPath string
+ AssetRootPath string
GameID string
Game esapi.Game
}
@@ -1778,6 +1863,7 @@ func companionEntriesFromParsed(
parents = append(parents, companionParent{
Game: game,
SystemRootPath: file.RootPath,
+ AssetRootPath: file.AssetRootPath,
AvailableMediaDirs: file.AvailableMediaDirs,
GameID: game.ScreenScraperIDAttr,
})
@@ -1815,6 +1901,7 @@ func companionEntriesFromParsed(
func (g *GamelistXMLScraper) mapCompanionParentToResult(p *companionParent) scraper.MapResult {
result := g.MapToDB(&GamelistRecord{
SystemRootPath: p.SystemRootPath,
+ AssetRootPath: p.AssetRootPath,
AvailableMediaDirs: p.AvailableMediaDirs,
Game: p.Game,
})
diff --git a/pkg/database/scraper/gamelistxml/scraper_test.go b/pkg/database/scraper/gamelistxml/scraper_test.go
index 4ef2357f4..498f5c044 100644
--- a/pkg/database/scraper/gamelistxml/scraper_test.go
+++ b/pkg/database/scraper/gamelistxml/scraper_test.go
@@ -1205,7 +1205,7 @@ func TestMapToDB_ScreenScraperID_NeitherSet(t *testing.T) {
func TestPathProp_NormalizesSlashes(t *testing.T) {
t.Parallel()
root := t.TempDir()
- p := pathProp("prop:image", "./images/mario.png", root, nil)
+ p := pathProp("prop:image", "./images/mario.png", root, nil, afero.NewOsFs(), false)
require.NotNil(t, p, "expected non-nil property")
if strings.Contains(p.Text, "\\") {
t.Errorf("pathProp returned backslashes in path: %q", p.Text)