Skip to content
Draft
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
1 change: 1 addition & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
55 changes: 55 additions & 0 deletions pkg/config/configscraper.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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
}
99 changes: 93 additions & 6 deletions pkg/database/scraper/gamelistxml/scraper.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import (
type GamelistRecord struct {
AvailableMediaDirs map[string]string
SystemRootPath string
AssetRootPath string
MatchKind gamelistMatchKind
Game esapi.Game
MatchedMediaDBID int64
Expand Down Expand Up @@ -317,6 +318,7 @@ func resolveSystemsFromPlatform(

type parsedGamelistFile struct {
RootPath string
AssetRootPath string
GamelistPath string
AvailableMediaDirs map[string]string
Games []esapi.Game
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -434,6 +489,7 @@ outer:
slugPathSelections++
records = append(records, &GamelistRecord{
SystemRootPath: file.RootPath,
AssetRootPath: file.AssetRootPath,
AvailableMediaDirs: file.AvailableMediaDirs,
Game: *game,
MatchKind: gamelistMatchSlugPath,
Expand Down Expand Up @@ -468,6 +524,7 @@ outer:
}
records = append(records, &GamelistRecord{
SystemRootPath: file.RootPath,
AssetRootPath: file.AssetRootPath,
AvailableMediaDirs: file.AvailableMediaDirs,
Game: *game,
MatchKind: selection.matchKind,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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 <stem>.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,
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -1778,6 +1863,7 @@ func companionEntriesFromParsed(
parents = append(parents, companionParent{
Game: game,
SystemRootPath: file.RootPath,
AssetRootPath: file.AssetRootPath,
AvailableMediaDirs: file.AvailableMediaDirs,
GameID: game.ScreenScraperIDAttr,
})
Expand Down Expand Up @@ -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,
})
Expand Down
2 changes: 1 addition & 1 deletion pkg/database/scraper/gamelistxml/scraper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading