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
13 changes: 11 additions & 2 deletions docs/scraper.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,18 @@ Source fields are cleaned before mapping: HTML entities are unescaped, tab/newli

Filesystem fallback searches known subdirectories under `<systemRootPath>/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 `<ROM root>/gamelist.xml` files are loaded. Nested files such as `<ROM root>/Japan/gamelist.xml` are not read by the current scraper.
By default, only `<ROM root>/gamelist.xml` files are loaded. Nested files such as `<ROM root>/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), `<path>` 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 `<custom_path>/<system ID>/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), `<path>` 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.

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

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
}
70 changes: 70 additions & 0 deletions pkg/config/configscraper_test.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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 = ")

Comment thread
coderabbitai[bot] marked this conversation as resolved.
reloaded, err := NewConfig(configDir, BaseDefaults)
require.NoError(t, err)
assert.Equal(t, customPath, reloaded.ScraperGamelistXMLCustomPath())
}
Loading
Loading