Library: a name edited in the WebUI reaches RetroArch's playlists and every ES-DE rename

A rename in the WebUI was written only to the game's metadata (issue 55).
RetroArch's menus (XMB, RGUI) show the playlist label, which never changed,
and ES-DE took the new name only while its own was still the label, so a
second rename was dropped. src/scraper.cpp now rewrites the game's entry in
every playlist that lists its file (edit_playlist_entries, also for removals),
with Json::dump in src/scraper_json.h; ES-DE's adapter lets every field the
owner edited win (frontends/es-de/ps5/library_ps5.cpp).

Tests: python3 -m unittest tests.test_esde_library tests.test_scraper: 15 OK
(the label changes, the other entries and the playlist's fields stay; two
renames in a row both reach ES-DE's gamelist, its favourite kept).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
MihawkandClaude committed 2026-10-18 19:47:34 -04:00
1 parent cc17179253
commit 67b7fed0ac
5 files changed
+134 -3

No files matched your search

+8 -2
View File
@@ -240,7 +240,8 @@ std::map<std::string, std::string> read_meta(const std::string &path)
}
/* The scraped fields into a game's list entry: a field ES-DE has is kept, unless it is
* still the library's default (the name as the label). */
* still the library's default (the name as the label) or the owner edited it in the
* WebUI, which lists it in "edited" (issue 55: a second rename was dropped). */
void add_meta(pugi::xml_node node, const char *media, const char *system,
const struct ps5_library_game &game)
{
@@ -252,6 +253,10 @@ void add_meta(pugi::xml_node node, const char *media, const char *system,
{"name", "name"}, {"description", "desc"}, {"developer", "developer"},
{"publisher", "publisher"}, {"genre", "genre"}, {"players", "players"},
{"rating", "rating"}, {"released", "releasedate"}};
const auto list = meta.find("edited");
const std::string edited_list = list == meta.end() ? "" : "," + list->second + ",";
const auto edited = [&](const char *name)
{ return edited_list.find(std::string(",") + name + ",") != std::string::npos; };
for (const auto &field : fields)
{
const auto value = meta.find(field[0]);
@@ -273,7 +278,8 @@ void add_meta(pugi::xml_node node, const char *media, const char *system,
text = digits + "T000000";
}
const std::string current = node.child_value(field[1]);
if (current.empty() || (std::strcmp(field[1], "name") == 0 && current == game.label))
if (current.empty() || edited(field[0]) ||
(std::strcmp(field[1], "name") == 0 && current == game.label))
set_child(node, field[1], text);
}
}
+57
View File
@@ -2793,6 +2793,52 @@ bool game_identity(const std::string &system, const std::string &key, const std:
return false;
}
/* Every RetroArch playlist entry for a game's file: edit changes it in place, or
* returns false to remove it. A playlist is written only when it held the game; false
* when one could not be written. RetroArch's menus read the label (issues 55, 57). */
bool edit_playlist_entries(const std::string &path, const std::function<bool(Json &)> &edit)
{
const std::string folder = root_path + "/playlists";
std::vector<std::string> files;
if (DIR *dir = opendir(folder.c_str()))
{
while (const dirent *entry = readdir(dir))
{
const std::string name = entry->d_name;
if (name.size() > 4 && name.compare(name.size() - 4, 4, ".lpl") == 0)
files.push_back(folder + '/' + name);
}
closedir(dir);
}
bool ok = true;
for (const auto &file : files)
{
Json playlist = Json::parse(read_text(file));
const auto items = playlist.fields.find("items");
if (playlist.kind != Json::object || items == playlist.fields.end() ||
items->second.kind != Json::array)
continue;
auto &entries = items->second.items;
bool changed = false;
for (size_t i = 0; i < entries.size();)
{
if (entries[i]["path"].str() != path)
{
++i;
continue;
}
changed = true;
if (edit(entries[i]))
++i;
else
entries.erase(entries.begin() + long(i));
}
if (changed && !write_atomic(file, playlist.dump()))
ok = false;
}
return ok;
}
bool edit_game(const std::string &system, const std::string &key, const std::string &body,
std::string &why)
{
@@ -2853,7 +2899,18 @@ bool edit_game(const std::string &system, const std::string &key, const std::str
meta["edited"] = kinds_text(edited);
make_folders(library_root() + '/' + system + "/metadata");
if (write_atomic(meta_path(system, key), fields_text(meta)))
{
// The new name in RetroArch's menus too (issue 55); the metadata is the record.
if (input["name"].kind == Json::string)
edit_playlist_entries(game.path,
[&](Json &entry)
{
entry.fields["label"] =
Json{Json::string, false, 0, meta["name"]};
return true;
});
return true;
}
why = "Details could not be saved. Check console storage.";
return false;
}
+47 -1
View File
@@ -1,6 +1,6 @@
/* Copyright (C) 2026 Mihawk; SPDX-License-Identifier: GPL-3.0-or-later */
/* A small JSON reader for the scraper's sources (ScreenScraper answers in JSON): values
* are read whole into a tree; nothing is written. Malformed input reads as null. */
* are read whole into a tree, and dump() writes one back. Malformed input reads as null. */
#pragma once
#include <cstdio>
#include <cstdlib>
@@ -49,6 +49,52 @@ struct Json
return "";
}
/* The value as JSON text, for files the WebUI edits (RetroArch's playlists): fields
* come out in name order, which RetroArch's reader does not mind. */
std::string dump() const
{
switch (kind)
{
case boolean:
return flag ? "true" : "false";
case number:
return str();
case string:
{
std::string out = "\"";
for (unsigned char c : text)
if (c == '"' || c == '\\')
out += '\\', out += char(c);
else if (c < 0x20)
{
char escaped[8];
std::snprintf(escaped, sizeof escaped, "\\u%04x", c);
out += escaped;
}
else
out += char(c);
return out + '"';
}
case array:
{
std::string out = "[";
for (const auto &item : items)
out += (out.size() > 1 ? "," : "") + item.dump();
return out + ']';
}
case object:
{
std::string out = "{";
for (const auto &field : fields)
out += (out.size() > 1 ? "," : "") + Json{string, false, 0, field.first}.dump() +
':' + field.second.dump();
return out + '}';
}
default:
return "null";
}
}
static Json parse(const std::string &input)
{
size_t at = 0;
+15
View File
@@ -154,6 +154,21 @@ int main(int argc, char **argv)
assert(path != "./Gone (USA).zip");
}
assert(count == 2 && kept && added);
/* A name the owner edited in the WebUI wins over ES-DE's, every time (issue 55). */
for (const char *name : {"Jungle Run Two", "Jungle Run, again"})
{
write_text(media + "/snes/metadata/Jungle Run 2 (USA).meta",
std::string("edited = \"name\"\nname = \"") + name + "\"\n");
assert(ps5_esde_write_library_to(&paths, summary, sizeof(summary)) == 2);
pugi::xml_document renamed;
assert(renamed.load_file((data + "/gamelists/snes/gamelist.xml").c_str()));
bool found = false;
for (pugi::xml_node game : renamed.child("gameList").children("game"))
if (std::string(game.child_value("path")) == "./Jungle Run 2 (USA).zip")
found = std::string(game.child_value("name")) == name &&
std::string(game.child_value("favorite")) == "true";
assert(found);
}
pugi::xml_document homebrew;
assert(homebrew.load_file((data + "/gamelists/homebrew/gamelist.xml").c_str()));
assert(std::string(homebrew.child("gameList").child("game").child_value("path")) ==
+7
View File
@@ -533,6 +533,13 @@ class Scraper(unittest.TestCase):
self.assertEqual(next(g for system in json.loads(self.request('GET', '/api/library')[1])['systems'] for g in system['games'] if g['path'] == path)['name'], 'My game')
meta = (self.root / 'library/snes/metadata' / (key + '.meta')).read_text()
self.assertIn('edited = "name,description,developer,publisher,genre,players,rating,released"', meta)
# RetroArch's menus show the playlist's label: the new name reaches it, the other
# entries and the playlist's own fields stay (issue 55).
playlist = json.loads((self.root / f'playlists/{DB}.lpl').read_text())
self.assertEqual(playlist['version'], '1.5')
labels = {item['path']: item['label'] for item in playlist['items']}
self.assertEqual(labels, {p: 'My game' if p == path else l for l, p in self.games.items()})
self.assertEqual({item['core_path'] for item in playlist['items']}, {'DETECT'})
metadata['rating'] = 'NaN'
self.assertEqual(self.request('POST', '/api/library/game?' + query, json.dumps(metadata).encode())[0], 400)
self.assertEqual(rom.read_bytes(), b'replacement')