Games: a card opens the game's media, each kind replaceable by the user's own file

- GET /api/library/game: a game's details and, for each of the ten kinds of media,
  whether it is stored (type, size, uploaded) and the types it takes.
- PUT /api/library/media: the user's file, streamed to a hidden file beside its final
  name and renamed in place when whole (token required; PNG/JPEG for pictures, MP4/WebM
  for video, PDF for manuals: what every frontend shows). It becomes the kind's only
  file and is marked in the .meta ("uploaded"): no scrape replaces it, even when
  replacing everything.
- The page: clicking a card opens an overlay with the game's details, then one row per
  kind (a preview, a description, its file); missing kinds greyed; Replace / Add, or a
  file dropped on the row, with progress. The card's tags name every kind properly.

Evidence: tests/test_scraper.py test_a_game_shows_its_media_and_takes_the_users_own
(refused types, unknown game and kind, a path outside the library, no token; the
user's cover replaces the scraped one and survives a replace-everything scrape);
tests/webui_scraper_browser.cjs (overlay rows, greyed missing kinds, a refused GIF,
an uploaded PNG shown at once, the checkbox still selecting). tools/verify.sh: PASS
(format unit build integration evidence).
This commit is contained in:
Mihawk committed 2026-10-07 12:48:50 -04:00
1 parent e357d7b829
commit 42d0dd16a9
8 files changed
+394 -10

No files matched your search

+125 -2
View File
@@ -731,6 +731,20 @@ std::string media_destination(const Game &game, const std::string &kind, const c
{
return library_root() + '/' + game.system + '/' + kind_folders.at(kind) + '/' + game.key + ext;
}
/* The kinds the user put in place themselves (the .meta's "uploaded" list). */
std::vector<std::string> uploaded_kinds(const std::string &system, const std::string &key)
{
return kinds_from(read_fields(meta_path(system, key))["uploaded"]);
}
/* A kind a job fetches for a game: one it lacks, or any when replacing; never one the
* user uploaded (a scrape does not undo the user's choice). */
bool wanted(const Game &game, const std::string &kind, const Options &options)
{
if (!options.overwrite)
return stored_media(game.system, game.key, kind).empty();
const auto mine = uploaded_kinds(game.system, game.key);
return std::find(mine.begin(), mine.end(), kind) == mine.end();
}
/* What is written of a game's metadata (fields edited by hand are kept). */
void save_meta(const Item &item, const Options &options, const Fields &details = {})
{
@@ -1109,7 +1123,7 @@ void process_screenscraper(Http &http, Job &job, unsigned index)
return finish(State::unmatched, "ScreenScraper has no system for these games.");
std::vector<std::string> needed;
for (const auto &kind : options.kinds)
if (options.overwrite || stored_media(item.game.system, item.game.key, kind).empty())
if (wanted(item.game, kind, options))
needed.push_back(kind);
const bool details_wanted =
options.overwrite ||
@@ -1296,7 +1310,7 @@ void process(Http &http, Job &job, unsigned index)
/* What is already stored is kept (unless overwrite): nothing to fetch for it. */
std::vector<std::string> needed;
for (const auto &kind : kinds)
if (options.overwrite || stored_media(item.game.system, item.game.key, kind).empty())
if (wanted(item.game, kind, options))
needed.push_back(kind);
if (needed.empty() && !kinds.empty())
{
@@ -1570,6 +1584,115 @@ bool busy()
return active && active->state == "running";
}
/* What a kind of media accepts from the user: the types every frontend reads. */
const std::vector<std::string> &accepted_types(const std::string &kind)
{
static const std::vector<std::string> images = {"png", "jpg", "jpeg"}, video = {"mp4", "webm"},
manual = {"pdf"};
return kind == "video" ? video : kind == "manual" ? manual : images;
}
bool find_game(const std::string &system, const std::string &key, Game &out)
{
if (!safe_part(system) || !safe_part(key))
return false;
for (const auto &game : load_games())
if (game.system == system && game.key == key)
{
out = game;
return true;
}
return false;
}
std::string game_json(const std::string &system, const std::string &key)
{
Game game;
if (!find_game(system, key, game))
return "";
const Fields meta = read_fields(meta_path(system, key));
const auto mine = uploaded_kinds(system, key);
std::string kinds;
for (const auto &kind : kind_catalog)
{
const std::string file = stored_media(system, key, kind.id);
struct stat st{};
const bool present = !file.empty() && stat(file.c_str(), &st) == 0;
std::string accepts;
const std::string id = kind.id;
for (const auto &type : accepted_types(id))
accepts += std::string(accepts.empty() ? "" : ",") + quote(type);
kinds += std::string(kinds.empty() ? "" : ",") + "{\"id\":" + quote(kind.id) +
",\"name\":" + quote(kind.name) + ",\"description\":" + quote(kind.description) +
",\"accepts\":[" + accepts + "],\"present\":" + (present ? "true" : "false");
if (present)
kinds +=
",\"type\":" + quote(file.substr(file.find_last_of('.') + 1)) +
",\"bytes\":" + std::to_string(st.st_size) +
",\"changed\":" + std::to_string(st.st_mtime) + ",\"uploaded\":" +
(std::find(mine.begin(), mine.end(), kind.id) != mine.end() ? "true" : "false");
kinds += '}';
}
std::string details;
for (const char *field : {"name", "description", "developer", "publisher", "genre", "players",
"rating", "released", "source", "scraped"})
if (meta.count(field) && !meta.at(field).empty())
details += std::string(details.empty() ? "" : ",") + quote(field) + ':' +
quote(meta.at(field));
return "{\"system\":" + quote(system) + ",\"system_name\":" + quote(game.system_name) +
",\"key\":" + quote(key) + ",\"label\":" + quote(game.label) +
",\"path\":" + quote(game.path) + ",\"details\":{" + details + "},\"kinds\":[" + kinds +
"]}";
}
bool upload_target(const std::string &system, const std::string &key, const std::string &kind,
const std::string &type, std::string &temporary, std::string &destination,
std::string &why)
{
Game game;
if (!kind_folders.count(kind))
{
why = "No such kind of media.";
return false;
}
const auto &types = accepted_types(kind);
if (std::find(types.begin(), types.end(), type) == types.end())
{
std::string list;
for (const auto &t : types)
list += (list.empty() ? "" : ", ") + t;
why = kind_name(kind) + " takes " + list + " files (what every frontend can show).";
return false;
}
if (!find_game(system, key, game))
{
why = "This game is not in the library.";
return false;
}
destination = media_destination(game, kind, ('.' + type).c_str());
const std::string folder = destination.substr(0, destination.find_last_of('/'));
make_folders(folder);
temporary = folder + "/.partial-" + nonce();
return true;
}
void uploaded(const std::string &system, const std::string &key, const std::string &kind,
const std::string &destination)
{
/* The new file is the kind's only one (a lookup takes the first type it finds). */
const std::string stem = destination.substr(0, destination.find_last_of('.'));
for (const char *ext : {".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm", ".pdf"})
if (stem + ext != destination)
unlink((stem + ext).c_str());
make_folders(library_root() + '/' + system + "/metadata");
Fields meta = read_fields(meta_path(system, key));
auto mine = kinds_from(meta["uploaded"]);
if (std::find(mine.begin(), mine.end(), kind) == mine.end())
mine.push_back(kind);
meta["uploaded"] = kinds_text(mine);
meta["media." + kind] = destination.substr(library_root().size() + 1);
write_atomic(meta_path(system, key), fields_text(meta));
}
std::string library_json()
{
const auto games = load_games();
+12
View File
@@ -75,6 +75,18 @@ std::string account_json(const std::string &source);
bool sign_in(const std::string &source, const std::string &user, const std::string &password,
std::string &why);
bool sign_out(const std::string &source);
/* One game, for its page: its details and, for every kind of media, whether it is
* stored (type, size, uploaded by the user) and the file types it accepts; "" for no
* such game. */
std::string game_json(const std::string &system, const std::string &game);
/* A media file the user uploads for a game: where to write it (a hidden file beside its
* final name); false with why for a type the frontends cannot show. Once committed,
* uploaded() makes it the kind's only file and marks it the user's: no scrape replaces it. */
bool upload_target(const std::string &system, const std::string &game, const std::string &kind,
const std::string &type, std::string &temporary, std::string &destination,
std::string &why);
void uploaded(const std::string &system, const std::string &game, const std::string &kind,
const std::string &destination);
/* A stored media file's path for the WebUI, or "" (kind: cover, screenshot...). */
std::string media_file(const std::string &system, const std::string &game, const std::string &kind);
+61 -5
View File
@@ -71,10 +71,11 @@ struct BatchState;
enum class Transfer
{
none,
single, // PUT /api/upload
batch, // PUT /api/upload/batch
part, // PUT /api/upload/part
scraped // PUT /api/scraper/pc/media: a file the PC helper downloaded
single, // PUT /api/upload
batch, // PUT /api/upload/batch
part, // PUT /api/upload/part
scraped, // PUT /api/scraper/pc/media: a file the PC helper downloaded
media // PUT /api/library/media: a media file the user chose for a game
};
struct Request
{
@@ -88,6 +89,7 @@ struct Request
size_t body_limit = 16384;
std::string job;
unsigned task = 0;
std::string media_system, media_game, media_kind, media_why; // Transfer::media
unsigned error = 0;
const char *message = "";
bool counted = false; // in transfers_in_flight
@@ -1189,6 +1191,33 @@ void prepare_scraped(MHD_Connection *c, Request &r)
r.message = "This download is no longer wanted.";
}
}
// A media file the user uploads for one game (src/scraper.h): written under a hidden
// name beside its final one and renamed into place when whole.
void prepare_media(MHD_Connection *c, Request &r)
{
r.transfer = Transfer::media;
r.media_system = arg(c, "system");
r.media_game = arg(c, "game");
r.media_kind = arg(c, "kind");
std::string type = arg(c, "type"), why;
for (char &ch : type)
ch = char(std::tolower((unsigned char)ch));
if (!content_length(c, r.expected) || r.expected == 0 || r.expected > (uint64_t(1) << 30))
{
r.error = 413;
r.message = "A media file is at most 1 GiB.";
return;
}
if (!ps5_scraper::upload_target(r.media_system, r.media_game, r.media_kind, type, r.temporary,
r.destination, why) ||
!r.writer.create(r.temporary))
{
r.temporary.clear();
r.error = 415;
r.media_why = why.empty() ? "The console could not store this file." : why;
r.message = r.media_why.c_str();
}
}
std::string query(MHD_Connection *c, const char *key, const char *fallback)
{
const char *value = arg(c, key);
@@ -1221,6 +1250,30 @@ MHD_Result scraper_route(MHD_Connection *c, const std::string &url, const std::s
};
if (method == "GET" && url == "/api/library")
return respond(c, 200, ps5_scraper::library_json());
if (method == "GET" && url == "/api/library/game")
{
const std::string game = ps5_scraper::game_json(arg(c, "system"), arg(c, "game"));
return game.empty() ? error(c, 404, "This game is not in the library.")
: respond(c, 200, game);
}
if (r.transfer == Transfer::media)
{
const bool flushed = r.writer.flush();
const uint64_t got = r.writer.written();
const int fd = r.writer.release();
bool ok = flushed && fd >= 0 && got == r.expected && r.received == r.expected;
if (ok)
ok = ps5_transfer::commit(fd, r.temporary, r.destination, true, true) ==
ps5_transfer::Commit::done;
if (fd >= 0)
close(fd);
if (!ok)
return error(c, 507, "The file could not be stored. Check free space and try again.");
chmod(r.destination.c_str(), 0777);
r.temporary.clear();
ps5_scraper::uploaded(r.media_system, r.media_game, r.media_kind, r.destination);
return respond(c, 201, ps5_scraper::game_json(r.media_system, r.media_game));
}
if (method == "GET" && url == "/api/library/media")
{
const std::string file =
@@ -1234,6 +1287,7 @@ MHD_Result scraper_route(MHD_Connection *c, const std::string &url, const std::s
: ext == "gif" ? "image/gif"
: ext == "mp4" ? "video/mp4"
: ext == "webm" ? "video/webm"
: ext == "pdf" ? "application/pdf"
: "application/octet-stream";
return serve_file(c, file, type, false);
}
@@ -1597,6 +1651,8 @@ MHD_Result handle(void *, MHD_Connection *c, const char *url, const char *method
prepare_part(c, *r);
else if (std::strcmp(method, "PUT") == 0 && std::strcmp(url, "/api/scraper/pc/media") == 0)
prepare_scraped(c, *r);
else if (std::strcmp(method, "PUT") == 0 && std::strcmp(url, "/api/library/media") == 0)
prepare_media(c, *r);
if (std::strcmp(url, "/api/scraper/start") == 0)
r->body_limit = 4 << 20; // one line a game chosen
if (r->error)
@@ -1658,7 +1714,7 @@ MHD_Result handle(void *, MHD_Connection *c, const char *url, const char *method
}
if (r.error && !(r.transfer == Transfer::batch && r.batch && r.error == 400))
return error(c, r.error, r.message);
if (r.transfer == Transfer::scraped || is_scraper_route(url))
if (r.transfer == Transfer::scraped || r.transfer == Transfer::media || is_scraper_route(url))
return scraper_route(c, url, method, r);
if (r.transfer != Transfer::none || is_transfer_route(url))
return transfer_route(c, url, method, r);
+48
View File
@@ -360,6 +360,54 @@ class Scraper(unittest.TestCase):
self.assertFalse(old.exists())
self.assertTrue(young.exists()) # maybe a stale daemon's file still being written
def test_a_game_shows_its_media_and_takes_the_users_own(self):
dk = 'Donkey Kong Country 2 - Diddy\'s Kong Quest (USA)'
key = Path(self.games[dk]).stem
self.wait(self.start(lines=[f'snes\t{self.games[dk]}']))
status, body = self.request('GET', f'/api/library/game?system=snes&game={urllib.parse.quote(key)}')
self.assertEqual(status, 200, body)
game = json.loads(body)
kinds = {k['id']: k for k in game['kinds']}
self.assertEqual(len(kinds), 10) # every kind, present or not
self.assertEqual((kinds['cover']['present'], kinds['cover']['type'], kinds['cover']['uploaded']), (True, 'png', False))
self.assertEqual((kinds['fanart']['present'], kinds['video']['accepts']), (False, ['mp4', 'webm']))
self.assertEqual(game['details']['name'], "Donkey Kong Country 2 - Diddy's Kong Quest")
query = f'system=snes&game={urllib.parse.quote(key)}'
mine = b'\xff\xd8\xff' + b'my own cover' * 1000
# A type no frontend shows, a game not in the library, a kind that does not exist,
# an address outside the library, and no token: all refused, nothing written.
for path, expected in ((f'/api/library/media?{query}&kind=cover&type=gif', 415),
('/api/library/media?system=snes&game=Nope&kind=cover&type=png', 415),
(f'/api/library/media?{query}&kind=config&type=png', 415),
('/api/library/media?system=..&game=..%2Fconfig&kind=cover&type=png', 415)):
status, body = self.request('PUT', path, mine)
self.assertEqual(status, expected, (path, body))
self.assertIn('takes png, jpg, jpeg files', json.loads(self.request('PUT', f'/api/library/media?{query}&kind=cover&type=gif', mine)[1])['error'])
token, self.token = self.token, ''
self.assertEqual(self.request('PUT', f'/api/library/media?{query}&kind=cover&type=jpg', mine)[0], 403)
self.token = token
self.assertTrue(self.stored(dk, 'cover').exists())
# The user's cover: it replaces the scraped one, the only file of its kind.
status, body = self.request('PUT', f'/api/library/media?{query}&kind=cover&type=JPG', mine)
self.assertEqual(status, 201, body)
cover = {k['id']: k for k in json.loads(body)['kinds']}['cover']
self.assertEqual((cover['type'], cover['bytes'], cover['uploaded']), ('jpg', len(mine), True))
self.assertFalse(self.stored(dk, 'cover').exists()) # the .png
self.assertEqual(self.request('GET', f'/api/library/media?{query}&kind=cover')[1], mine)
meta = (self.root / 'library/snes/metadata' / f'{key}.meta').read_text()
self.assertIn('uploaded = "cover"', meta)
self.assertIn('media.cover = "snes/covers/', meta)
# A missing kind added; a scrape replacing everything leaves the user's files.
fanart = b'\x89PNG' + b'fan art' * 500
self.assertEqual(self.request('PUT', f'/api/library/media?{query}&kind=fanart&type=png', fanart)[0], 201)
before = len(self.media_gets())
self.wait(self.start(lines=[f'snes\t{self.games[dk]}'], overwrite=True))
self.assertEqual(len(self.media_gets()), before + 2) # screenshot and title, not the cover
self.assertEqual(self.request('GET', f'/api/library/media?{query}&kind=cover')[1], mine)
self.assertEqual((self.root / 'library/snes/fanarts' / f'{key}.png').read_bytes() if (self.root / 'library/snes/fanarts').exists()
else self.request('GET', f'/api/library/media?{query}&kind=fanart')[1], fanart)
self.assertFalse(list((self.root / 'library').rglob('.partial-*')))
def test_games_page_in_a_browser(self):
playwright = next((str(p) for p in (Path.home() / '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright-core',
Path('/usr/lib/chatgpt/resources/cua_node/lib/node_modules/playwright-core')) if p.exists()),
+27 -1
View File
@@ -61,6 +61,32 @@ const { chromium } = require(process.env.PLAYWRIGHT_PATH || 'playwright');
assert.equal(await page.locator('.recap-game .kind-pill').count(), 2, 'one kind shown when one is picked');
await page.goto(process.env.WEBUI_TEST_URL + '/#games');
await page.waitForFunction(() => document.querySelectorAll('.game-cover img').length === 4);
// A card opens the game: one row a kind of media, missing ones greyed, each replaceable.
await page.locator('.game-card', { hasText: 'Donkey Kong Country 2' }).locator('.game-cover').click();
const sheet = page.locator('#game-sheet');
await sheet.waitFor({ state: 'visible' });
await page.waitForFunction(() => document.querySelectorAll('.media-row-item').length === 10);
assert.equal(await page.locator('.media-row-item:not(.missing)').count(), 3);
assert.equal(await page.locator('.media-row-item.missing').count(), 7);
assert.equal(await page.locator('.media-row-item[data-kind="cover"] .preview img').count(), 1);
assert.match(await page.locator('#sheet-media-count').innerText(), /3 of 10 kinds/);
const fanart = page.locator('.media-row-item[data-kind="fanart"]');
assert.equal(await fanart.locator('button').innerText(), 'Add…');
// A type no frontend shows is refused on the page; a PNG is sent and shown at once.
await fanart.locator('input[type=file]').setInputFiles({ name: 'art.gif', mimeType: 'image/gif', buffer: Buffer.from('GIF89a') });
assert.match(await fanart.locator('.state').innerText(), /takes PNG, JPG, JPEG files/);
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
await fanart.locator('input[type=file]').setInputFiles({ name: 'my fan art.png', mimeType: 'image/png', buffer: png });
await page.waitForFunction(() => !document.querySelector('.media-row-item[data-kind="fanart"]').classList.contains('missing'));
assert.match(await page.locator('.media-row-item[data-kind="fanart"] .state').innerText(), /PNG · .* · your file, kept when downloading again/);
assert.match(await page.locator('#sheet-media-count').innerText(), /4 of 10 kinds/);
await page.keyboard.press('Escape');
await sheet.waitFor({ state: 'hidden' });
assert.match(await page.locator('.game-card', { hasText: 'Donkey Kong Country 2' }).locator('.media-badges').innerText(), /Fan art/);
// The checkbox still selects without opening.
await page.locator('.game-card', { hasText: 'Donkey Kong Country 2' }).locator('.game-select').click();
assert.equal(await sheet.isVisible(), false);
await page.locator('.game-card', { hasText: 'Donkey Kong Country 2' }).locator('.game-select').click();
// The method is remembered on the console.
await page.goto(process.env.WEBUI_TEST_URL + '/#media');
await page.waitForFunction(() => document.querySelector('input[name="scrape-method"][value="ps5"]').checked);
@@ -68,6 +94,6 @@ const { chromium } = require(process.env.PLAYWRIGHT_PATH || 'playwright');
await page.setViewportSize({ width: 390, height: 844 });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), true, 'no sideways scroll on a phone');
assert.deepEqual(errors, []);
console.log('PASS: method asked then remembered, media tiles, system scraped on the PS5 with live stats, ambiguous game resolved, recap by kind and game, covers shown, phone width');
console.log('PASS: method asked then remembered, media tiles, system scraped on the PS5 with live stats, ambiguous game resolved, recap by kind and game, covers shown, the game overlay with its media rows and an upload, phone width');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exit(1); });
+9
View File
@@ -53,4 +53,13 @@ main{max-width:100rem;margin-inline:auto}
.recap-systems{display:grid;gap:.5rem}.recap-system{border:.0555556rem solid var(--line);border-radius:.6rem;background:var(--surface)}.recap-system summary{cursor:pointer;display:flex;align-items:center;gap:.7rem;padding:.6rem .8rem;font-size:.9rem;list-style:none}.recap-system summary::-webkit-details-marker{display:none}.recap-system summary::before{content:'';width:.45rem;height:.45rem;border-right:.12rem solid var(--muted);border-bottom:.12rem solid var(--muted);transform:rotate(-45deg);transition:transform .15s;flex:none}.recap-system[open] summary::before{transform:rotate(45deg)}.recap-system summary strong{flex:1}.recap-system summary span{font-size:.78rem;color:var(--muted);font-variant-numeric:tabular-nums}.recap-games{display:grid;padding:0 .8rem .5rem}.recap-game{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.2rem 1rem;align-items:center;padding:.45rem 0;border-top:.0555556rem solid var(--line);font-size:.85rem}.recap-game .game-name{min-width:0;overflow-wrap:anywhere}.recap-game .game-name small{display:block;color:var(--muted);font-size:.75rem}.recap-game .pills{display:flex;gap:.3rem;flex-wrap:wrap;justify-content:flex-end}.recap-game .note{grid-column:1/-1;font-size:.76rem;color:var(--muted);margin:0}.recap-more{justify-self:center;margin:.4rem 0}
.kind-pill{display:inline-flex;align-items:center;gap:.25rem;border-radius:999px;padding:.1rem .5rem;font-size:.72rem;border:.0555556rem solid transparent;white-space:nowrap}.kind-pill.got{background:color-mix(in srgb,var(--green) 16%,transparent);color:#0f7a48;border-color:color-mix(in srgb,var(--green) 40%,transparent)}.kind-pill.had{background:var(--ground);color:var(--muted);border-color:var(--line)}.kind-pill.missed{color:#a32c51;border:.0555556rem dashed #d0607a;background:transparent}:root[data-theme=dark] .kind-pill.got{color:#7fe3ae}:root[data-theme=dark] .kind-pill.missed{color:#ff9db7}
@media(max-width:600px){.recap-game{grid-template-columns:1fr}.recap-game .pills{justify-content:flex-start}.recap-system summary{flex-wrap:wrap;row-gap:.2rem}.recap-system summary strong{flex:1 1 calc(100% - 2rem)}.recap-system summary span{flex-basis:100%;padding-left:1.15rem}}
.game-card{cursor:pointer;transition:border-color .15s,transform .12s,box-shadow .15s}.game-card:hover{border-color:var(--accent);transform:translateY(-.1rem);box-shadow:0 .5rem 1.2rem rgba(55,24,74,.12)}.game-card:focus-visible{outline:.166667rem solid #a566ef;outline-offset:.15rem}
.game-sheet{border:0;border-radius:var(--radius);padding:0;width:min(56rem,calc(100vw - 2rem));max-height:calc(100vh - 2rem);background:var(--surface);color:var(--ink);box-shadow:0 1.5rem 4rem rgba(20,6,40,.4);overflow:hidden}.game-sheet[open]{display:flex;flex-direction:column;animation:sign-in-in .2s ease-out}.game-sheet::backdrop{background:rgba(25,10,40,.55);backdrop-filter:blur(3px)}
.sheet-head{display:flex;gap:1rem;align-items:flex-start;padding:1.1rem 1.2rem;border-bottom:.0555556rem solid var(--line)}.sheet-cover{width:4.2rem;aspect-ratio:3/4;border-radius:.4rem;background:var(--ground);overflow:hidden;flex:none;display:grid;place-items:center}.sheet-cover img{width:100%;height:100%;object-fit:contain}.sheet-cover .icon{color:var(--muted)}.sheet-heading{flex:1;min-width:0;display:grid;gap:.35rem}.sheet-heading h2{font-size:1.3rem;overflow-wrap:anywhere}.sheet-heading p{font-size:.8rem;overflow-wrap:anywhere}.sheet-close{border:0;background:transparent;color:var(--muted);width:2.2rem;height:2.2rem;border-radius:50%;display:grid;place-items:center;flex:none}.sheet-close:hover{background:var(--ground);color:var(--ink)}.sheet-close svg{width:1.2rem;height:1.2rem;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round}
.sheet-body{overflow:auto;padding:1rem 1.2rem 1.3rem;display:grid;gap:1.1rem}.sheet-details{display:grid;gap:.7rem}.sheet-details .description{font-size:.88rem;color:var(--ink);white-space:pre-line;max-height:7.5rem;overflow:hidden;position:relative}.sheet-details .description.open{max-height:none}.sheet-details dl{display:grid;grid-template-columns:repeat(auto-fill,minmax(9rem,1fr));gap:.5rem .9rem;margin:0}.sheet-details dt{font-size:.72rem;color:var(--muted)}.sheet-details dd{margin:0;font-size:.85rem}
.sheet-media-head{display:flex;align-items:baseline;justify-content:space-between;gap:1rem}.sheet-media-head h3{margin:0;font-size:1.02rem}.sheet-media-head p{font-size:.8rem}.sheet-hint{font-size:.78rem;margin:.3rem 0 .7rem}.sheet-media{display:grid;gap:.5rem}
.media-row-item{display:grid;grid-template-columns:7.5rem minmax(0,1fr) auto;gap:.9rem;align-items:center;border:.0555556rem solid var(--line);border-radius:.6rem;padding:.55rem;background:var(--surface);transition:border-color .15s,background .15s}.media-row-item.drop{border-color:var(--accent);background:var(--ground)}.media-row-item .preview{width:7.5rem;height:5.2rem;border-radius:.4rem;background:var(--ground);display:grid;place-items:center;overflow:hidden}.media-row-item .preview img,.media-row-item .preview video{width:100%;height:100%;object-fit:contain;background:#000}.media-row-item .preview img{background:transparent}.media-row-item .preview .icon{width:1.6rem;height:1.6rem;color:var(--muted)}.media-row-item .preview a{display:grid;place-items:center;gap:.2rem;font-size:.72rem;text-decoration:none;width:100%;height:100%}.media-row-item .what{min-width:0;display:grid;gap:.2rem}.media-row-item .what strong{font-size:.9rem;display:flex;align-items:center;gap:.45rem}.media-row-item .what strong .icon{width:1.05rem;height:1.05rem;color:var(--accent)}.media-row-item .what p{font-size:.76rem;margin:0}.media-row-item .what .state{font-size:.74rem;color:var(--muted)}.media-row-item .what .state.mine{color:var(--accent)}.media-row-item progress{width:100%;height:.35rem}.media-row-item .actions{display:flex;flex-direction:column;gap:.35rem;align-items:stretch}
.media-row-item.missing{background:var(--ground);border-style:dashed}.media-row-item.missing .preview,.media-row-item.missing .what{opacity:.5}.media-row-item.missing .preview{background:transparent;border:.0555556rem dashed var(--control)}
.media-row-item.busy{pointer-events:none}.media-row-item.busy .actions{opacity:.5}
@media(max-width:600px){.media-row-item{grid-template-columns:5.5rem minmax(0,1fr)}.media-row-item .preview{width:5.5rem;height:4.2rem}.media-row-item .actions{grid-column:1/-1;flex-direction:row}.media-row-item .actions button{flex:1}.sheet-head{padding:.9rem}.sheet-body{padding:.9rem}.sheet-cover{width:3.2rem}}
+110 -2
View File
@@ -762,7 +762,7 @@ for (const span of $$('[data-icon]')) {
span.append(element('b', label));
}
// The games and their media (src/scraper.h): the shared library every frontend reads.
const KIND_NAMES = { cover: 'Cover', screenshot: 'Screenshot', title: 'Title', logo: 'Logo', video: 'Video' };
const KIND_NAMES = { cover: 'Box art', backcover: 'Back', box3d: '3D box', screenshot: 'Screenshot', title: 'Title', logo: 'Logo', physical: 'Disc', fanart: 'Fan art', manual: 'Manual', video: 'Video' };
function gameName(game) { return game.name || game.label; }
// What tells versions of one game apart: the (region), (Rev 1), (Proto), [a1], (1992)
// tags of its label or, failing that, of its file name. Two cards with one title are
@@ -809,8 +809,12 @@ function drawGames() {
for (const kind of game.media) badges.append(element('span', KIND_NAMES[kind] || kind));
const tags = element('div', undefined, 'version-tags');
for (const tag of gameTags(game)) tags.append(tagNode(tag));
card.title = game.path.split('/').pop();
card.title = `${game.path.split('/').pop()} · open to see and change its media`;
info.append(element('strong', gameName(game)), tags, element('small', system.name), badges);
// The card opens the game's media; its checkbox still selects it for a download.
card.tabIndex = 0; card.setAttribute('aria-haspopup', 'dialog');
card.addEventListener('click', event => { if (event.target !== pick) openGame(system, game); });
card.addEventListener('keydown', event => { if ((event.key === 'Enter' || event.key === ' ') && event.target === card) { event.preventDefault(); openGame(system, game); } });
card.append(pick, cover, info); grid.append(card);
}
if (list.length > shownGames) {
@@ -834,6 +838,110 @@ async function loadGames() {
} catch (error) { $('#games-grid').replaceChildren(element('p', error.message, 'list-message inline-error')); }
}
for (const id of ['#games-system', '#games-missing']) $(id).addEventListener('change', () => { shownGames = 300; drawGames(); });
// A game's page: its details, then one row a kind of media, missing ones greyed, each
// replaceable by a file from this computer (streamed to the console, then put in place).
let sheetGame = null;
const DETAIL_NAMES = [['developer', 'Developer'], ['publisher', 'Publisher'], ['released', 'Released'], ['genre', 'Genre'], ['players', 'Players'], ['rating', 'Rating']];
async function openGame(system, game) {
sheetGame = { system, game, info: null };
const dialog = $('#game-sheet');
$('#sheet-title').textContent = gameName(game);
$('#sheet-tags').replaceChildren(...gameTags(game).map(tagNode));
$('#sheet-sub').textContent = `${system.name} · ${game.path.split('/').pop()}`;
$('#sheet-details').hidden = true;
$('#sheet-media').replaceChildren(element('p', 'Loading…', 'list-message'));
drawSheetCover();
if (!dialog.open) dialog.showModal();
try { sheetGame.info = await api(`/api/library/game?system=${encodeURIComponent(system.id)}&game=${encodeURIComponent(game.key)}`); }
catch (error) { $('#sheet-media').replaceChildren(element('p', error.message, 'list-message inline-error')); return; }
drawSheet();
}
function sheetMediaUrl(kind) {
const { system, game, info } = sheetGame, entry = info?.kinds.find(k => k.id === kind);
return `/api/library/media?system=${encodeURIComponent(system.id)}&game=${encodeURIComponent(game.key)}&kind=${kind}&v=${entry?.changed || ''}`;
}
function drawSheetCover() {
const box = $('#sheet-cover'), { system, game, info } = sheetGame;
const has = info ? info.kinds.find(k => k.id === 'cover')?.present : game.media.includes('cover');
if (!has) { box.replaceChildren(icon('file')); return; }
const img = document.createElement('img'); img.alt = ''; img.src = info ? sheetMediaUrl('cover') : mediaUrl(system.id, game, 'cover');
img.addEventListener('error', () => img.replaceWith(icon('file'))); box.replaceChildren(img);
}
function drawSheet() {
const { info } = sheetGame, details = info.details;
// Details, when a source gave them.
const box = $('#sheet-details'); box.replaceChildren();
if (details.description) {
const text = element('p', details.description, 'description'); box.append(text);
if (details.description.length > 420) { const more = element('button', 'Read more', 'text-button'); more.type = 'button'; more.addEventListener('click', () => { text.classList.toggle('open'); more.textContent = text.classList.contains('open') ? 'Show less' : 'Read more'; }); box.append(more); }
}
const facts = DETAIL_NAMES.filter(([k]) => details[k]);
if (facts.length) {
const dl = element('dl');
for (const [k, label] of facts) dl.append(element('dt', label), element('dd', k === 'rating' ? `${Math.round(Number(details[k]) * 100)}%` : details[k]));
box.append(dl);
}
box.hidden = !box.childElementCount;
const present = info.kinds.filter(k => k.present).length;
$('#sheet-media-count').textContent = `${present} of ${info.kinds.length} kinds`;
$('#sheet-media').replaceChildren(...info.kinds.map(mediaRow));
drawSheetCover();
}
function mediaRow(kind) {
const row = element('div', undefined, `media-row-item${kind.present ? '' : ' missing'}`), preview = element('div', undefined, 'preview');
row.dataset.kind = kind.id;
if (!kind.present) preview.append(svgIcon(KIND_ICONS[kind.id] || KIND_ICONS.cover));
else if (kind.id === 'video') { const v = document.createElement('video'); v.src = sheetMediaUrl(kind.id); v.controls = true; v.preload = 'metadata'; v.muted = true; preview.append(v); }
else if (kind.type === 'pdf') { const a = element('a'); a.href = sheetMediaUrl(kind.id); a.target = '_blank'; a.rel = 'noopener'; a.append(svgIcon(KIND_ICONS.manual), element('span', 'Open PDF')); preview.append(a); }
else {
const img = document.createElement('img'); img.loading = 'lazy'; img.alt = `${kind.name}`; img.src = sheetMediaUrl(kind.id);
img.addEventListener('error', () => img.replaceWith(svgIcon(KIND_ICONS[kind.id] || KIND_ICONS.cover))); preview.append(img);
}
const what = element('div', undefined, 'what'), title = element('strong');
title.append(svgIcon(KIND_ICONS[kind.id] || KIND_ICONS.cover), element('span', kind.name));
const state = kind.present ? element('span', `${kind.type.toUpperCase()} · ${bytes(kind.bytes)}${kind.uploaded ? ' · your file, kept when downloading again' : ''}`, `state${kind.uploaded ? ' mine' : ''}`)
: element('span', `Missing · add a ${kind.accepts.join(', ').toUpperCase()} file`, 'state');
what.append(title, element('p', kind.description, 'muted'), state);
const actions = element('div', undefined, 'actions'), choose = element('button', kind.present ? 'Replace…' : 'Add…', 'secondary');
choose.type = 'button';
const input = document.createElement('input'); input.type = 'file'; input.hidden = true;
input.accept = kind.accepts.map(type => '.' + type).join(',');
choose.addEventListener('click', () => input.click());
input.addEventListener('change', () => { if (input.files[0]) uploadMedia(kind, input.files[0], row); input.value = ''; });
actions.append(choose, input);
// A file dropped on the row replaces that kind.
row.addEventListener('dragover', event => { event.preventDefault(); row.classList.add('drop'); });
row.addEventListener('dragleave', () => row.classList.remove('drop'));
row.addEventListener('drop', event => { event.preventDefault(); row.classList.remove('drop'); const file = event.dataTransfer.files[0]; if (file) uploadMedia(kind, file, row); });
row.append(preview, what, actions);
return row;
}
function uploadMedia(kind, file, row) {
const type = (file.name.split('.').pop() || '').toLowerCase();
const state = row.querySelector('.state');
if (!kind.accepts.includes(type)) { state.textContent = `${kind.name} takes ${kind.accepts.join(', ').toUpperCase()} files (what every frontend can show).`; state.className = 'state inline-error'; return; }
const { system, game } = sheetGame, bar = document.createElement('progress'); bar.max = 100; bar.value = 0;
row.classList.add('busy'); state.textContent = `Sending ${file.name} · ${bytes(file.size)}`; state.className = 'state'; state.after(bar);
const request = new XMLHttpRequest();
request.open('PUT', `/api/library/media?system=${encodeURIComponent(system.id)}&game=${encodeURIComponent(game.key)}&kind=${kind.id}&type=${encodeURIComponent(type)}`);
request.setRequestHeader('X-RetroArch-Token', token);
request.upload.addEventListener('progress', event => { if (event.lengthComputable) bar.value = event.loaded / event.total * 100; });
request.addEventListener('load', () => {
let data = {}; try { data = JSON.parse(request.responseText); } catch {}
if (request.status !== 201) { row.classList.remove('busy'); bar.remove(); state.textContent = data.error || `The upload failed (${request.status}). Try again.`; state.className = 'state inline-error'; return; }
sheetGame.info = data;
// The card and its media list follow, for every frontend reads the same file.
game.media = data.kinds.filter(k => k.present).map(k => k.id); game.scraped = String(Date.now());
drawSheet(); drawGames();
announce(`${kind.name} of ${gameName(game)} replaced.`);
});
request.addEventListener('error', () => { row.classList.remove('busy'); bar.remove(); state.textContent = 'The connection was lost. Try again.'; state.className = 'state inline-error'; });
request.send(file);
}
$('#sheet-close').addEventListener('click', () => $('#game-sheet').close());
$('#game-sheet').addEventListener('click', event => { if (event.target === $('#game-sheet')) $('#game-sheet').close(); });
$('#game-sheet').addEventListener('close', () => { for (const v of $$('#sheet-media video')) v.pause(); });
$('#games-search').addEventListener('input', () => { shownGames = 300; drawGames(); });
$('#refresh-games').addEventListener('click', loadGames);
+2
View File
@@ -44,6 +44,8 @@
</section>
<section id="transfers" class="page panel" hidden aria-labelledby="all-transfers-title"><div class="section-toolbar"><div><h2 id="all-transfers-title">Your transfers</h2><p>Uploads and downloads from this browser session</p></div><button id="clear-transfers" class="secondary">Clear finished</button></div><p class="help-text">Keep this page open until uploads finish. Downloads continue in your browser’s download manager.</p><div id="all-transfers" class="transfer-list"></div></section>
<section id="settings" class="page panel" hidden aria-labelledby="settings-heading"><div class="section-toolbar"><div><h2 id="settings-heading">Global RetroArch settings</h2><p>Manage shared preferences and individual core profiles.</p></div><button id="refresh-settings" class="secondary">Refresh settings</button></div><p class="settings-intro" id="settings-help">Saved changes apply the next time you open RetroArch. Game and folder overrides can take priority.</p><div class="settings-controls"><label>Profile<select id="settings-profile"><option value="">Global RetroArch</option></select></label><label id="settings-kind-label" hidden>Settings type<select id="settings-kind"><option value="core-options">Core options</option><option value="core-settings">RetroArch overrides</option></select></label><label class="settings-search">Find a setting<input id="settings-search" type="search" placeholder="Search settings and descriptions"></label></div><div class="settings-view-bar"><div class="settings-mode" role="group" aria-label="Settings mode"><button type="button" id="settings-guided" aria-pressed="true">Guided</button><button type="button" id="settings-advanced" aria-pressed="false">Advanced</button></div><span id="settings-mode-help">Clear explanations and ready-to-use choices.</span></div><nav id="settings-categories" aria-label="Setting categories"></nav><div class="settings-category-heading"><h3 id="settings-category-title"></h3><p id="settings-category-help"></p></div><p id="settings-coverage"></p><p id="settings-count" role="status"></p><form id="settings-form"><fieldset id="settings-fields" disabled><legend class="visually-hidden">Settings for selected profile</legend></fieldset><div class="settings-paging"><button id="settings-previous" class="secondary" type="button">Previous</button><span id="settings-page"></span><button id="settings-next" class="secondary" type="button">Next</button></div><div class="form-actions settings-actions"><button type="submit" id="save-settings" class="primary" disabled>Save changes</button><span id="settings-result" role="status"></span></div></form></section>
<dialog id="game-sheet" class="game-sheet" aria-labelledby="sheet-title"><div class="sheet-head"><div class="sheet-cover" id="sheet-cover"></div><div class="sheet-heading"><h2 id="sheet-title"></h2><div id="sheet-tags" class="version-tags"></div><p id="sheet-sub" class="muted"></p></div><button type="button" id="sheet-close" class="sheet-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button></div>
<div class="sheet-body"><section id="sheet-details" class="sheet-details" hidden></section><section aria-labelledby="sheet-media-title"><div class="sheet-media-head"><h3 id="sheet-media-title">Media</h3><p id="sheet-media-count" class="muted"></p></div><p class="muted sheet-hint">Every frontend shows these files. To use your own, choose Replace or drop a file on its row; a file you add is kept when media is downloaded again.</p><div id="sheet-media" class="sheet-media"></div></section></div></dialog>
<dialog id="sign-in" class="sign-in" aria-labelledby="sign-in-title"><form id="sign-in-form" method="dialog" novalidate><div class="sign-in-head"><span id="sign-in-logo"></span><div><h2 id="sign-in-title">Sign in to ScreenScraper</h2><p class="muted">Your free screenscraper.fr account. It is checked with ScreenScraper, then kept on your PS5 only: this page never sees your password again.</p></div></div>
<div id="sign-in-fields"><label>Name<input id="sign-in-user" name="username" autocomplete="username" autocapitalize="off" spellcheck="false" required maxlength="128"></label><label>Password<input id="sign-in-password" name="password" type="password" autocomplete="current-password" required maxlength="256"></label><p class="muted sign-in-note">No account? <a href="https://www.screenscraper.fr/membreinscription.php" target="_blank" rel="noopener noreferrer">Create one for free</a>. A contributor or donor account allows more downloads a day and at once.</p></div>
<div id="sign-in-account" class="sign-in-account" hidden><p><strong id="sign-in-who"></strong></p><dl id="sign-in-quota" class="sign-in-quota"></dl></div>