diff --git a/frontends/es-de/ps5/library_ps5.cpp b/frontends/es-de/ps5/library_ps5.cpp index a42c529..0478da4 100644 --- a/frontends/es-de/ps5/library_ps5.cpp +++ b/frontends/es-de/ps5/library_ps5.cpp @@ -240,7 +240,8 @@ std::map 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); } } diff --git a/src/scraper.cpp b/src/scraper.cpp index 047160d..d28f1bb 100644 --- a/src/scraper.cpp +++ b/src/scraper.cpp @@ -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 &edit) +{ + const std::string folder = root_path + "/playlists"; + std::vector 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; } diff --git a/src/scraper_json.h b/src/scraper_json.h index a91db9f..416537b 100644 --- a/src/scraper_json.h +++ b/src/scraper_json.h @@ -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 #include @@ -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; diff --git a/tests/esde_library_test.cpp b/tests/esde_library_test.cpp index 2cd6814..a8c8a87 100644 --- a/tests/esde_library_test.cpp +++ b/tests/esde_library_test.cpp @@ -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")) == diff --git a/tests/test_scraper.py b/tests/test_scraper.py index a0bef40..d10f110 100644 --- a/tests/test_scraper.py +++ b/tests/test_scraper.py @@ -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')