diff --git a/docs/scraper.md b/docs/scraper.md index 16fc57af..d68e008a 100644 --- a/docs/scraper.md +++ b/docs/scraper.md @@ -136,9 +136,18 @@ Source fields are cleaned before mapping: HTML entities are unescaped, tab/newli Filesystem fallback searches known subdirectories under `/media/` when an XML path is absent. For games in subfolders, it searches the mirrored ROM-relative path before the flat filename; for example `./Japan/Game.nes` checks `media/images/Japan/Game.png` before `media/images/Game.png`. Side/back box art are filesystem-fallback only. -Only `/gamelist.xml` files are loaded. Nested files such as `/Japan/gamelist.xml` are not read by the current scraper. +By default, only `/gamelist.xml` files are loaded. Nested files such as `/Japan/gamelist.xml` are not read. -For systems that index virtual or non-file-backed entries (where the stored media path does not correspond to a real file), `` must match the exact path the indexer stored for that media row. +An additional metadata bundle can be configured independently of ROM storage: + +```toml +[scraper.gamelist_xml] +custom_path = "/path/to/gamelists" +``` + +For each indexed system, the scraper also checks `//gamelist.xml`. Game paths in this file resolve against the system's first ROM root; asset paths resolve against the custom system directory. Custom bundle image references are optional: only files present during scraping are stored, and missing references fall back to `media/` artwork under the custom directory and then the system's ROM roots. Run the scraper again after installing more bundle artwork. Regular ROM-root gamelists are processed first and take precedence over matching custom entries. + +Custom gamelists enrich existing indexed records; they do not create systems, titles, or media rows. For systems that index virtual or non-file-backed entries (where the stored media path does not correspond to a real file), `` must match the exact path the indexer stored for that media row. `gamelist.xml` deliberately does not scrape user-state fields such as favorite, hidden, or kidgame. It also does not overwrite filename-parser-owned fields such as disc and track. diff --git a/pkg/config/config.go b/pkg/config/config.go index b1f2ce55..b8c9f2a4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -65,6 +65,7 @@ type Values struct { Playtime Playtime `toml:"playtime,omitempty"` Profiles Profiles `toml:"profiles,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 00000000..a26ac1f0 --- /dev/null +++ b/pkg/config/configscraper.go @@ -0,0 +1,41 @@ +// 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 + +// Scraper configures metadata scraper behavior. +type Scraper struct { + GamelistXML ScraperGamelistXML `toml:"gamelist_xml,omitempty"` +} + +// ScraperGamelistXML configures the EmulationStation gamelist.xml scraper. +type ScraperGamelistXML struct { + CustomPath string `toml:"custom_path,omitempty"` +} + +// ScraperGamelistXMLCustomPath returns the optional directory containing +// per-system gamelist bundles at {custom_path}/{system_id}/gamelist.xml. +func (c *Instance) ScraperGamelistXMLCustomPath() string { + if c == nil { + return "" + } + c.mu.RLock() + defer c.mu.RUnlock() + return c.vals.Scraper.GamelistXML.CustomPath +} diff --git a/pkg/config/configscraper_test.go b/pkg/config/configscraper_test.go new file mode 100644 index 00000000..4d3eb153 --- /dev/null +++ b/pkg/config/configscraper_test.go @@ -0,0 +1,70 @@ +// 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 + +import ( + "path/filepath" + "strconv" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScraperGamelistXMLCustomPath_Default(t *testing.T) { + t.Parallel() + + assert.Empty(t, (*Instance)(nil).ScraperGamelistXMLCustomPath()) + assert.Empty(t, (&Instance{}).ScraperGamelistXMLCustomPath()) +} + +func TestScraperGamelistXMLCustomPath_Load(t *testing.T) { + t.Parallel() + + customPath := filepath.Join(t.TempDir(), "gamelists") + cfg := &Instance{} + err := cfg.LoadTOML("[scraper.gamelist_xml]\ncustom_path = " + strconv.Quote(customPath) + "\n") + require.NoError(t, err) + + assert.Equal(t, customPath, cfg.ScraperGamelistXMLCustomPath()) +} + +func TestScraperGamelistXMLCustomPath_SaveLoadRoundTrip(t *testing.T) { + t.Parallel() + + configDir := t.TempDir() + customPath := filepath.Join(t.TempDir(), "gamelists") + cfg, err := NewConfig(configDir, BaseDefaults) + require.NoError(t, err) + require.NoError(t, cfg.LoadTOML( + "[scraper.gamelist_xml]\ncustom_path = "+strconv.Quote(customPath)+"\n", + )) + require.NoError(t, cfg.Save()) + + contents, err := afero.ReadFile(cfg.getFs(), cfg.cfgPath) + require.NoError(t, err) + assert.Contains(t, string(contents), "[scraper.gamelist_xml]") + assert.Contains(t, string(contents), "custom_path = ") + + reloaded, err := NewConfig(configDir, BaseDefaults) + require.NoError(t, err) + assert.Equal(t, customPath, reloaded.ScraperGamelistXMLCustomPath()) +} diff --git a/pkg/database/scraper/gamelistxml/scraper.go b/pkg/database/scraper/gamelistxml/scraper.go index 106755e8..bf7da15c 100644 --- a/pkg/database/scraper/gamelistxml/scraper.go +++ b/pkg/database/scraper/gamelistxml/scraper.go @@ -56,13 +56,15 @@ import ( // root path and the DB identifiers of the matched MediaTitle and one of its // Media rows (used as the sentinel write target). type GamelistRecord struct { - MediaDirsByRoot []map[string]string - SystemRootPath string - MatchKind gamelistMatchKind - Game esapi.Game - MatchedMediaDBID int64 - MatchedTitleDBID int64 - MediaLevelWriteSafe bool + MediaDirsByRoot []map[string]string + SystemRootPath string + AssetRootPath string + MatchKind gamelistMatchKind + Game esapi.Game + MatchedMediaDBID int64 + MatchedTitleDBID int64 + MediaLevelWriteSafe bool + RequireExistingImage bool } type gamelistMatchKind string @@ -307,9 +309,11 @@ func resolveSystemsFromPlatform( } type parsedGamelistFile struct { - RootPath string - GamelistPath string - Games []esapi.Game + RootPath string + AssetRootPath string + GamelistPath string + Games []esapi.Game + RequireExistingImage bool } type parsedGamelistSystem struct { @@ -350,9 +354,59 @@ func (g *GamelistXMLScraper) loadParsedGamelistSystem( Games: gl.Games, }) } + + select { + case <-ctx.Done(): + return parsed, ctx.Err() + default: + } + if customFile, ok := g.loadCustomGamelistFile(system); ok { + parsed.Files = append(parsed.Files, customFile) + } return parsed, nil } +// loadCustomGamelistFile loads the optional per-system metadata bundle. ROM +// paths remain relative to the system's first ROM root, while asset paths are +// relative to the bundle's system directory. Bundle image references are +// treated as optional and only mapped when their files currently exist. +func (g *GamelistXMLScraper) loadCustomGamelistFile(system scraper.ScrapeSystem) (parsedGamelistFile, bool) { + customBase := g.cfg.ScraperGamelistXMLCustomPath() + 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, + Games: gl.Games, + RequireExistingImage: true, + }, 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 @@ -390,6 +444,12 @@ outer: default: } + fileMediaDirsByRoot := mediaDirsByRoot + if file.AssetRootPath != "" { + fileMediaDirsByRoot = make([]map[string]string, 0, len(mediaDirsByRoot)+1) + fileMediaDirsByRoot = append(fileMediaDirsByRoot, statMediaDirsFS(g.filesystem(), file.AssetRootPath)) + fileMediaDirsByRoot = append(fileMediaDirsByRoot, mediaDirsByRoot...) + } gamelistFiles++ gamelistEntries += len(file.Games) @@ -423,13 +483,15 @@ outer: slugMatches++ slugPathSelections++ records = append(records, &GamelistRecord{ - SystemRootPath: file.RootPath, - MediaDirsByRoot: mediaDirsByRoot, - Game: *game, - MatchKind: gamelistMatchSlugPath, - MatchedTitleDBID: title.DBID, - MatchedMediaDBID: pathMedia.DBID, - MediaLevelWriteSafe: true, + SystemRootPath: file.RootPath, + AssetRootPath: file.AssetRootPath, + MediaDirsByRoot: fileMediaDirsByRoot, + Game: *game, + MatchKind: gamelistMatchSlugPath, + MatchedTitleDBID: title.DBID, + MatchedMediaDBID: pathMedia.DBID, + MediaLevelWriteSafe: true, + RequireExistingImage: file.RequireExistingImage, }) delete(indexes.MediaByPathFold, matchedPathKey) continue @@ -458,13 +520,15 @@ outer: mediaLevelWriteSafe = selection.mediaLevelSafe } records = append(records, &GamelistRecord{ - SystemRootPath: file.RootPath, - MediaDirsByRoot: mediaDirsByRoot, - Game: *game, - MatchKind: selection.matchKind, - MatchedTitleDBID: title.DBID, - MatchedMediaDBID: selection.media.DBID, - MediaLevelWriteSafe: mediaLevelWriteSafe, + SystemRootPath: file.RootPath, + AssetRootPath: file.AssetRootPath, + MediaDirsByRoot: fileMediaDirsByRoot, + Game: *game, + MatchKind: selection.matchKind, + MatchedTitleDBID: title.DBID, + MatchedMediaDBID: selection.media.DBID, + MediaLevelWriteSafe: mediaLevelWriteSafe, + RequireExistingImage: file.RequireExistingImage, }) delete(indexes.TitlesBySlug, pf.Slug) case pathOK: @@ -479,13 +543,15 @@ outer: Int64("mediaTitleDBID", pathMedia.MediaTitleDBID). Msg("gamelistxml: path-only fallback matched record") records = append(records, &GamelistRecord{ - SystemRootPath: file.RootPath, - MediaDirsByRoot: mediaDirsByRoot, - Game: *game, - MatchKind: gamelistMatchPathOnly, - MatchedTitleDBID: pathMedia.MediaTitleDBID, - MatchedMediaDBID: pathMedia.DBID, - MediaLevelWriteSafe: true, + SystemRootPath: file.RootPath, + AssetRootPath: file.AssetRootPath, + MediaDirsByRoot: fileMediaDirsByRoot, + Game: *game, + MatchKind: gamelistMatchPathOnly, + MatchedTitleDBID: pathMedia.MediaTitleDBID, + MatchedMediaDBID: pathMedia.DBID, + MediaLevelWriteSafe: true, + RequireExistingImage: file.RequireExistingImage, }) delete(indexes.MediaByPathFold, matchedPathKey) case titleSlugKnown(indexes, pf.Slug): @@ -1070,6 +1136,10 @@ func (g *GamelistXMLScraper) MapToDB(record *GamelistRecord) scraper.MapResult { propType := string(tags.TagTypeProperty) root := record.SystemRootPath + assetRoot := record.AssetRootPath + if assetRoot == "" { + assetRoot = root + } // fallbackNames are ROM-relative PNG filenames used to locate matching // artwork files under media/ sub-directories. @@ -1092,7 +1162,9 @@ func (g *GamelistXMLScraper) MapToDB(record *GamelistRecord) scraper.MapResult { // the pre-stated media sub-directories for a matching .png file. appendImageProp := func(propValue tags.TagValue, xmlPath string) { key := propType + ":" + string(propValue) - p := pathProp(key, xmlPath, root, g.externalAssetRoots) + p := pathPropFS( + g.filesystem(), key, xmlPath, assetRoot, g.externalAssetRoots, record.RequireExistingImage, + ) if p == nil { p = findMediaFilePropAcrossRootsFS( g.filesystem(), key, fallbackNames, @@ -1127,10 +1199,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 := pathPropFS( + g.filesystem(), propType+":"+string(tags.TagPropertyVideo), game.Video, + assetRoot, g.externalAssetRoots, false, + ); p != nil { mediaProps = append(mediaProps, *p) } - if p := pathProp(propType+":"+string(tags.TagPropertyManual), game.Manual, root, g.externalAssetRoots); p != nil { + if p := pathPropFS( + g.filesystem(), propType+":"+string(tags.TagPropertyManual), game.Manual, + assetRoot, g.externalAssetRoots, false, + ); p != nil { mediaProps = append(mediaProps, *p) } @@ -1247,16 +1325,29 @@ func appendNormalizedTag(tagInfos []database.TagInfo, tagType, raw, label string return append(tagInfos, database.TagInfo{Type: tagType, Tag: normalized, Label: label}) } -// 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 { +// pathPropFS resolves esPath to an absolute path and returns a MediaProperty +// for typeTag. When requireExists is true, unresolved and missing paths are +// skipped so another artwork source can provide the property. +func pathPropFS( + fs afero.Fs, + typeTag, esPath, systemRootPath string, + externalAssetRoots []string, + requireExists 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 requireExists { + exists, err := afero.Exists(fs, resolved) + if err != nil || !exists { + return nil + } + } + abs := filepath.ToSlash(resolved) return &database.MediaProperty{ TypeTag: typeTag, Text: abs, @@ -1665,9 +1756,11 @@ func isCompanionGame(game *esapi.Game) bool { // Parent records carry full metadata but no ROM path; they represent the canonical game // title shared by multiple regional ROM releases. type companionParent struct { - SystemRootPath string - GameID string - Game esapi.Game + SystemRootPath string + AssetRootPath string + GameID string + Game esapi.Game + RequireExistingImage bool } // companionChild holds a ZaparooCompanion ROM child record parsed from a gamelist.xml. @@ -1722,9 +1815,11 @@ func companionEntriesFromParsed( switch { case game.ScreenScraperIDAttr != "" && game.Path == "": parents = append(parents, companionParent{ - Game: game, - SystemRootPath: file.RootPath, - GameID: game.ScreenScraperIDAttr, + Game: game, + SystemRootPath: file.RootPath, + AssetRootPath: file.AssetRootPath, + GameID: game.ScreenScraperIDAttr, + RequireExistingImage: file.RequireExistingImage, }) case game.ParentIDAttr != "" && game.Path != "": resolved := esmedia.ResolvePath(game.Path, file.RootPath) @@ -1903,8 +1998,10 @@ func resolveCompanionSlugConflicts( // dirs are therefore not needed here. func (g *GamelistXMLScraper) mapCompanionParentToResult(p *companionParent) scraper.MapResult { result := g.MapToDB(&GamelistRecord{ - SystemRootPath: p.SystemRootPath, - Game: p.Game, + SystemRootPath: p.SystemRootPath, + AssetRootPath: p.AssetRootPath, + Game: p.Game, + RequireExistingImage: p.RequireExistingImage, }) result.TitleProps = append(result.TitleProps, result.MediaProps...) result.MediaProps = nil diff --git a/pkg/database/scraper/gamelistxml/scraper_test.go b/pkg/database/scraper/gamelistxml/scraper_test.go index 82227408..7357743c 100644 --- a/pkg/database/scraper/gamelistxml/scraper_test.go +++ b/pkg/database/scraper/gamelistxml/scraper_test.go @@ -28,6 +28,7 @@ import ( "testing" "time" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database/scraper" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database/tags" @@ -314,6 +315,180 @@ func mediaBySlugAndPath(slug string, title *database.MediaTitle, rows ...databas return indexes } +func newCustomGamelistConfig(t *testing.T, customPath string) *config.Instance { + t.Helper() + cfg, err := config.NewConfig(t.TempDir(), config.BaseDefaults) + require.NoError(t, err) + require.NoError(t, cfg.LoadTOML( + "[scraper.gamelist_xml]\ncustom_path = "+strconv.Quote(customPath)+"\n", + )) + return cfg +} + +func propertyByType(props []database.MediaProperty, typeTag string) (database.MediaProperty, bool) { + for i := range props { + if props[i].TypeTag == typeTag { + return props[i], true + } + } + return database.MediaProperty{}, false +} + +func TestLoadRecords_CustomGamelistBundle(t *testing.T) { + t.Parallel() + + romRoot := t.TempDir() + mirrorRoot := t.TempDir() + customRoot := t.TempDir() + customSystemDir := filepath.Join(customRoot, "nes") + require.NoError(t, os.MkdirAll(filepath.Join(romRoot, "media", "boxart"), 0o750)) + require.NoError(t, os.MkdirAll(filepath.Join(mirrorRoot, "media", "screenshots"), 0o750)) + require.NoError(t, os.MkdirAll(filepath.Join(customSystemDir, "assets"), 0o750)) + require.NoError(t, os.MkdirAll(filepath.Join(customSystemDir, "media", "images"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(customSystemDir, "assets", "screen.png"), []byte("screen"), 0o600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(customSystemDir, "media", "images", "Game.png"), []byte("fallback"), 0o600, + )) + require.NoError(t, os.WriteFile(filepath.Join(customSystemDir, "gamelist.xml"), []byte(` + + + ./Game.nes + Game + ./assets/missing.png + ./assets/screen.png + +`), 0o600)) + + s := &GamelistXMLScraper{cfg: newCustomGamelistConfig(t, customRoot)} + records, err := s.LoadRecords( + context.Background(), + scraper.ScrapeSystem{ID: "nes", ROMPaths: []string{romRoot, mirrorRoot}}, + mediaByPath(database.Media{ + DBID: 12, MediaTitleDBID: 23, Path: filepath.Join(romRoot, "Game.nes"), + }), + ) + require.NoError(t, err) + require.Len(t, records, 1) + record := records[0] + assert.Equal(t, romRoot, record.SystemRootPath) + assert.Equal(t, customSystemDir, record.AssetRootPath) + assert.True(t, record.RequireExistingImage) + require.Len(t, record.MediaDirsByRoot, 3) + assert.Equal(t, filepath.Join(customSystemDir, "media", "images"), record.MediaDirsByRoot[0]["images"]) + assert.Equal(t, filepath.Join(romRoot, "media", "boxart"), record.MediaDirsByRoot[1]["boxart"]) + assert.Equal(t, filepath.Join(mirrorRoot, "media", "screenshots"), record.MediaDirsByRoot[2]["screenshots"]) + + mapped := s.MapToDB(record) + imageType := string(tags.TagTypeProperty) + ":" + string(tags.TagPropertyImageImage) + image, ok := propertyByType(mapped.MediaProps, imageType) + require.True(t, ok) + assert.Equal(t, filepath.ToSlash(filepath.Join(customSystemDir, "media", "images", "Game.png")), image.Text) + + screenshotType := string(tags.TagTypeProperty) + ":" + string(tags.TagPropertyImageScreenshot) + screenshot, ok := propertyByType(mapped.MediaProps, screenshotType) + require.True(t, ok) + assert.Equal(t, filepath.ToSlash(filepath.Join(customSystemDir, "assets", "screen.png")), screenshot.Text) +} + +func TestMapToDB_MissingImagePolicyDependsOnGamelistSource(t *testing.T) { + t.Parallel() + + root := t.TempDir() + imageType := string(tags.TagTypeProperty) + ":" + string(tags.TagPropertyImageImage) + + regular := (&GamelistXMLScraper{}).MapToDB(&GamelistRecord{ + SystemRootPath: root, + Game: esapi.Game{Image: "./assets/missing.png"}, + }) + regularImage, ok := propertyByType(regular.MediaProps, imageType) + require.True(t, ok) + assert.Equal(t, filepath.ToSlash(filepath.Join(root, "assets", "missing.png")), regularImage.Text) + + custom := (&GamelistXMLScraper{}).MapToDB(&GamelistRecord{ + SystemRootPath: root, + AssetRootPath: root, + RequireExistingImage: true, + Game: esapi.Game{Image: "./assets/missing.png"}, + }) + _, ok = propertyByType(custom.MediaProps, imageType) + assert.False(t, ok) +} + +func TestLoadRecords_RegularGamelistTakesPrecedenceOverCustom(t *testing.T) { + t.Parallel() + + romRoot := t.TempDir() + customRoot := t.TempDir() + customSystemDir := filepath.Join(customRoot, "nes") + require.NoError(t, os.MkdirAll(customSystemDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(romRoot, "gamelist.xml"), []byte(` +./Game.nesRegular`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(customSystemDir, "gamelist.xml"), []byte(` +./Game.nesCustom`), 0o600)) + + records, err := (&GamelistXMLScraper{cfg: newCustomGamelistConfig(t, customRoot)}).LoadRecords( + context.Background(), + scraper.ScrapeSystem{ID: "nes", ROMPaths: []string{romRoot}}, + mediaByPath(database.Media{ + DBID: 12, MediaTitleDBID: 23, Path: filepath.Join(romRoot, "Game.nes"), + }), + ) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, "Regular", records[0].Game.Name) + assert.Empty(t, records[0].AssetRootPath) +} + +func TestLoadParsedGamelistSystem_SkipsMalformedCustomFile(t *testing.T) { + t.Parallel() + + customRoot := t.TempDir() + customSystemDir := filepath.Join(customRoot, "nes") + require.NoError(t, os.MkdirAll(customSystemDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(customSystemDir, "gamelist.xml"), []byte(""), 0o600, + )) + + parsed, err := (&GamelistXMLScraper{cfg: newCustomGamelistConfig(t, customRoot)}). + loadParsedGamelistSystem(context.Background(), scraper.ScrapeSystem{ID: "nes", ROMPaths: []string{t.TempDir()}}) + require.NoError(t, err) + assert.Empty(t, parsed.Files) +} + +func TestCompanionParent_CustomGamelistAssetPolicy(t *testing.T) { + t.Parallel() + + customRoot := t.TempDir() + customSystemDir := filepath.Join(customRoot, "nes") + require.NoError(t, os.MkdirAll(filepath.Join(customSystemDir, "assets"), 0o750)) + imagePath := filepath.Join(customSystemDir, "assets", "parent.png") + require.NoError(t, os.WriteFile(imagePath, []byte("image"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(customSystemDir, "gamelist.xml"), []byte(` + + + Parent./assets/parent.png + +`), 0o600)) + + s := &GamelistXMLScraper{cfg: newCustomGamelistConfig(t, customRoot)} + system := scraper.ScrapeSystem{ID: "nes", ROMPaths: []string{t.TempDir()}} + parsed, err := s.loadParsedGamelistSystem(context.Background(), system) + require.NoError(t, err) + parents, children := companionEntriesFromParsed(context.Background(), system, parsed) + require.Len(t, parents, 1) + assert.Empty(t, children) + assert.Equal(t, customSystemDir, parents[0].AssetRootPath) + assert.True(t, parents[0].RequireExistingImage) + + mapped := s.mapCompanionParentToResult(&parents[0]) + imageType := string(tags.TagTypeProperty) + ":" + string(tags.TagPropertyImageImage) + image, ok := propertyByType(mapped.TitleProps, imageType) + require.True(t, ok) + assert.Equal(t, filepath.ToSlash(imagePath), image.Text) +} + func TestLoadRecords_PathMatch(t *testing.T) { t.Parallel() @@ -1321,16 +1496,16 @@ func TestMapToDB_ScreenScraperID_NeitherSet(t *testing.T) { } } -// TestPathProp_NormalizesSlashes verifies that pathProp returns forward-slash +// TestPathProp_NormalizesSlashes verifies that pathPropFS returns forward-slash // paths regardless of the OS separator. The MediaDB stores paths with // filepath.ToSlash (see indexing_pipeline.go), so artwork paths must match. func TestPathProp_NormalizesSlashes(t *testing.T) { t.Parallel() root := t.TempDir() - p := pathProp("prop:image", "./images/mario.png", root, nil) + p := pathPropFS(afero.NewOsFs(), "prop:image", "./images/mario.png", root, nil, false) require.NotNil(t, p, "expected non-nil property") if strings.Contains(p.Text, "\\") { - t.Errorf("pathProp returned backslashes in path: %q", p.Text) + t.Errorf("pathPropFS returned backslashes in path: %q", p.Text) } }