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)