diff --git a/src/scraper.cpp b/src/scraper.cpp index b6648d6..4f2e99f 100644 --- a/src/scraper.cpp +++ b/src/scraper.cpp @@ -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 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 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 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 &accepted_types(const std::string &kind) +{ + static const std::vector 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(); diff --git a/src/scraper.h b/src/scraper.h index ad58862..00ab9ce 100644 --- a/src/scraper.h +++ b/src/scraper.h @@ -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); diff --git a/src/webui_ps5.cpp b/src/webui_ps5.cpp index 6f3e78d..de35d24 100644 --- a/src/webui_ps5.cpp +++ b/src/webui_ps5.cpp @@ -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); diff --git a/tests/test_scraper.py b/tests/test_scraper.py index d9a7d6d..e79fc46 100644 --- a/tests/test_scraper.py +++ b/tests/test_scraper.py @@ -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()), diff --git a/tests/webui_scraper_browser.cjs b/tests/webui_scraper_browser.cjs index 8427cc9..3ce485a 100644 --- a/tests/webui_scraper_browser.cjs +++ b/tests/webui_scraper_browser.cjs @@ -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); }); diff --git a/webui/app.css b/webui/app.css index 49b571f..5382cd6 100644 --- a/webui/app.css +++ b/webui/app.css @@ -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}} diff --git a/webui/app.js b/webui/app.js index 4ad29f9..81e74b8 100644 --- a/webui/app.js +++ b/webui/app.js @@ -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); diff --git a/webui/index.html b/webui/index.html index f50f9e3..2a3feb4 100644 --- a/webui/index.html +++ b/webui/index.html @@ -44,6 +44,8 @@ +

+

Media

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.